From 308d22cbadc84e45102c23b97d95205dbe81c7b4 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Wed, 28 Oct 2026 09:56:22 +0100 Subject: [PATCH 01/59] M10: Enhance scanned file cleans scanned pages in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan & OCR ▸ Enhance scanned file (Acrobat's Enhance ▸ Scanned document) now cleans up scanned pages: background removal (the camera clean-up's level 0-100), edge shadow removal (book gutters, copier borders) and despeckle, each its own switch. The camera tool renders the page and replaces it with a picture, which would drop a scan's invisible OCR text, comments and links. Instead each scan image (pictures covering at least half the page, each at least a tenth) is decoded, cleaned and written back into its own image object at the same size and colour space: a JPEG stays a JPEG (quality 90), other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit with its decode array, and every other dictionary entry (SMask, Interpolate, ...) is kept. An image several pages share is cleaned once. What can't be cleaned (CMYK, indexed, JPEG 2000, JBIG2, CCITT, colour-key masks, broken data, oversized images) is reported, never skipped silently; pages that are no scan are listed too. The engine works as a job like Optimize (scan_clean_job, run on a worker with progress and cancel, apply_scan_clean refuses a result for an older document state) and applies one undoable step, "Enhance scanned file". pdfcraft-optimize exposes decode_samples/encode_samples for in-place pixel edits. camera_clean gains clean_samples for grey and RGB samples with separate switches and an edge-shadow pass; the camera tool's output is unchanged. page_enhance_scan drives it headlessly. Not yet an option of Recognize text. Refs #4119. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/automation/src/dispatch.rs | 29 ++ crates/automation/src/tools.rs | 12 + crates/automation/tests/it/automation.rs | 56 +++ crates/engine/src/camera_clean.rs | 235 ++++++++++-- crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/edit.rs | 7 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/scan_clean.rs | 276 ++++++++++++++ crates/engine/src/scan_clean/tests.rs | 338 ++++++++++++++++++ crates/engine/src/scan_fields.rs | 23 +- crates/optimize/src/images.rs | 10 +- crates/optimize/src/lib.rs | 2 + crates/optimize/src/samples.rs | 273 ++++++++++++++ crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/app/frame.rs | 1 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/i18n/de.tsv | 19 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/src/workflows.rs | 1 + crates/ui-egui/src/workflows/scan_clean_ui.rs | 253 +++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/scan_clean.rs | 77 ++++ docs/gaps.md | 5 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- parity/acrobat-features.toml | 24 +- 34 files changed, 1613 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/scan_clean.rs create mode 100644 crates/engine/src/scan_clean/tests.rs create mode 100644 crates/optimize/src/samples.rs create mode 100644 crates/ui-egui/src/workflows/scan_clean_ui.rs create mode 100644 crates/ui-egui/tests/it/scan_clean.rs diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..91e8bf1ece 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -200,6 +201,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 87f47e8ebe..b8dfd36388 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -106,6 +106,35 @@ impl Automation { out["detected"] = json!(given.is_none()); out } + "page_enhance_scan" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => Vec::new(), + }; + let level = match a.opt_int("level")? { + None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level); + if let Some(on) = a.opt_bool("edge_shadows")? { + settings.edge_shadows = on; + } + if let Some(on) = a.opt_bool("despeckle")? { + settings.despeckle = on; + } + let id = self.doc(&a)?.id; + let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?; + let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>(); + let skipped: Vec = + report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect(); + let mut out = summary(self.doc(&a)?); + out["pages_cleaned"] = json!(numbered(&report.pages)); + out["images"] = json!(report.images); + out["not_scanned"] = json!(numbered(&report.not_scanned)); + out["skipped"] = json!(skipped); + out + } "page_delete" => { let pages = self.pages(&a, "pages")?; self.apply(&a, Edit::DeletePages { pages })? diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b4ac8873b..0d34911154 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -225,6 +225,18 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, broken data, or nothing to change). One undoable step; none when nothing changed.") + .cmd("ocr.enhance") + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to clean up (default: all)"), + "level": { "type": "integer", "minimum": 0, "maximum": 100 }, + "edge_shadows": { "type": "boolean" }, + "despeckle": { "type": "boolean" }, + }), + &["doc"], + )), t("page_delete", "Delete pages", "Delete pages. Undoable until saved.") .destructive() .cmd("page.delete") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 78038d8ebd..c6616f11f3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3376,6 +3376,62 @@ fn enhance_camera_image_through_tools() { )); } +#[test] +fn enhance_scanned_file_cleans_the_scan_in_place_through_tools() { + let dir = workdir("enhance-scan"); + // A 300 × 400 grey scan: dull paper, a dark gutter at the left, text bars and a speck. + let img = image::GrayImage::from_fn(300, 400, |x, y| { + let light = 0.55 + 0.45 * (x as f32 / 60.0).min(1.0); + let ink = if ((60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x)) || (x, y) == (150, 25) { 0.12 } else { 0.78 }; + image::Luma([(ink * light * 255.0) as u8]) + }); + img.save(dir.join("scan.jpg")).unwrap(); + let mut a = auto(&dir); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.jpg"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "before.jpg" })); + + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "level": 100 })); + assert_eq!(r["undo"], "Enhance scanned file"); + assert_eq!((r["pages_cleaned"].clone(), r["images"].clone(), r["not_scanned"].clone()), (json!([1]), json!(1), json!([]))); + assert_eq!(r["skipped"], json!([])); + // In place: still one picture of the same size, still a JPEG, now with white paper. + let images = ok(&mut a, "page_images", json!({ "doc": scan, "page": 1 })); + assert_eq!((images["count"].clone(), images["images"][0]["pixels"].clone()), (json!(1), json!([300, 400]))); + let saved = ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "after" })); + assert_eq!(saved["format"], "jpg"); + let (before, after) = (image::open(dir.join("before.jpg")).unwrap().to_luma8(), image::open(dir.join("after.jpg")).unwrap().to_luma8()); + for (x, y) in [(150, 40), (3, 40), (3, 380), (150, 25)] { + assert!( + before.get_pixel(x, y)[0] < 200 && after.get_pixel(x, y)[0] >= 230, + "({x}, {y}): {:?} → {:?}", + before.get_pixel(x, y), + after.get_pixel(x, y) + ); + } + assert!(after.get_pixel(150, 62)[0] <= 60, "a stroke stays dark"); + // Undo puts the scan back as it was. + ok(&mut a, "edit_undo", json!({ "doc": scan })); + ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "undone.jpg" })); + assert_eq!(std::fs::read(dir.join("undone.jpg")).unwrap(), std::fs::read(dir.join("before.jpg")).unwrap()); + + // Switches: despeckle alone leaves everything but the speck. + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "pages": [1], "level": 0, "despeckle": true, "edge_shadows": false })); + assert_eq!(r["images"], 1); + // A document of text pages has no scans: nothing changes, and it says so. + let text = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": text })); + assert_eq!((r["images"].clone(), r["not_scanned"].clone(), r["undo"].clone()), (json!(0), json!([1, 2, 3]), Value::Null)); + // Bad arguments are refused before anything changes. + for bad in [ + json!({ "doc": scan, "level": 101 }), + json!({ "doc": scan, "pages": [9] }), + json!({ "doc": scan, "despeckle": "yes" }), + json!({ "doc": scan, "pages": [] }), + ] { + assert!(matches!(a.call("page_enhance_scan", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn enhance_camera_image_finds_the_edges_by_itself() { let dir = workdir("enhance-camera-auto"); diff --git a/crates/engine/src/camera_clean.rs b/crates/engine/src/camera_clean.rs index 9c4120be56..d1768a2a83 100644 --- a/crates/engine/src/camera_clean.rs +++ b/crates/engine/src/camera_clean.rs @@ -1,6 +1,8 @@ -//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out -//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker -//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour. +//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level) or a +//! scanned page's picture (Enhance ▸ Scanned document, [`crate::scan_clean`]): even out the +//! lighting so the paper turns white (room-light colour casts, a hand's shadow, darker edges and +//! book gutters go with it), then raise the contrast and drop isolated specks. Colour ink stays +//! colour. //! //! The paper's illumination is estimated per channel on a coarse grid of blocks, from each //! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out. @@ -8,6 +10,8 @@ /// The strongest a level can be. pub const MAX_LEVEL: u8 = 100; +/// The level from which [`clean`] also drops isolated specks. +pub const DESPECKLE_FROM: u8 = 60; /// How an enhanced camera page looks. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -66,34 +70,158 @@ pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Opti let even = clean(rgb, width, height, 50)?; let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32; let mut grey: Vec = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect(); - despeckle_grey(&mut grey, w, h); + despeckle(&mut grey, 1, w, h); Pixels::Grey(grey) } }) } -/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest). -/// Returns `None` when the buffer doesn't match the size. +/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest), from +/// [`DESPECKLE_FROM`] without isolated specks. Returns `None` when the buffer doesn't match the +/// size. pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> { + clean_samples(rgb, 3, width, height, CleanUp { level, edge_shadows: false, despeckle: level >= DESPECKLE_FROM }) +} + +/// What [`clean_samples`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CleanUp { + /// 0 (unchanged) … 100: even out the paper's illumination and raise the contrast, as + /// [`clean`]'s level. + pub level: u8, + /// Even out shadows along the picture's edges first (a book's gutter, a copier's dark edge): + /// lightened line by line to the paper beside them, within the outer fifth of each side. + pub edge_shadows: bool, + /// Drop isolated dark specks. + pub despeckle: bool, +} + +/// [`clean`] for samples of `n` components (1: grey, 3: RGB), with each step chosen apart (each +/// works at level 0 too). Returns `None` when `n` is neither or the buffer doesn't match the size. +pub fn clean_samples(px: &[u8], n: usize, width: u32, height: u32, how: CleanUp) -> Option> { let (w, h) = (width as usize, height as usize); - if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() { + if !(n == 1 || n == 3) || w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(n)? != px.len() { return None; } - let level = level.min(MAX_LEVEL); - if level == 0 { - return Some(rgb.to_vec()); + let level = how.level.min(MAX_LEVEL); + let mut out = px.to_vec(); + if how.edge_shadows { + even_edges(&mut out, n, w, h); } + if level > 0 { + out = even_out(&out, n, w, h, level); + } + if how.despeckle { + despeckle(&mut out, n, w, h); + } + Some(out) +} + +/// The share of the width (and of the height) at each side where [`even_edges`] works. +const EDGE_BAND: f32 = 0.2; +/// The most [`even_edges`] lightens a line by; a darker line (a scanner's black border, a +/// picture) is not taken for shadowed paper and stays as it is. +const MAX_EDGE_GAIN: f32 = 3.0; + +/// Even out shadows that run along the picture's edges, in place: a book's gutter, a copier's +/// dark border, a page that curved away from the glass. Within the outer [`EDGE_BAND`] of each +/// side, each column (then each row) gets the paper's level along it (per channel, its 90th +/// percentile, so the text across it doesn't count), and is lightened to the brightest paper of +/// that band. The block grid of [`background`] is too coarse for such a narrow shadow: its +/// blocks borrow the brighter paper next to them. +fn even_edges(px: &mut [u8], n: usize, w: usize, h: usize) { + for vertical in [true, false] { + // Columns: lines across the width, each running down the height; rows the other way. + let (lines, along) = if vertical { (w, h) } else { (h, w) }; + let band = ((lines as f32 * EDGE_BAND) as usize).min(lines / 2); + if band == 0 { + continue; + } + let at = |line: usize, i: usize| if vertical { i * w + line } else { line * w + i }; + // The paper's level of every line in the bands and a few beyond them. + let reach = band.saturating_add(3).min(lines); + let measured = |line: usize| line < reach || line >= lines.saturating_sub(reach); + let mut level: Vec> = vec![None; lines]; + let mut hist = [[0u32; 256]; 3]; + for (line, slot) in level.iter_mut().enumerate().filter(|(line, _)| measured(*line)) { + hist.iter_mut().for_each(|hh| hh.fill(0)); + for i in 0..along { + let p = at(line, i) * n; + for (c, hh) in hist.iter_mut().enumerate().take(n) { + if let Some(v) = px.get(p + c) + && let Some(count) = hh.get_mut(*v as usize) + { + *count += 1; + } + } + } + let want = (along as f32 * 0.9) as u32; + *slot = Some(std::array::from_fn(|c| { + let mut seen = 0; + let v = hist.get(c).and_then(|hh| { + hh.iter().position(|&count| { + seen += count; + seen > want + }) + }); + v.unwrap_or(255) as f32 / 255.0 + })); + } + // Smoothed: the median of five neighbouring lines (fewer at the very edge, centred all + // the same, so a gradient isn't shifted), so one odd line (a rule, a fold) doesn't stand + // out. + let smooth: Vec> = (0..lines) + .map(|line| { + level.get(line).copied().flatten()?; + let r = line.min(lines - 1 - line).min(2); + Some(std::array::from_fn(|c| { + let mut near: Vec = (line - r..=line + r).filter_map(|l| level.get(l).copied().flatten()).map(|v| v[c]).collect(); + near.sort_by(f32::total_cmp); + near.get(near.len() / 2).copied().unwrap_or(1.0) + })) + }) + .collect(); + // Each side's band, and the lines its paper is measured on (the band and a few beyond). + for (shadowed, measured) in [(0..band, 0..reach), (lines - band..lines, lines.saturating_sub(reach)..lines)] { + // The brightest paper there: what the shadow is lightened to. + let paper: [f32; 3] = + std::array::from_fn(|c| measured.clone().filter_map(|l| smooth.get(l).copied().flatten()).map(|v| v[c]).fold(0.0, f32::max)); + for line in shadowed { + let Some(own) = smooth.get(line).copied().flatten() else { continue }; + let gain: [f32; 3] = std::array::from_fn(|c| { + let g = paper[c] / own[c].max(1e-3); + if (1.0..=MAX_EDGE_GAIN).contains(&g) { g } else { 1.0 } + }); + if gain.iter().take(n).all(|&g| g == 1.0) { + continue; + } + for i in 0..along { + let p = at(line, i) * n; + for (c, g) in gain.iter().enumerate().take(n) { + if let Some(v) = px.get_mut(p + c) { + *v = (*v as f32 * g).round().clamp(0.0, 255.0) as u8; + } + } + } + } + } + } +} + +/// The lighting evened out and the contrast raised at `level` (1–100); `px` holds `w` × `h` +/// pixels of `n` components. +fn even_out(px: &[u8], n: usize, w: usize, h: usize, level: u8) -> Vec { let k = level as f32 / MAX_LEVEL as f32; - let background = background(rgb, w, h); + let background = background(px, n, w, h); // Lighting is evened out fully from half strength on; contrast keeps growing to the top. let flatten = (2.0 * k).min(1.0); let white = 1.0 - 0.12 * k; // what reaches white let black = 0.30 * k; // what reaches black - let mut out = Vec::with_capacity(rgb.len()); - for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() { + let mut out = Vec::with_capacity(px.len()); + for (i, p) in px.chunks_exact(n).enumerate() { let (x, y) = (i % w, i / w); let bg = background.at(x, y); - for (c, &v) in px.iter().enumerate() { + for (c, &v) in p.iter().enumerate() { let v = v as f32 / 255.0; let b = bg.get(c).copied().unwrap_or(1.0).max(0.05); let even = (v / b).min(1.0); @@ -102,10 +230,7 @@ pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> out.push((stretched * 255.0).round() as u8); } } - if level >= 60 { - despeckle(&mut out, w, h); - } - Some(out) + out } /// The paper's illumination: a grid of per-channel values, read back with bilinear @@ -140,9 +265,10 @@ impl Background { } } -/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright -/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed. -fn background(rgb: &[u8], w: usize, h: usize) -> Background { +/// Estimate the paper's illumination: per block, the 90th percentile of each of the `n` channels +/// (bright paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then +/// smoothed. Channels past `n` stay white. +fn background(px: &[u8], n: usize, w: usize, h: usize) -> Background { let block = (w.min(h) / 16).clamp(8, 128); let (cols, rows) = (w.div_ceil(block), h.div_ceil(block)); let mut cells = Vec::with_capacity(cols * rows); @@ -150,20 +276,20 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { for by in 0..rows { for bx in 0..cols { hist.iter_mut().for_each(|hh| hh.fill(0)); - let mut n = 0u32; + let mut count = 0u32; for y in by * block..((by + 1) * block).min(h) { for x in bx * block..((bx + 1) * block).min(w) { - if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) { + if let Some(p) = px.get((y * w + x) * n..(y * w + x) * n + n) { for (c, &v) in p.iter().enumerate() { if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) { *slot += 1; } } - n += 1; + count += 1; } } } - let want = (n as f32 * 0.9) as u32; + let want = (count as f32 * 0.9) as u32; cells.push(std::array::from_fn(|c| { let mut seen = 0; let v = hist.get(c).and_then(|hh| { @@ -219,19 +345,15 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { Background { cols, rows, block: block as f32, cells } } -/// [`despeckle`] for one grey byte per pixel. -fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) { - let mut rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); - despeckle(&mut rgb, w, h); - for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) { - *g = p[0]; - } -} - -/// Lighten dark pixels with at most one dark neighbour (specks), in place. -fn despeckle(rgb: &mut [u8], w: usize, h: usize) { - let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000; - let dark: Vec = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect(); +/// Lighten dark pixels with at most one dark neighbour (specks), in place; `px` holds `w` × `h` +/// pixels of `n` components (1 or 3). +fn despeckle(px: &mut [u8], n: usize, w: usize, h: usize) { + let lum = |p: &[u8]| match p { + [r, g, b] => (*r as u32 * 299 + *g as u32 * 587 + *b as u32 * 114) / 1000, + [v, ..] => *v as u32, + [] => 255, + }; + let dark: Vec = px.chunks_exact(n.max(1)).map(|p| lum(p) < 128).collect(); let is_dark = |x: isize, y: isize| { x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false) }; @@ -243,7 +365,7 @@ fn despeckle(rgb: &mut [u8], w: usize, h: usize) { let neighbours = (-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count(); if neighbours <= 1 - && let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3) + && let Some(p) = px.get_mut((y as usize * w + x as usize) * n..(y as usize * w + x as usize) * n + n) { p.fill(255); } @@ -388,5 +510,40 @@ mod tests { assert_eq!(clean(&[], 0, 0, 50), None); assert_eq!(clean(&[0; 12], 2, 3, 50), None); assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3)); + let every = CleanUp { level: 100, edge_shadows: true, despeckle: true }; + assert_eq!(clean_samples(&[0; 12], 2, 2, 3, every), None, "two components"); + assert_eq!(clean_samples(&[0; 5], 1, 2, 3, every), None); + for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 2), (5, 3)] { + assert_eq!(clean_samples(&vec![100; w * h], 1, w as u32, h as u32, every).map(|v| v.len()), Some(w * h)); + } + } + + #[test] + fn edge_shadows_lighten_shadowed_paper_but_not_a_black_border_or_the_middle() { + // Grey paper (200) with a shadow darkening the left 20 columns to 120, a black scanner + // border in the right 8 columns, and a dark block in the middle. + let (w, h) = (100usize, 60usize); + let mut grey = vec![200u8; w * h]; + for y in 0..h { + for x in 0..w { + let v = if x >= w - 8 { + 5 + } else if (40..60).contains(&x) && (20..40).contains(&y) { + 30 + } else if x < 20 { + 120 + (80 * x / 20) as u8 + } else { + 200 + }; + grey[y * w + x] = v; + } + } + let out = clean_samples(&grey, 1, w as u32, h as u32, CleanUp { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + for x in [0, 5, 19] { + assert!(out[30 * w + x].abs_diff(200) <= 3, "column {x}: {} → {}", grey[30 * w + x], out[30 * w + x]); + } + assert_eq!(out[30 * w + w - 3], 5, "a black border is no shadow"); + assert_eq!(out[30 * w + 50], 30, "the middle is untouched"); + assert_eq!(out[30 * w + 30], 200); } } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..47394a7bb5 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -265,7 +265,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("In this file", "scan-text", "ocr.recognize", Ready), item("In multiple files", "files", "ocr.recognize_batch", Ready), - item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")), + item("Enhance scanned file", "sparkles", "ocr.enhance", Ready), item("Enhance camera image", "camera", "ocr.enhance_camera", Ready), item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..8c83996c6d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -352,6 +352,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"), c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"), + c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"), c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index c31984ca19..655a1ce558 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -375,6 +375,13 @@ pub enum Edit { page: usize, words: Vec, }, + /// Scan & OCR ▸ Enhance ▸ Scanned document: put the scan images + /// [`ScanCleanJob::run`](crate::scan_clean::ScanCleanJob::run) cleaned in place of the old + /// ones (each object must still be an image). Built by + /// [`Session::apply_scan_clean`](crate::Session::apply_scan_clean). + EnhanceScan { + images: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Stream)>, + }, /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on /// `page`, in its own font when it can show it, else in Helvetica. EditTextLine { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index ea25898068..ff86f21a08 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -98,6 +98,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Set alternate text", "Mark figure as decorative", "Recognize text", + "Enhance scanned file", "Run JavaScript", "Run form script", "Save as {level}", @@ -239,6 +240,7 @@ impl Edit { Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), Edit::XfaEvent { .. } => Label::of("Run form script"), // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 49f19fa637..e2a99665aa 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -121,6 +121,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MarkDecorative { .. } | Edit::MarkTagArtifact { .. } | Edit::AddOcrText { .. } + | Edit::EnhanceScan { .. } | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } | Edit::RemoveStandardId { .. } @@ -491,6 +492,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; } + Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?, Edit::EditPageImage { page, index, change } => { let img = pdfcraft_edit::page_images(doc, *page)? .into_iter() diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..0fe874e711 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -77,6 +77,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::EditPageImage { .. } + | Edit::EnhanceScan { .. } | Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 889b839368..9e2eb54389 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -43,6 +43,7 @@ mod pictures; pub mod print_raster; pub mod printcheck; mod protection; +pub mod scan_clean; mod scan_colours; mod scan_fields; mod session; diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs new file mode 100644 index 0000000000..bfc8188b29 --- /dev/null +++ b/crates/engine/src/scan_clean.rs @@ -0,0 +1,276 @@ +//! Scan & OCR ▸ Enhance ▸ Scanned document: clean up the pictures scanned pages are made of. +//! +//! Unlike Enhance ▸ Camera image, the page is not rendered and replaced: each scan image is +//! decoded, cleaned ([`crate::camera_clean::clean_samples`]) and written back **into the same +//! image object**, at the same size and in the same colour space. So everything else on the +//! page stays as it was: an invisible OCR text layer, comments, links, form fields, other +//! content, and the image's own soft mask, `/Interpolate` and other entries. A JPEG stays a JPEG +//! (at quality 90), any other grey or RGB image is stored losslessly (Flate), and a black and +//! white (1-bit) image stays 1-bit. An image several pages draw is cleaned once. +//! +//! A page is a scan when its pictures cover at least half of it (as for Recognize text); the +//! pictures each covering at least a tenth of the page are its scan. What is not cleaned is +//! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft +//! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping +//! decode array) or whose data is broken. +//! +//! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, +//! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] +//! makes the result one undoable step, "Enhance scanned file" (the name the Scan & OCR tool has). + +use pdfcraft_cos::{ObjRef, Object, Stream}; + +use crate::camera_clean::{CleanUp, MAX_LEVEL, clean_samples}; +use crate::edit::check_permission; +use crate::{DocId, Edit, EditError, Session, guard, optimize}; + +pub use crate::camera_clean::DESPECKLE_FROM; + +/// The JPEG quality a cleaned JPEG scan is written back at (as Enhance camera image). +const QUALITY: u8 = 90; +/// On a scanned page, an image covering at least this share of the page is part of the scan; +/// smaller ones (a logo or a signature pasted on) are left alone. +const PART_OF_SCAN: f64 = 0.1; + +/// How scanned pages are cleaned up. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScanCleanSettings { + /// Background removal, 0 (off) … 100 (strongest), as Enhance camera image's clean-up level: + /// the paper's illumination is evened out (fully from 50), so a grey, yellowed or unevenly lit + /// background turns white; then the contrast is raised so faint strokes darken. Colour ink + /// stays colour. + pub level: u8, + /// Edge shadow removal: shadows along the scan's edges (a book's gutter, a copier's dark + /// border) are lightened to the paper next to them, line by line, before the level applies. + pub edge_shadows: bool, + /// Despeckle: isolated dark specks (dust, scanner noise), dark pixels with at most one dark + /// neighbour, turn white. The only change a black and white (1-bit) scan takes. + pub despeckle: bool, +} + +impl ScanCleanSettings { + /// The level `level` (capped at 100) with edge shadow removal (unless the level is 0), and + /// despeckling from [`DESPECKLE_FROM`] as the camera tool does. + pub fn at(level: u8) -> Self { + let level = level.min(MAX_LEVEL); + Self { level, edge_shadows: level > 0, despeckle: level >= DESPECKLE_FROM } + } + + /// Whether these settings change anything at all. + pub fn changes_something(self) -> bool { + self.level > 0 || self.edge_shadows || self.despeckle + } +} + +impl Default for ScanCleanSettings { + fn default() -> Self { + Self::at(50) + } +} + +/// What a clean-up did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScanCleanReport { + /// Pages (0-based) whose scan was cleaned, in order. + pub pages: Vec, + /// Images rewritten. One drawn on several pages counts once. + pub images: usize, + /// Pages left alone because no picture covers most of them. + pub not_scanned: Vec, + /// Scan images left as they were, and why. + pub skipped: Vec, +} + +/// A scan image that was not cleaned. +#[derive(Clone, Debug, PartialEq)] +pub struct SkippedImage { + /// The pages (0-based) that draw it, among those asked for. + pub pages: Vec, + /// Its resource name on the first of them. + pub name: String, + /// Why, in a sentence that follows "skipped because". + pub reason: String, +} + +/// One image to clean: its object, its name and the pages that draw it. +struct Target { + object: ObjRef, + name: String, + pages: Vec, +} + +/// What cleaning scanned pages needs, independent of the session. +pub struct ScanCleanJob { + cos: pdfcraft_cos::Document, + targets: Vec, + settings: ScanCleanSettings, + /// The document's edit generation it was taken at: a result is for that state only. + generation: u64, + report: ScanCleanReport, +} + +/// The cleaned images, ready for [`Session::apply_scan_clean`], and the report. +pub struct ScanCleaned { + images: Vec<(ObjRef, Stream)>, + generation: u64, + pub report: ScanCleanReport, +} + +impl Session { + /// Capture what cleaning up the scans on `pages` (0-based; empty = all) of document `id` + /// needs. Errors when the document can't be changed (read-only, or its permissions forbid + /// changes) or a page doesn't exist. + pub fn scan_clean_job(&self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if let Some(why) = &doc.read_only_reason { + return Err(EditError::ReadOnly(why.clone())); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(String::new()))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&Edit::EnhanceScan { images: Vec::new() }, &p)?; + } + let all = doc.info.pages.len(); + let mut wanted: Vec = if pages.is_empty() { (0..all).collect() } else { pages.to_vec() }; + if let Some(p) = wanted.iter().find(|p| **p >= all) { + return Err(EditError::Invalid(format!("there is no page {}: the document has {all}", p.saturating_add(1)))); + } + wanted.sort_unstable(); + wanted.dedup(); + let mut report = ScanCleanReport::default(); + let mut targets: Vec = Vec::new(); + for page in wanted { + let Some(info) = doc.info.pages.get(page) else { continue }; + let Some(images) = crate::scan_fields::scan_images(doc, page, info) else { + report.not_scanned.push(page); + continue; + }; + for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { + let Some(object) = image.object else { + report.skipped.push(SkippedImage { + pages: vec![page], + name: image.name, + reason: "it is stored inside the page's resources rather than as an object of its own".into(), + }); + continue; + }; + match targets.iter_mut().find(|t| t.object == object) { + Some(t) if !t.pages.contains(&page) => t.pages.push(page), + Some(_) => {} + None => targets.push(Target { object, name: image.name, pages: vec![page] }), + } + } + } + Ok(ScanCleanJob { cos: editor.cos.clone(), targets, settings, generation: doc.edit_generation(), report }) + } + + /// Write the images [`ScanCleanJob::run`] cleaned into document `id` as one undoable step, + /// "Enhance scanned file" (none when nothing was cleaned), and return the report. Refused + /// when the document changed since the job was taken. + pub fn apply_scan_clean(&mut self, id: DocId, cleaned: ScanCleaned) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.edit_generation() != cleaned.generation { + return Err(EditError::Invalid("the document changed while its scans were being cleaned up; nothing was changed, try again".into())); + } + if !cleaned.images.is_empty() { + self.apply(id, Edit::EnhanceScan { images: cleaned.images })?; + } + Ok(cleaned.report) + } + + /// Enhance ▸ Scanned document in one call: [`Self::scan_clean_job`], run, then + /// [`Self::apply_scan_clean`] (the CLI and agents use this). + pub fn enhance_scanned_pages(&mut self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let job = self.scan_clean_job(id, pages, settings)?; + let cleaned = job.run(|_, _| true)?; + self.apply_scan_clean(id, cleaned) + } +} + +impl ScanCleanJob { + /// The images to clean (one drawn on several pages counts once). + pub fn total(&self) -> usize { + self.targets.len() + } + + /// Clean every scan image. `progress(done, total)` is called before each image; returning + /// `false` stops with [`EditError::Cancelled`]. A panic is returned as an error, so a worker + /// thread always reports back. + pub fn run(self, mut progress: impl FnMut(usize, usize) -> bool) -> Result { + guard(move || self.work(&mut progress)).map_err(|m| EditError::Invalid(format!("cleaning up the scans failed unexpectedly ({m})")))? + } + + fn work(self, progress: &mut dyn FnMut(usize, usize) -> bool) -> Result { + let ScanCleanJob { cos, targets, settings, generation, mut report } = self; + let total = targets.len(); + let mut images = Vec::new(); + let mut changed_pages: Vec = Vec::new(); + for (done, target) in targets.into_iter().enumerate() { + if !progress(done, total) { + return Err(EditError::Cancelled); + } + let mut skip = |reason: String| report.skipped.push(SkippedImage { pages: target.pages.clone(), name: target.name.clone(), reason }); + match clean_image(&cos, target.object, settings) { + Ok(Some(stream)) => { + images.push((target.object, stream)); + changed_pages.extend(target.pages.iter().copied()); + } + Ok(None) => skip("it is already clean: nothing changes at these settings".into()), + Err(reason) => skip(reason), + } + } + changed_pages.sort_unstable(); + changed_pages.dedup(); + report.images = images.len(); + report.pages = changed_pages; + report.skipped.sort_by_key(|s| s.pages.first().copied()); + Ok(ScanCleaned { images, generation, report }) + } +} + +/// Image `object` cleaned with `settings`: the new stream, `None` when nothing changes, or why it +/// can't be cleaned. +fn clean_image(cos: &pdfcraft_cos::Document, object: ObjRef, settings: ScanCleanSettings) -> Result, String> { + let obj = cos.get(object); + let Object::Stream(s) = &*obj else { return Err("it is not an image".into()) }; + if s.dict.name(b"Subtype") != Some(b"Image") { + return Err("it is not an image".into()); + } + // A colour-key mask names sample values to leave unpainted: cleaned samples would match others. + if matches!(s.dict.get(b"Mask").map(|m| cos.resolve(m)).as_deref(), Some(Object::Array(_))) { + return Err("it has a colour-key mask, which cleaning would change".into()); + } + let samples = optimize::decode_samples(cos, s).map_err(|e| e.to_string())?; + let cleaned = if samples.bilevel { + if !settings.despeckle { + return Err("it is black and white, which only Despeckle changes, and Despeckle is off".into()); + } + clean_samples(&samples.data, 1, samples.width, samples.height, CleanUp { level: 0, edge_shadows: false, despeckle: true }) + } else { + if !settings.changes_something() { + return Ok(None); + } + let how = CleanUp { level: settings.level, edge_shadows: settings.edge_shadows, despeckle: settings.despeckle }; + clean_samples(&samples.data, samples.components, samples.width, samples.height, how) + } + .ok_or("its samples don't match its size")?; + if cleaned == samples.data { + return Ok(None); + } + let cleaned = optimize::Samples { data: cleaned, ..samples }; + optimize::encode_samples(s, &cleaned, QUALITY).map(Some).ok_or_else(|| "it could not be written back".into()) +} + +/// [`Edit::EnhanceScan`]: put the cleaned images in place. Each object must still be an image. +pub(crate) fn write_back(doc: &mut pdfcraft_cos::Document, images: &[(ObjRef, Stream)]) -> Result<(), EditError> { + for (object, stream) in images { + let is_image = matches!(&*doc.get(*object), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")); + if !is_image { + return Err(EditError::Invalid(format!("object {} is no longer an image; the scan was not changed", object.num))); + } + doc.set(*object, Object::Stream(stream.clone())); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs new file mode 100644 index 0000000000..561ba9e2e4 --- /dev/null +++ b/crates/engine/src/scan_clean/tests.rs @@ -0,0 +1,338 @@ +use std::sync::Arc; + +use pdfcraft_testkit::Pdf; + +use super::*; + +const W: usize = 300; +const H: usize = 400; +/// Isolated specks on the paper, away from the text rows. +const SPECKS: [(usize, usize); 3] = [(150, 25), (200, 85), (120, 125)]; + +/// The light falling on the paper at `x`: full in the middle, darker towards a shadowed left +/// edge (as a book's gutter) and a little at the right edge. +fn light(x: usize) -> f32 { + let left = (x as f32 / 60.0).min(1.0); + let right = ((W - 1 - x) as f32 / 30.0).min(1.0); + (0.55 + 0.45 * left) * (0.8 + 0.2 * right) +} + +fn is_stroke(x: usize, y: usize) -> bool { + (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x) +} + +/// A synthetic scan, `n` components (1 grey, 3 RGB): greyish yellowed paper, a shadowed gutter, +/// dark text strokes and a few specks. +fn scan_pixels(n: usize) -> Vec { + let mut out = Vec::with_capacity(W * H * n); + for y in 0..H { + for x in 0..W { + let ink = if is_stroke(x, y) || SPECKS.contains(&(x, y)) { 0.12 } else { 0.8 }; + let v = ink * light(x); + let px = [v, v, v * 0.85]; + for c in px.iter().take(n) { + out.push((c * 255.0).round().clamp(0.0, 255.0) as u8); + } + } + } + out +} + +fn jpeg(px: &[u8], n: usize) -> Vec { + let mut out = Vec::new(); + let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; + image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), px, W as u32, H as u32, ty).unwrap(); + out +} + +/// Three pages: 1 a scan (the image `image_dict`/`data` as object 4) with an invisible text +/// layer and a comment, 2 a page of real text, 3 the same scan image drawn again. +fn scan_pdf(image_dict: &str, data: &[u8]) -> Vec { + let resources = "/Resources << /XObject << /Im0 4 0 R >> /Font << /F1 5 0 R >> >>"; + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 8 0 R 9 0 R] /Count 3 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 6 0 R /Annots [7 0 R] >>")) + .stream(&format!("/Type /XObject /Subtype /Image {image_dict}"), data) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q BT 3 Tr /F1 12 Tf 20 380 Td (Invisible OCR words) Tj ET")) + .obj("<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Contents (A comment) >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 10 0 R >>")) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 11 0 R >>")) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q")) + .build() +} + +fn grey_scan() -> Vec { + scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1)) +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, Arc::new(bytes), None).unwrap(); + (s, id) +} + +fn object(s: &Session, id: DocId, num: u32) -> Arc { + s.get(id).unwrap().editor.as_ref().unwrap().cos.get(ObjRef::new(num, 0)) +} + +/// The samples of the image page 1 draws, now. +fn samples(s: &Session, id: DocId) -> optimize::Samples { + let doc = s.get(id).unwrap(); + let object = doc.page_images(0)[0].object.unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + let Object::Stream(img) = &*cos.get(object) else { panic!("the image") }; + optimize::decode_samples(cos, img).unwrap() +} + +fn grey_at(px: &optimize::Samples, x: usize, y: usize) -> u8 { + px.data[(y * W + x) * px.components] +} + +#[test] +fn a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place() { + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + let kept: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + let text_before = s.get(id).unwrap().text_lines(0); + let original = s.get(id).unwrap().bytes.clone(); + + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.pages, vec![0, 2], "the shared image counts for both its pages"); + assert_eq!(report.images, 1); + assert_eq!(report.not_scanned, vec![1], "the text page is no scan"); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + + let after = samples(&s, id); + assert_eq!((after.width, after.height, after.components, after.jpeg), (W as u32, H as u32, 1, false)); + // Paper, also in the shadowed gutter and at the right edge, turns (nearly) white. + for (x, y) in [(150, 40), (150, 380), (3, 40), (3, 380), (30, 200), (W - 2, 40)] { + assert!(grey_at(&before, x, y) < 210, "the fixture's paper is grey at ({x}, {y})"); + assert!(grey_at(&after, x, y) >= 235, "paper at ({x}, {y}): {} → {}", grey_at(&before, x, y), grey_at(&after, x, y)); + } + // The edge is evened out: the gutter is as white as the middle. + assert!(grey_at(&after, 150, 40).abs_diff(grey_at(&after, 3, 40)) <= 12); + // Strokes stay dark (darker, with the contrast), also in the gutter's light. + for (x, y) in [(150, 62), (61, 102), (238, 342)] { + assert!(grey_at(&after, x, y) <= 40, "stroke at ({x}, {y}): {}", grey_at(&after, x, y)); + } + for (x, y) in SPECKS { + assert!(grey_at(&before, x, y) < 60); + assert!(grey_at(&after, x, y) >= 200, "speck at ({x}, {y}): {}", grey_at(&after, x, y)); + } + // Everything but the image is as it was: the page, its invisible text layer, the comment, the + // font, the other pages. The image keeps its entries. + let now: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + assert_eq!(kept, now); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + let text_after = s.get(id).unwrap().text_lines(0); + assert!(text_after.iter().any(|l| l.text.contains("Invisible OCR words")), "{text_after:?}"); + assert_eq!(text_before.len(), text_after.len()); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(img.dict.name(b"ColorSpace"), Some(&b"DeviceGray"[..])); + + // Undo brings the original image, and the original file, back. + s.undo(id).unwrap(); + assert_eq!(samples(&s, id), before); + assert_eq!(s.get(id).unwrap().bytes, original); + assert_eq!(s.get(id).unwrap().can_undo(), None); +} + +#[test] +fn a_jpeg_scan_stays_a_jpeg_in_colour() { + let px = scan_pixels(3); + let (mut s, id) = open(scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), &jpeg(&px, 3))); + let report = s.enhance_scanned_pages(id, &[0], ScanCleanSettings::at(80)).unwrap(); + // Only page 1 was asked for; the image it shares with page 3 is changed for both. + assert_eq!((report.pages.as_slice(), report.images), (&[0][..], 1)); + let after = samples(&s, id); + assert!(after.jpeg && after.components == 3); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..])); + // Yellowed paper becomes white; the strokes stay dark. + let at = |x: usize, y: usize| after.data[(y * W + x) * 3..][..3].to_vec(); + for (x, y) in [(150, 40), (5, 300)] { + assert!(at(x, y).iter().all(|&v| v >= 230), "paper at ({x}, {y}): {:?}", at(x, y)); + } + assert!(at(150, 63).iter().all(|&v| v <= 60), "stroke: {:?}", at(150, 63)); + // The file still renders. + let doc = s.get(id).unwrap(); + let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest { + page: 0, + scale: 1.0, + ..Default::default() + }); + assert!(shot.error.is_none(), "{:?}", shot.error); +} + +#[test] +fn despeckle_is_its_own_switch() { + let speck = |settings: ScanCleanSettings| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], settings).unwrap(); + let after = samples(&s, id); + SPECKS.iter().map(|&(x, y)| grey_at(&after, x, y)).max().unwrap_or(0) + }; + assert!(speck(ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(80) }) < 100, "kept without despeckle"); + assert!(speck(ScanCleanSettings::at(80)) >= 200); + // Despeckle alone, at level 0, changes nothing but the specks. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: true }).unwrap(); + let after = samples(&s, id); + let changed: Vec = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect(); + let mut specks: Vec = SPECKS.iter().map(|&(x, y)| y * W + x).collect(); + specks.sort_unstable(); + assert_eq!(changed, specks); + // Nothing to do: no undo step, and the report says why. + let (mut s, id) = open(grey_scan()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.pages.is_empty()); + assert_eq!(report.skipped.len(), 1); + assert!(report.skipped[0].reason.contains("already clean"), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_black_and_white_scan_stays_one_bit_and_loses_its_specks() { + // 64 × 64, one bit per pixel, 1 = white: a black bar and two specks. + let (w, h) = (64usize, 64usize); + let mut bits = vec![0xFFu8; w / 8 * h]; + let mut black = |x: usize, y: usize| bits[y * w / 8 + x / 8] &= !(0x80 >> (x % 8)); + for y in 20..30 { + for x in 10..50 { + black(x, y); + } + } + black(5, 5); + black(58, 58); + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 64 64] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>") + .stream("/Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 1", &bits) + .stream("", "q 64 0 0 64 0 0 cm /Im0 Do Q") + .build(); + let (mut s, id) = open(bytes); + // Only Despeckle changes a black and white scan: without it the image is reported. + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(100) }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.skipped.first().is_some_and(|k| k.reason.contains("black and white")), "{:?}", report.skipped); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 1); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.int(b"BitsPerComponent"), Some(1)); + let px = samples(&s, id); + assert!(px.bilevel); + let at = |x: usize, y: usize| px.data[y * w + x]; + assert_eq!((at(5, 5), at(58, 58)), (255, 255), "specks gone"); + assert_eq!((at(10, 20), at(30, 25), at(49, 29)), (0, 0, 0), "the bar stays"); + assert_eq!(px.data.iter().filter(|&&v| v == 0).count(), 40 * 10); +} + +#[test] +fn hostile_images_are_reported_not_a_panic() { + // Each page draws one image over the whole page. + let images: [(&str, Vec); 6] = [ + ("/Width 100000 /Height 100000 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![0; 64]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 3, 9]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![200; 1000]), + ("/Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![128]), + ("/Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8", vec![0; 256]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Mask [250 255]", vec![200; 300 * 400]), + ]; + let n = images.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", 3 + 3 * i)).collect(); + let mut pdf = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj(format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>")); + for (i, (dict, data)) in images.iter().enumerate() { + let (img, contents) = (4 + 3 * i, 5 + 3 * i); + pdf = pdf + .obj(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 {img} 0 R >> >> /Contents {contents} 0 R >>" + )) + .stream(&format!("/Type /XObject /Subtype /Image {dict}"), data) + .stream("", "q 300 0 0 400 0 0 cm /Im0 Do Q"); + } + let (mut s, id) = open(pdf.build()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 0); + assert_eq!(report.not_scanned, vec![3], "a 1 × 1 image is no scan"); + let why: Vec<(usize, &str)> = report.skipped.iter().map(|k| (k.pages[0], k.reason.as_str())).collect(); + assert_eq!(why.len(), 5, "{why:?}"); + assert!(why[0].0 == 0 && why[0].1.contains("80 million"), "{why:?}"); + assert!(why[1].0 == 1 && why[1].1.contains("could not be decoded"), "{why:?}"); + assert!(why[2].0 == 2 && why[2].1.contains("shorter"), "{why:?}"); + assert!(why[3].0 == 4 && why[3].1.contains("colour space"), "{why:?}"); + assert!(why[4].0 == 5 && why[4].1.contains("colour-key mask"), "{why:?}"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + // Pages that don't exist are an error, not a panic. + assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); +} + +#[test] +fn protected_documents_keep_their_encryption_and_restrictions() { + let protect = |user: &str, permissions: i32| { + let mut doc = pdfcraft_cos::Document::open(Arc::new(grey_scan())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) + }; + // Opened with the password: cleaned, saved still encrypted, and the clean scan reads back. + let mut s = Session::new(); + let id = s.open("x.pdf", None, protect("pw", -1), Some("pw")).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap().images, 1); + let saved = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected"); + let id2 = s.open("y.pdf", None, saved, Some("pw")).unwrap(); + assert!(grey_at(&samples(&s, id2), 3, 40) >= 235); + // Changes withheld: refused before any work. + let id = s.open("r.pdf", None, protect("", 0b0100), None).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::default()).unwrap_err(), EditError::NotPermitted("changes to the document")); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_result_for_an_older_state_is_refused() { + let (mut s, id) = open(grey_scan()); + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert_eq!(job.total(), 1); + let cleaned = job.run(|_, _| true).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + assert!(matches!(s.apply_scan_clean(id, cleaned), Err(EditError::Invalid(_)))); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Rotate page")); + // Cancelled from the progress callback. + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert!(matches!(job.run(|_, _| false), Err(EditError::Cancelled))); +} + +#[test] +fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() { + let gutter = |edge_shadows: bool| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { edge_shadows, ..ScanCleanSettings::at(100) }).unwrap(); + let after = samples(&s, id); + [(3, 40), (3, 380), (10, 200)].map(|(x, y)| grey_at(&after, x, y)) + }; + assert!(gutter(false).iter().any(|&v| v < 200), "{:?}", gutter(false)); + assert!(gutter(true).iter().all(|&v| v >= 235), "{:?}", gutter(true)); + // On its own, at level 0, it lightens the gutter's paper to the paper beside it. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + let after = samples(&s, id); + assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40)); + assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone"); +} diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 8287743fc5..769beaefca 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -25,24 +25,33 @@ const MIN_WORD_HEIGHT: f64 = 4.0; /// Whether `page` is a picture of a page: images cover most of it. pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + scan_images(doc, page, info).is_some() +} + +/// The raster images (larger than one pixel) `page` draws, each with the share of the page (its +/// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. +/// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). +pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); let page_area = (cx1 - cx0) * (cy1 - cy0); if !page_area.is_finite() || page_area <= 0.0 { - return false; + return None; } - let covered: f64 = doc + let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) - .iter() + .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) .map(|i| { let r = i.visible_rect.unwrap_or(i.rect); let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - w * h + let share = w * h / page_area; + (i, share) }) - .filter(|a| a.is_finite()) - .sum(); - covered >= COVERED * page_area + .filter(|(_, share)| share.is_finite()) + .collect(); + let covered: f64 = images.iter().map(|(_, share)| share).sum(); + (covered >= COVERED).then_some(images) } /// The page as displayed, in points with y up: where detection on a scan works. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..f19d7d8fc7 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -8,7 +8,7 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). -const MAX_PIXELS: u64 = 80_000_000; +pub(crate) const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { @@ -203,7 +203,7 @@ pub(crate) fn components(doc: &Document, cs: &Object) -> Option { } } -fn is_dct(s: &Stream) -> Option { +pub(crate) fn is_dct(s: &Stream) -> Option { match s.dict.get(b"Filter") { None => Some(false), Some(Object::Name(n)) => match n.as_slice() { @@ -288,7 +288,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { /// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where /// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters /// or the JPEG's segments can't be read. -fn ycc_encoded(doc: &Document, s: &Stream) -> Option { +pub(crate) fn ycc_encoded(doc: &Document, s: &Stream) -> Option { let entry = color_transform(doc, s)?; match adobe_transform(&s.raw)? { Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), @@ -412,7 +412,7 @@ pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> }) } -fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { +pub(crate) fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; @@ -435,7 +435,7 @@ pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes /// nothing. Scanners write it on page images (#490). -fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { +pub(crate) fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { let decode = doc.resolve(decode); let Some(a) = decode.as_array() else { return false }; a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0))) diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..cb6b537115 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -27,6 +27,7 @@ mod audit; pub mod fragments; mod images; mod links; +mod samples; mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -35,6 +36,7 @@ use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, ser use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; +pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs new file mode 100644 index 0000000000..7f402b51b8 --- /dev/null +++ b/crates/optimize/src/samples.rs @@ -0,0 +1,273 @@ +//! An image's samples read out and written back at the same size, for tools that change the +//! pixels of an image in place (Scan & OCR ▸ Enhance ▸ Scanned document). The image dictionary +//! stays as it was apart from the encoding: its size, colour space, masks, `/Interpolate`, +//! `/Intent`, `/Metadata` and every other entry are kept. + +use pdfcraft_cos::{Document, Object, Stream}; + +use crate::images::{MAX_PIXELS, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded}; + +/// An image's samples, one byte each, row-major. +#[derive(Clone, Debug, PartialEq)] +pub struct Samples { + pub width: u32, + pub height: u32, + /// 1 (grey) or 3 (RGB). + pub components: usize, + /// The samples. A bi-level image's are 0 (black) or 255 (white) as drawn, whatever its + /// decode array. + pub data: Vec, + /// Stored as a JPEG (written back as one); otherwise written back losslessly (Flate). + pub jpeg: bool, + /// One bit per pixel (written back as one). + pub bilevel: bool, + /// A bi-level image whose 1 bits are black (decode array `[1 0]`): written back the same way, + /// so its decode array stays true. + pub inverted: bool, +} + +/// Why an image's samples were not read. Each says what the image is, so a report can name it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum NotDecoded { + #[error("it is a stencil mask, not a picture")] + StencilMask, + #[error("its colour space is not grey or RGB (CMYK, indexed and special colours are not supported yet)")] + ColourSpace, + #[error("it has {0} bits per sample (8, and 1 for black and white, are supported)")] + BitDepth(i64), + #[error("its decode array remaps the samples")] + Decode, + #[error("it is compressed in a format PdfCraft cannot read back yet (JPEG 2000, JBIG2 or CCITT fax)")] + Filter, + #[error("its JPEG colour transform is ambiguous, so readers may disagree about its colours")] + Ambiguous, + #[error("it is larger than 80 million pixels")] + TooLarge, + #[error("its data could not be decoded, or is shorter than its size says")] + Corrupt, +} + +/// Read the samples of image stream `s` (a dictionary with `/Subtype /Image`). +pub fn decode_samples(doc: &Document, s: &Stream) -> Result { + let d = &s.dict; + if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { + return Err(NotDecoded::StencilMask); + } + let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; + let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); + let bilevel = match bpc { + 8 => false, + 1 if n == 1 => true, + other => return Err(NotDecoded::BitDepth(other)), + }; + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; + let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 { + return Err(NotDecoded::Corrupt); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(NotDecoded::TooLarge); + } + if bilevel { + if jpeg { + // A JPEG has 8 bits per sample: the dictionary contradicts the data. + return Err(NotDecoded::Corrupt); + } + let inverted = match d.get(b"Decode") { + None => false, + Some(a) if is_identity_decode(doc, a, 1) => false, + Some(a) if is_inverted_decode(doc, a) => true, + Some(_) => return Err(NotDecoded::Decode), + }; + let data = unpack_bits(s, width, height, inverted).ok_or(NotDecoded::Corrupt)?; + return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted }); + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) { + return Err(NotDecoded::Decode); + } + if jpeg && n == 3 && ycc_encoded(doc, s).is_none() { + return Err(NotDecoded::Ambiguous); + } + let data = pixels(doc, s, n).ok_or(NotDecoded::Corrupt)?; + Ok(Samples { width, height, components: n, data, jpeg, bilevel: false, inverted: false }) +} + +/// `[1 0]`: a bi-level image whose 1 bits are black. +fn is_inverted_decode(doc: &Document, decode: &Object) -> bool { + let decode = doc.resolve(decode); + matches!(decode.as_array().map(Vec::as_slice), Some([a, b]) if doc.resolve(a).as_f64() == Some(1.0) && doc.resolve(b).as_f64() == Some(0.0)) +} + +/// The bytes a `w` × `h` bi-level image takes: each row padded to whole bytes. +fn packed_len(w: u32, h: u32) -> Option { + usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?) +} + +/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn. +fn unpack_bits(s: &Stream, w: u32, h: u32, inverted: bool) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let need = packed_len(w, h)?; + // Some writers pad the data; anything far longer than the image needs is not this image. + let bits = s.decoded_within(need.saturating_add(need / 2).saturating_add(4096)).ok()?; + let bits = bits.get(..need)?; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let mut out = Vec::with_capacity(w.checked_mul(h)?); + for row in bits.chunks(stride).take(h) { + for x in 0..w { + let byte = row.get(x / 8).copied().unwrap_or(0); + let one = byte & (0x80 >> (x % 8)) != 0; + // Without a decode array 0 is black and 1 white; [1 0] swaps them. + out.push(if one != inverted { 255 } else { 0 }); + } + } + Some(out) +} + +/// The pixels (0 black … 255 white, threshold at half) packed back to bits. +fn pack_bits(px: &[u8], w: u32, h: u32, inverted: bool) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let mut out = vec![0u8; packed_len(w, h)?]; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if px.len() != w.checked_mul(h)? { + return None; + } + for (y, row) in px.chunks(w.max(1)).enumerate() { + for (x, &v) in row.iter().enumerate() { + let white = v >= 128; + if white != inverted + && let Some(b) = out.get_mut(y * stride + x / 8) + { + *b |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Image stream `old` with its samples replaced by `samples` (same size and components): a JPEG +/// stays a JPEG (at `quality`, 1–100), anything else becomes Flate, and a bi-level image stays +/// one bit per pixel with its decode array. Filter parameters go (the new data needs none); every +/// other entry stays. `None` if the samples don't fit the image. +pub fn encode_samples(old: &Stream, samples: &Samples, quality: u8) -> Option { + let Samples { width, height, components: n, data, jpeg: as_jpeg, bilevel, inverted } = samples; + let (w, h, n) = (*width, *height, *n); + if !(n == 1 || n == 3) || data.len() != usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)? { + return None; + } + let mut d = old.dict.clone(); + for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + d.remove(k); + } + if *bilevel { + if n != 1 { + return None; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + return Some(Stream::flate(d, &pack_bits(data, w, h, *inverted)?)); + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + if *as_jpeg { + let bytes = jpeg(data, n, w, h, quality)?; + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Some(Stream::from_raw(d, bytes)) + } else { + Some(Stream::flate(d, data)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::Dict; + + fn image(dict: &[(&str, Object)], raw: Vec) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + for (k, v) in dict { + d.set(k.as_bytes().to_vec(), v.clone()); + } + Stream::from_raw(d, raw) + } + + fn doc() -> Document { + let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + Document::open(std::sync::Arc::new(pdf.to_vec())).unwrap() + } + + #[test] + fn grey_samples_round_trip_losslessly_and_keep_the_other_entries() { + let px: Vec = (0..12 * 5).map(|i| (i * 4) as u8).collect(); + let s = image( + &[ + ("Width", Object::Int(12)), + ("Height", Object::Int(5)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ("Interpolate", Object::Bool(true)), + ], + px.clone(), + ); + let got = decode_samples(&doc(), &s).unwrap(); + assert_eq!((got.width, got.height, got.components, got.jpeg, got.bilevel), (12, 5, 1, false, false)); + assert_eq!(got.data, px); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(back.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(decode_samples(&doc(), &back).unwrap().data, px); + } + + #[test] + fn bilevel_samples_keep_their_decode_array_and_bits() { + // 10 × 2: first row black on the left half, second row all white; [1 0] makes 1 black. + let raw = vec![0b1111_1000, 0b0000_0000, 0, 0]; + let s = image( + &[ + ("Width", Object::Int(10)), + ("Height", Object::Int(2)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(1)), + ("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)])), + ], + raw.clone(), + ); + let got = decode_samples(&doc(), &s).unwrap(); + assert!(got.bilevel); + assert_eq!(&got.data[..10], &[0, 0, 0, 0, 0, 255, 255, 255, 255, 255]); + assert!(got.data[10..].iter().all(|&v| v == 255)); + assert!(got.inverted); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.int(b"BitsPerComponent"), Some(1)); + assert!(back.dict.get(b"Decode").is_some()); + assert_eq!(back.decoded().unwrap(), raw); + } + + #[test] + fn what_is_not_supported_says_why_and_never_panics() { + let base = |extra: &[(&str, Object)], raw: Vec| { + let mut d: Vec<(&str, Object)> = vec![ + ("Width", Object::Int(4)), + ("Height", Object::Int(4)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ]; + d.extend(extra.iter().cloned()); + decode_samples(&doc(), &image(&d, raw)) + }; + assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); + assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); + assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); + assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); + // Too short, a huge size, a broken JPEG, no size at all. + assert_eq!(base(&[], vec![0; 5]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(1 << 30)), ("Height", Object::Int(1 << 30))], vec![0; 16]), Err(NotDecoded::TooLarge)); + assert_eq!(base(&[("Filter", Object::name("DCTDecode"))], vec![0xFF, 0xD8, 1, 2, 3]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(-3))], vec![0; 16]), Err(NotDecoded::Corrupt)); + // Samples that don't fit the image are refused when written back. + let s = image(&[("Width", Object::Int(4)), ("Height", Object::Int(4))], vec![0; 16]); + let wrong = Samples { width: 4, height: 4, components: 1, data: vec![0; 15], jpeg: false, bilevel: false, inverted: false }; + assert!(encode_samples(&s, &wrong, 90).is_none()); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8925a0dca1..1fd46a956f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -179,6 +179,8 @@ impl PdfCraftApp { stamp_draft: Default::default(), optimize_draft: OptimizeDraft::default(), optimize_run: None, + scan_clean_draft: scan_clean_ui::ScanCleanDraft::default(), + scan_clean_run: None, progress_notice: None, page_clipboard: None, grid_drop: None, @@ -508,6 +510,7 @@ impl eframe::App for PdfCraftApp { self.auto_ocr_scanned_page(); self.poll_ocr(); self.poll_optimize(); + self.poll_scan_clean(); self.poll_action(); self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bb5a65cb34..3fe737b40b 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -101,6 +101,7 @@ impl PdfCraftApp { "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "accessibility-options" => Some(Dialog::AccessibilityOptions), "recognize-text" => Some(Dialog::RecognizeText), + "enhance-scan" => Some(Dialog::EnhanceScan), "js-console" => Some(Dialog::JsConsole), "document-js" => Some(Dialog::DocumentJs), "preferences" => Some(Dialog::Preferences), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e0a8370c60..3d6f493e10 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -5,6 +5,7 @@ impl PdfCraftApp { pub(super) fn show_progress(&mut self, ctx: &egui::Context) { if widgets::progress_notice(self, ctx) { self.cancel_optimize(); + self.cancel_scan_clean(); } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index acccbcc55a..dec99e8b58 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -500,6 +500,7 @@ impl PdfCraftApp { "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), + "ocr.enhance" => self.dialog = Some(Dialog::EnhanceScan), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c21604a4da..a77b0e4d19 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -56,6 +56,7 @@ struct Pending { open_revision: Option, a11y_now: bool, ocr_now: bool, + scan_clean_now: bool, compare_now: bool, images_now: bool, stamp_now: bool, @@ -372,6 +373,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = go || cancel; return false; } + Dialog::EnhanceScan => { + let (go, cancel) = crate::scan_clean_ui::body(ui, app, &t); + req.scan_clean_now = go; + req.close = go || cancel; + return false; + } Dialog::AccessibilityOptions => { let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); req.a11y_now = start; @@ -1818,6 +1825,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if req.ocr_now { app.start_ocr(); } + if req.scan_clean_now { + app.start_scan_clean(); + } if req.compare_now { app.run_compare(); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..076fb6bf66 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2558,3 +2558,22 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + Enhance scanned file… Gescannte Datei verbessern… + Enhance Scanned File Gescannte Datei verbessern + Cleans up the scanned pictures in place: recognized text, comments and links on the pages stay as they are. Bereinigt die gescannten Bilder an Ort und Stelle: erkannter Text, Kommentare und Links auf den Seiten bleiben erhalten. + Filters Filter + Background removal Hintergrund entfernen + Evens out the paper so a grey or yellowed background turns white, and darkens faint strokes. 0 is off. Gleicht das Papier aus, sodass ein grauer oder vergilbter Hintergrund weiß wird, und dunkelt blasse Striche nach. 0 ist aus. + Edge shadow removal Randschatten entfernen + Lightens shadows along the edges of the scan, such as a book's gutter or a copier's dark border Hellt Schatten an den Rändern des Scans auf, etwa den Bundsteg eines Buchs oder den dunklen Rand eines Kopierers + Despeckle Flecken entfernen + Removes isolated dark specks such as dust and scanner noise. The only filter black and white scans take. Entfernt einzelne dunkle Flecken wie Staub und Scannerrauschen. Der einzige Filter für Schwarzweiß-Scans. + Enhance Verbessern + No scanned pages were cleaned up. Es wurden keine gescannten Seiten bereinigt. + Scanned pages are already being enhanced Gescannte Seiten werden bereits verbessert + Couldn't enhance the scanned pages Die gescannten Seiten konnten nicht verbessert werden + Enhancing scans… image {d} of {t} Scans werden verbessert… Bild {d} von {t} + Enhancing the scanned pages was cancelled Das Verbessern der gescannten Seiten wurde abgebrochen +@plural Cleaned up the scan on {n} page.|Cleaned up the scans on {n} pages. Scan auf {n} Seite bereinigt.|Scans auf {n} Seiten bereinigt. +@plural {n} page is not a scan.|{n} pages are not scans. {n} Seite ist kein Scan.|{n} Seiten sind keine Scans. +@plural {n} image was left as it was (page {p}: {why}).|{n} images were left as they were (page {p}: {why}, …). {n} Bild blieb unverändert (Seite {p}: {why}).|{n} Bilder blieben unverändert (Seite {p}: {why}, …). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65bc7e4b1b..e61e3827a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,7 @@ use workflows::js_ui; use workflows::ocr_ui; use workflows::optimize_ui; use workflows::print_ui; +use workflows::scan_clean_ui; use workflows::web_print; /// Files dropped on a document's page grid or its Pages panel. @@ -494,6 +495,9 @@ pub struct PdfCraftApp { pub optimize_draft: OptimizeDraft, /// The running optimization (Optimize PDF ▸ Advanced optimization). pub optimize_run: Option, + /// Scan & OCR ▸ Enhance scanned file: the dialog's choices and the running clean-up. + pub scan_clean_draft: scan_clean_ui::ScanCleanDraft, + pub scan_clean_run: Option, /// A background job's progress card (see [`widgets::progress_notice`]). pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index dd3b3e90aa..72d3b6ddf2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -162,6 +162,8 @@ pub enum Dialog { AccessibilityOptions, /// Scan & OCR ▸ Recognize text. RecognizeText, + /// Scan & OCR ▸ Enhance scanned file. + EnhanceScan, /// The JavaScript console (⌘J). JsConsole, /// Document JavaScripts. diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 63686a70cc..9314914ec4 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -11,4 +11,5 @@ pub(super) mod js_ui; pub(super) mod ocr_ui; pub(super) mod optimize_ui; pub(super) mod print_ui; +pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs new file mode 100644 index 0000000000..a275b5af20 --- /dev/null +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -0,0 +1,253 @@ +//! Scan & OCR ▸ Enhance scanned file: the dialog (pages, background removal level, edge +//! shadows, despeckle) and the background run with its progress card. The scans are cleaned +//! on a worker thread ([`pdfcraft_engine::scan_clean`]); the result is applied as one undoable +//! step when it is done, unless the document changed meanwhile. + +use std::sync::{Arc, Mutex}; + +use egui::{Align, Layout}; +use pdfcraft_engine::scan_clean::{ScanCleanReport, ScanCleanSettings, ScanCleaned}; +use pdfcraft_engine::{DocId, EditError}; + +use crate::ocr_ui::OcrPages; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The dialog's choices (kept between runs). +#[derive(Clone, Debug, PartialEq)] +pub struct ScanCleanDraft { + pub pages: OcrPages, + pub from: usize, + pub to: usize, + pub settings: ScanCleanSettings, +} + +impl Default for ScanCleanDraft { + fn default() -> Self { + ScanCleanDraft { pages: OcrPages::All, from: 1, to: 1, settings: ScanCleanSettings::default() } + } +} + +/// Progress of a run: images done, total, the result once finished, and a cancel request. +#[derive(Default)] +pub struct ScanCleanProgress { + pub done: usize, + pub total: usize, + pub result: Option>, + pub cancel: bool, +} + +pub struct ScanCleanRun { + pub doc: DocId, + pub progress: Arc>, +} + +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len().max(1)); + let d = &mut app.scan_clean_draft; + d.to = d.to.clamp(1, pages); + d.from = d.from.clamp(1, d.to); + ui.label(egui::RichText::new(tl!("Enhance Scanned File")).font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tl!("Cleans up the scanned pictures in place: recognized text, comments and links on the pages stay as they are.")) + .small() + .color(t.text_muted), + ); + ui.add_space(8.0); + let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); + ui.add_space(8.0); + }; + group(ui, "Pages", &mut |ui| { + widgets::radio_value(ui, &mut d.pages, OcrPages::All, tl!("All pages")); + widgets::radio_value(ui, &mut d.pages, OcrPages::Current, tl!("Current page")); + ui.horizontal(|ui| { + let from = widgets::radio_value(ui, &mut d.pages, OcrPages::Range, tl!("From")); + let on = d.pages == OcrPages::Range; + ui.add_enabled(on, egui::DragValue::new(&mut d.from).range(1..=pages)).labelled_by(from.id); + let to = ui.label(tl!("to")); + ui.add_enabled(on, egui::DragValue::new(&mut d.to).range(1..=pages)).labelled_by(to.id); + }); + }); + group(ui, "Filters", &mut |ui| { + ui.horizontal(|ui| { + let label = ui.label(tl!("Background removal")); + ui.spacing_mut().slider_width = 220.0; + ui.add(egui::Slider::new(&mut d.settings.level, 0..=pdfcraft_engine::camera_clean::MAX_LEVEL).trailing_fill(true)) + .labelled_by(label.id) + .on_hover_text(tl!("Evens out the paper so a grey or yellowed background turns white, and darkens faint strokes. 0 is off.")); + }); + ui.add(widgets::checkbox(&mut d.settings.edge_shadows, tl!("Edge shadow removal"))) + .on_hover_text(tl!("Lightens shadows along the edges of the scan, such as a book's gutter or a copier's dark border")); + ui.add(widgets::checkbox(&mut d.settings.despeckle, tl!("Despeckle"))) + .on_hover_text(tl!("Removes isolated dark specks such as dust and scanner noise. The only filter black and white scans take.")); + }); + ui.add_space(6.0); + let (mut go, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ready = d.settings.changes_something(); + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Enhance"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }) + }); + (go, cancel) +} + +/// What a finished run says: what was cleaned, what was not a scan, and what was left and why. +fn summary(r: &ScanCleanReport) -> String { + let lang = crate::i18n::current(); + let mut out = if r.pages.is_empty() { + tl!("No scanned pages were cleaned up.").to_string() + } else { + crate::i18n::trn(lang, r.pages.len() as u64, "Cleaned up the scan on {n} page.", "Cleaned up the scans on {n} pages.") + }; + if !r.not_scanned.is_empty() { + out.push(' '); + out.push_str(&crate::i18n::trn(lang, r.not_scanned.len() as u64, "{n} page is not a scan.", "{n} pages are not scans.")); + } + if let Some(first) = r.skipped.first() { + out.push(' '); + let page = first.pages.first().map_or_else(String::new, |p| (p + 1).to_string()); + // The reason is the engine's own sentence, in English. + out.push_str(&crate::i18n::fmt( + &crate::i18n::trn( + lang, + r.skipped.len() as u64, + "{n} image was left as it was (page {p}: {why}).", + "{n} images were left as they were (page {p}: {why}, …).", + ), + &[("p", &page), ("why", &first.reason)], + )); + } + out +} + +impl PdfCraftApp { + /// Clean up the scans on the pages chosen in the dialog, in the background. + pub fn start_scan_clean(&mut self) { + let Some((vi, id)) = self.active_ids() else { return }; + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } + // What's typed in a form field is part of the document (#166). + if !self.commit_form_typing() { + return; + } + let d = &self.scan_clean_draft; + let pages: Vec = match d.pages { + OcrPages::All => Vec::new(), + OcrPages::Current => self.views.get(vi).map(|v| vec![v.current]).unwrap_or_default(), + OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), + }; + let job = match self.session.scan_clean_job(id, &pages, d.settings) { + Ok(job) => job, + Err(e) => return self.notify(e.to_string()), + }; + let progress = Arc::new(Mutex::new(ScanCleanProgress { total: job.total(), ..Default::default() })); + let p = progress.clone(); + let work = move || { + let result = job.run(|done, total| { + let Ok(mut s) = p.lock() else { return false }; + s.done = done; + s.total = total; + !s.cancel + }); + if let Ok(mut s) = p.lock() { + s.result = Some(result); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if self.run_inline { + work(); + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-scan-clean".into()).spawn(work) { + // No worker: report the failure instead of waiting for a result that never comes. + if let Ok(mut s) = progress.lock() { + s.result = Some(Err(EditError::Invalid(e.to_string()))); + } + } + #[cfg(target_arch = "wasm32")] + work(); + self.scan_clean_run = Some(ScanCleanRun { doc: id, progress }); + self.poll_scan_clean(); + } + + /// Stop a running clean-up (nothing changes). + pub fn cancel_scan_clean(&mut self) { + if let Some(r) = &self.scan_clean_run + && let Ok(mut s) = r.progress.lock() + { + s.cancel = true; + } + } + + /// Show progress; apply the result once the worker is done. + pub(crate) fn poll_scan_clean(&mut self) { + let Some(run) = self.scan_clean_run.as_ref() else { return }; + let (doc, progress) = (run.doc, run.progress.clone()); + let Ok(mut s) = progress.lock() else { + // The worker panicked while holding the lock: it will never report back. + self.scan_clean_run = None; + self.progress_notice = None; + self.notify_tr("Couldn't enhance the scanned pages"); + return; + }; + let Some(result) = s.result.take() else { + let (cancelling, done, total) = (s.cancel, s.done, s.total); + drop(s); + let label = if cancelling { + tl!("Cancelling…").to_string() + } else { + crate::i18n::fmt( + tl!("Enhancing scans… image {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ) + }; + let fraction = if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }; + self.progress_notice = Some(crate::widgets::ProgressNotice { label, fraction, cancellable: !cancelling }); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + }; + drop(s); + self.scan_clean_run = None; + self.progress_notice = None; + match result.and_then(|cleaned| self.session.apply_scan_clean(doc, cleaned)) { + Ok(report) => { + let msg = summary(&report); + self.notify(msg); + } + Err(EditError::Cancelled) => self.notify_tr("Enhancing the scanned pages was cancelled"), + Err(e) => self.notify(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_engine::scan_clean::SkippedImage; + + #[test] + fn the_summary_says_what_was_done_and_what_was_left() { + let r = ScanCleanReport { + pages: vec![0, 2], + images: 1, + not_scanned: vec![1], + skipped: vec![SkippedImage { pages: vec![3], name: "Im0".into(), reason: "it is CMYK".into() }], + }; + assert_eq!(summary(&r), "Cleaned up the scans on 2 pages. 1 page is not a scan. 1 image was left as it was (page 4: it is CMYK)."); + assert_eq!(summary(&ScanCleanReport::default()), "No scanned pages were cleaned up."); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a5c3192358..2df736c14c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod scan_clean; mod side_panels; mod signing; mod stamps; diff --git a/crates/ui-egui/tests/it/scan_clean.rs b/crates/ui-egui/tests/it/scan_clean.rs new file mode 100644 index 0000000000..afb4a4f75c --- /dev/null +++ b/crates/ui-egui/tests/it/scan_clean.rs @@ -0,0 +1,77 @@ +//! Scan & OCR ▸ Enhance scanned file in the real shell (egui_kittest): the dialog, the run and +//! its report, and that the page keeps its comment while its scan is cleaned in place. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Session, Shape, Style}; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A two-page PDF: page 1 a grey scan with a darker gutter and text bars (and a sticky note), +/// page 2 the same scan again. +fn scan() -> Vec { + let img = image::GrayImage::from_fn(300, 400, |x, y| { + let light = 0.55 + 0.45 * (x as f32 / 60.0).min(1.0); + let ink = if (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x) { 0.12 } else { 0.78 }; + image::Luma([(ink * light * 255.0) as u8]) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + let bytes = s.create_from_images(&[("a.png".into(), png.clone()), ("b.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let shape = Shape::Note { at: [20.0, 20.0], icon: NoteIcon::Comment }; + let note = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "Keep me".into(), author: "Tester".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("scan.pdf", None, scan()).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +#[test] +fn enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().comment_props(0, 0).is_some()); + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Background removal").count(), 2, "the label and the slider it names"); + h.get_by_label("Edge shadow removal"); + h.get_by_label("Despeckle"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Current page").click(); + h.run_steps(1); + h.get_by_label("Enhance").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.scan_clean_run.is_none() && app.dialog.is_none(), "finished and closed"); + let toast = app.toast.clone().unwrap().0; + assert_eq!(toast, "Cleaned up the scan on 1 page."); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Enhance scanned file")); + assert_eq!(doc.info.pages.len(), 2); + assert_eq!(doc.comment_props(0, 0).map(|p| p.author), Some("Tester".to_string()), "the sticky note stays"); +} + +#[test] +fn nothing_to_enhance_is_said_not_done() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + h.state_mut().scan_clean_draft.settings = pdfcraft_engine::scan_clean::ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }; + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + // With every filter off the button is disabled; starting anyway changes nothing. + h.state_mut().start_scan_clean(); + h.run_steps(2); + let app = h.state(); + assert!(app.toast.clone().unwrap().0.starts_with("No scanned pages were cleaned up."), "{:?}", app.toast); + assert_eq!(app.session.get(id).unwrap().can_undo(), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 56ec3ae748..ab6f083ab1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -164,7 +165,9 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle, background and camera clean-up; MRC compression; pluggable engines; OCR in the + despeckle and background clean-up as options of Recognize text (Enhance scanned file does them + on grey, RGB and 1-bit scans, not CMYK, indexed, JBIG2 or CCITT ones); MRC compression; + pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without accents"); 19 of 26 OCR features planned; [#744](https://github.com/storytold/pdfcraft/issues/744) diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..6d910fc795 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -194,7 +195,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c7920e33c..3c77c491cb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -206,7 +207,7 @@ full parity in that area. | F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | -| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | +| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners. Background removal, edge shadow removal and despeckle work on scanned pages (Enhance scanned file) but not yet as options of Recognize text, nor on CMYK, indexed, JBIG2 or CCITT scans | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ac06fdd4..1ea04254aa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6674,10 +6674,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans (reported, not changed)." [[feature]] id = "ocr.background-removal" @@ -6686,10 +6686,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (Enhance camera image level) and scanned pages (Enhance scanned file ▸ Background removal, page_enhance_scan level): the paper's illumination is evened out per channel so a grey, yellowed or unevenly lit background turns white, then the contrast is raised; colour ink stays. Scans are cleaned in place in their own image objects (JPEG stays JPEG at quality 90, others lossless), so the OCR text layer, comments and links stay; one undo step. Missing: as an option of Recognize text; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed); a preview." [[feature]] id = "ocr.descreen" @@ -6722,10 +6722,10 @@ area = "I" tier = "P2" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (the clean-up level divides out a smooth illumination estimate) and scanned pages (Enhance scanned file ▸ Edge shadow removal, page_enhance_scan edge_shadows): within the outer fifth of each side, every column and row is lightened to the brightest paper of that band, which removes book gutters and copier borders the coarse estimate misses; a black scanner border is left alone. Missing: as an option of Recognize text; curved gutters that also bend the text (no dewarping); the scan formats background removal also skips." [[feature]] id = "ocr.camera-perspective" From 622625a178b9bec514b586f12b53fe1c28ab8f33 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Wed, 28 Oct 2026 13:37:00 +0100 Subject: [PATCH 02/59] M10: Enhance scanned file reports scans inside Form XObjects and names JPEG 2000 - A scan drawn only through grouped Form artwork is reported in skipped (once per form, with every page that draws it) instead of being counted as no scan; a Form of vector artwork holding only a small picture (under 36 dpi over its area) still counts as no scan. - decode_samples checks the filter before the colour space and bit depth, so a JPEG 2000 image without /ColorSpace or /BitsPerComponent is reported as JPEG 2000, not as a colour-space or bit-depth problem. - Optimize and Enhance scanned file no longer start while the other runs: they share the progress card and capture the same document state. - docs/gaps.md: background and edge-shadow removal apply to grey and RGB scans, despeckle also to 1-bit ones. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/engine/src/copy_image.rs | 2 +- crates/engine/src/scan_clean.rs | 25 ++++++- crates/engine/src/scan_clean/tests.rs | 53 ++++++++++++++ crates/engine/src/scan_fields.rs | 70 +++++++++++++++---- crates/optimize/src/samples.rs | 9 ++- crates/ui-egui/src/workflows/optimize_ui.rs | 23 ++++++ crates/ui-egui/src/workflows/scan_clean_ui.rs | 5 ++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 2 +- 10 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0d34911154..c718e4a47a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -225,7 +225,7 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), - t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, broken data, or nothing to change). One undoable step; none when nothing changed.") + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, a scan drawn inside grouped Form artwork, broken data, or nothing to change). One undoable step; none when nothing changed.") .cmd("ocr.enhance") .with(schema( json!({ diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..1407c9f1d2 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -113,7 +113,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { } /// The largest raster image Form `form` draws through its resources (nested Forms included). -fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { +pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { let mut seen = HashSet::from([form]); let mut best: Option<(u64, ObjRef)> = None; let res = resources(cos, form)?; diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs index bfc8188b29..48ff15093a 100644 --- a/crates/engine/src/scan_clean.rs +++ b/crates/engine/src/scan_clean.rs @@ -12,7 +12,7 @@ //! pictures each covering at least a tenth of the page are its scan. What is not cleaned is //! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft //! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping -//! decode array) or whose data is broken. +//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken. //! //! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, //! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] @@ -31,6 +31,8 @@ const QUALITY: u8 = 90; /// On a scanned page, an image covering at least this share of the page is part of the scan; /// smaller ones (a logo or a signature pasted on) are left alone. const PART_OF_SCAN: f64 = 0.1; +/// Why a scan drawn through grouped Form artwork stays as it is. +const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet"; /// How scanned pages are cleaned up. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -138,10 +140,29 @@ impl Session { wanted.dedup(); let mut report = ScanCleanReport::default(); let mut targets: Vec = Vec::new(); + // Form artwork already reported, and where in `report.skipped`. + let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new(); for page in wanted { let Some(info) = doc.info.pages.get(page) else { continue }; let Some(images) = crate::scan_fields::scan_images(doc, page, info) else { - report.not_scanned.push(page); + // A scan drawn through a Form XObject is a scan all the same: say why it stays. + let forms: Vec<_> = crate::scan_fields::scan_forms(doc, page, info).into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect(); + if forms.is_empty() { + report.not_scanned.push(page); + } + for (form, _) in forms { + let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k)); + match known { + Some(s) if !s.pages.contains(&page) => s.pages.push(page), + Some(_) => {} + None => { + if let Some(o) = form.object { + forms_reported.push((o, report.skipped.len())); + } + report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() }); + } + } + } continue; }; for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs index 561ba9e2e4..f39f8c0432 100644 --- a/crates/engine/src/scan_clean/tests.rs +++ b/crates/engine/src/scan_clean/tests.rs @@ -275,6 +275,59 @@ fn hostile_images_are_reported_not_a_panic() { assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); } +#[test] +fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() { + // Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text. + let page = |contents: usize| { + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>" + ) + }; + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>") + .obj(page(6)) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"), + ) + .stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1)) + .stream("", "/Fm0 Do") + .obj(page(8)) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj(page(11)) + .stream("", "q /Fm0 Do Q") + .build(); + let (mut s, id) = open(bytes); + let before = object(&s, id, 5); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert!(report.pages.is_empty() && report.images == 0, "{report:?}"); + assert_eq!(report.not_scanned, vec![1], "only the text page is no scan"); + assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}"); + let k = &report.skipped[0]; + assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0")); + assert!(k.reason.contains("form XObject"), "{k:?}"); + assert_eq!(object(&s, id, 5), before, "the picture is left as it was"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + + // A page of vector artwork in a Form, holding only a small logo, is no scan. + let logo = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>")) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"), + ) + .stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800]) + .stream("", "/Fm0 Do") + .build(); + let (mut s, id) = open(logo); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}"); +} + #[test] fn protected_documents_keep_their_encryption_and_restrictions() { let protect = |user: &str, permissions: i32| { diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 769beaefca..d2e663f9a5 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -19,6 +19,9 @@ const SCALE: f32 = 2.0; const MAX_SIDE: f32 = 3000.0; /// A page is a scan when its images cover at least this share of it. const COVERED: f64 = 0.5; +/// A picture inside Form artwork is taken for a scan when it has at least this resolution over +/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have). +const MIN_FORM_DPI: f64 = 36.0; /// Recognised "words" less tall than this (points) are specks read as letters, and would make /// the box they sit on look filled in. const MIN_WORD_HEIGHT: f64 = 4.0; @@ -32,28 +35,69 @@ pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { /// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. /// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { - let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); - let page_area = (cx1 - cx0) * (cy1 - cy0); - if !page_area.is_finite() || page_area <= 0.0 { - return None; - } + let area = page_area(info)?; let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) - .map(|i| { - let r = i.visible_rect.unwrap_or(i.rect); - let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); - let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - let share = w * h / page_area; - (i, share) - }) - .filter(|(_, share)| share.is_finite()) + .filter_map(|i| Some((share(info, area, &i)?, i))) + .map(|(share, i)| (i, share)) .collect(); let covered: f64 = images.iter().map(|(_, share)| share).sum(); (covered >= COVERED).then_some(images) } +/// The grouped Form artwork on `page` that holds a raster picture, each with the share of the +/// page it covers, when it and the page's own images together cover most of the page: a scan +/// drawn through a Form XObject, which [`scan_images`] does not count and Enhance ▸ Scanned +/// document can't clean yet, so it reports these. Empty otherwise. +pub(crate) fn scan_forms(doc: &Document, page: usize, info: &PageInfo) -> Vec<(pdfcraft_edit::PageImage, f64)> { + let (Some(area), Some(editor)) = (page_area(info), doc.editor.as_ref()) else { return Vec::new() }; + let mut covered = 0.0; + let mut forms = Vec::new(); + for i in doc.page_images(page) { + let Some(share) = share(info, area, &i) else { continue }; + if !i.is_form { + if i.width > 1 && i.height > 1 { + covered += share; + } + } else if i.object.is_some_and(|form| holds_scan(&editor.cos, form, share * area)) { + covered += share; + forms.push((i, share)); + } + } + if covered >= COVERED { forms } else { Vec::new() } +} + +/// Whether Form `form`, covering `points` square points of the page, draws a picture that could +/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page +/// of vector artwork. +fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool { + let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false }; + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { return false }; + let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64; + let per_point = MIN_FORM_DPI / 72.0; + px(b"Width") * px(b"Height") >= points * per_point * per_point +} + +/// The area of `info`'s crop box, when it is a usable one. +fn page_area(info: &PageInfo) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let area = (cx1 - cx0) * (cy1 - cy0); + (area.is_finite() && area > 0.0).then_some(area) +} + +/// The share of the crop box (of area `area`) that `image` covers, when it is a number. +fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let r = image.visible_rect.unwrap_or(image.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + let share = w * h / area; + share.is_finite().then_some(share) +} + /// The page as displayed, in points with y up: where detection on a scan works. struct Frame<'a>(&'a PageInfo); diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs index 7f402b51b8..1ed9f16b1f 100644 --- a/crates/optimize/src/samples.rs +++ b/crates/optimize/src/samples.rs @@ -53,6 +53,9 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { return Err(NotDecoded::StencilMask); } + // The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data, + // and the report should name the format, not the missing entries. + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); let bilevel = match bpc { @@ -60,7 +63,6 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result 1 if n == 1 => true, other => return Err(NotDecoded::BitDepth(other)), }; - let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); if width == 0 || height == 0 { @@ -258,6 +260,11 @@ mod tests { assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + // JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason. + let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8])); + let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))]; + assert_eq!(jpx(&size), Err(NotDecoded::Filter)); + assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter)); assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); // Too short, a huge size, a broken JPEG, no size at all. diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 3bb80ac439..4e1a7c20bf 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -360,6 +360,11 @@ impl PdfCraftApp { self.notify_tr("Optimization is already running"); return; } + // Both jobs share the progress card and capture the same document state. + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, Err(e) => return self.save_optimized(id, kind, Err(e)), @@ -491,4 +496,22 @@ mod tests { app.optimize_with_draft(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } + + #[test] + fn optimize_and_enhance_scanned_file_do_not_run_at_once() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress: Arc::default() }); + app.optimize_with_draft(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Scanned pages are already being enhanced")); + assert!(app.optimize_run.is_none()); + + app.scan_clean_run = None; + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.start_scan_clean(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); + assert!(app.scan_clean_run.is_none()); + } } diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs index a275b5af20..64f8ce48eb 100644 --- a/crates/ui-egui/src/workflows/scan_clean_ui.rs +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -140,6 +140,11 @@ impl PdfCraftApp { self.notify_tr("Scanned pages are already being enhanced"); return; } + // Both jobs share the progress card and capture the same document state. + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; diff --git a/docs/gaps.md b/docs/gaps.md index ab6f083ab1..dacca2515b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,8 +165,10 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle and background clean-up as options of Recognize text (Enhance scanned file does them - on grey, RGB and 1-bit scans, not CMYK, indexed, JBIG2 or CCITT ones); MRC compression; + despeckle and background clean-up as options of Recognize text (Enhance scanned file removes + the background and edge shadows on grey and RGB scans and despeckles those and 1-bit ones; it + reports CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans drawn inside Form XObjects + and leaves them as they are); MRC compression; pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1ea04254aa..5f48c815a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6677,7 +6677,7 @@ status = "partial" commands = ["ocr.enhance_camera", "ocr.enhance"] tools = ["page_enhance_camera", "page_enhance_scan"] evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] -notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans (reported, not changed)." +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed)." [[feature]] id = "ocr.background-removal" From 7878a4cb20604a57b7ae0cbc964bcf3c29b3a711 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Thu, 29 Oct 2026 16:37:23 +0100 Subject: [PATCH 03/59] M10: Enhance scanned file is tested to leave unchosen pages' scans alone A new engine test builds two pages that each draw their own scan image, cleans one page, and checks that page's image changed while the other image object, and its stream bytes, stayed identical; then the same with the other page chosen. Taking every page instead of the chosen ones fails it, on the report and, with that assertion removed, on the other image. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/scan_clean/tests.rs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs index f39f8c0432..11a1bca9c9 100644 --- a/crates/engine/src/scan_clean/tests.rs +++ b/crates/engine/src/scan_clean/tests.rs @@ -389,3 +389,37 @@ fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() { assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40)); assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone"); } + +/// Two pages, each with its own scan image (objects 4 and 7), so cleaning one page can be told +/// apart from cleaning the other. +fn two_scans_pdf() -> Vec { + let dict = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"); + let draw = format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 7 0 R >> >> /Contents 8 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .build() +} + +#[test] +fn only_the_chosen_pages_scan_is_cleaned() { + // Clean page 1, then page 2 of a fresh copy: the other page's image stays byte-identical. + for (chosen, image, other) in [(0usize, 4u32, 7u32), (1, 7, 4)] { + let (mut s, id) = open(two_scans_pdf()); + let (image_before, other_before) = (object(&s, id, image), object(&s, id, other)); + assert_eq!(s.get(id).unwrap().page_images(chosen)[0].object, Some(ObjRef::new(image, 0)), "the fixture"); + let report = s.enhance_scanned_pages(id, &[chosen], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.pages.as_slice(), report.images), (&[chosen][..], 1), "page {}", chosen + 1); + assert_ne!(object(&s, id, image), image_before, "page {}'s image was not cleaned", chosen + 1); + assert_eq!(object(&s, id, other), other_before, "cleaning page {} changed the other page's image", chosen + 1); + let (Object::Stream(was), Object::Stream(now)) = (&*other_before, &*object(&s, id, other)) else { panic!("the images") }; + assert_eq!(was.raw, now.raw, "the other image's stream bytes"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + } +} From ff227b770ca6e436ee0853a2585739a8be9eb1e9 Mon Sep 17 00:00:00 2001 From: kronbii <145912020+Kronbii@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:15:30 +0200 Subject: [PATCH 04/59] M2: area-average shrunk scans and smooth enlarged ones Scanned pages looked thin and broken at screen size. An image that does not ask for /Interpolate (most scans) was pre-shrunk with nearest-neighbour since about one sample in ten and dropped most strokes of its text, and the nearest sampler then read the shrunk raster again at a fractional offset. - Shrinking (vendored hayro patch 21): non-interpolated images outside Type 3 glyphs are area-averaged (area_shrink: each device pixel the coverage-weighted mean of the samples it covers, 16.16 fixed point whose weights sum to exactly one, premultiplied when masked) to their snapped device rectangle and drawn 1:1. /Interpolate governs smoothing when an image is enlarged (ISO 32000-2 8.9.5.3); averaging adds nothing between samples and drops none. PicScale's Box/Area filters were not usable: shrinking, they average a window four target pixels wide. - Enlarging (patch 28): an image of page-scan size (512+ samples on its shorter side) is drawn bilinearly when enlarged, so a zoomed scan has no staircase edges; icons, QR codes and pixel art keep their hard pixels. - A snapped image whose shrink failed keeps the full transform instead of being drawn at its source size (latent in the interpolated path too). Measured on a 13-page 300 dpi scan at 96, 120 and 192 dpi: every rendered sample is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). Bilinear zoom costs nothing measurable (four 1024 px tiles at 400%: 50 ms, nearest 48 ms; bicubic was 267 ms). Review fixes: - a tile snaps a shrunk image's half-pixel edges where the whole page does - a scan shrunk one way is smoothed the way it is stretched - a smaller soft mask doesn't let transparent colour through the area shrink Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/render/src/raster/tests.rs | 214 +++++++++++++--- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 411 ++++++++++++++++++++++++++++-- 6 files changed, 571 insertions(+), 66 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5bfe1fc817..ae64d4f352 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -257,6 +257,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged. - **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged. +- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 9c5d75bfbc..7312f3ddaf 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -6436,8 +6436,8 @@ fn draw_minified_image( /// making scans, screenshots and rasterized text look soft. #[test] fn non_interpolated_images_are_not_smoothed_when_minified() { - // Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's - // pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks. + // Top band K W R G, bottom band B Y M C. Shrinking 8×4 → 4×2 averages each 2×2 block, + // so every device pixel is one palette colour; Catmull-Rom blends across blocks. let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; let rgb: Vec = block_source(palette).concat(); let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect(); @@ -6455,38 +6455,76 @@ fn non_interpolated_images_are_not_smoothed_when_minified() { assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone"); } -/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole -/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second -/// time at the fractional offset, so every edge of a scan was a blended, soft pixel. +/// A shrunk image whose edges fall between device pixels is snapped to whole pixels (shrunk +/// once to that size, drawn 1:1), interpolated or not. Before, the shrunk raster was sampled a +/// second time at the fractional offset, so every edge of a scan was a blended, soft pixel. #[test] -fn interpolated_minified_image_edges_snap_to_whole_pixels() { - let black = vec![0u8; 40 * 40]; - let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q"; - let image = hex(&black); - let pdf = format!( - "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ +fn minified_image_edges_snap_to_whole_pixels() { + for interpolate in ["/Interpolate true ", ""] { + let black = vec![0u8; 40 * 40]; + let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q"; + let image = hex(&black); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ trailer << /Root 1 0 R >>\n%%EOF\n", - content.len(), - image.len() + 1 - ); - let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!((page.width, page.height), (11, 11)); - let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; - // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11. - for y in 1..11 { - for x in 0..10 { - assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image"); + content.len(), + image.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (11, 11)); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11. + for y in 1..11 { + for x in 0..10 { + assert_eq!(gray(x, y), 0, "{interpolate:?}: pixel ({x}, {y}) inside the snapped image"); + } + assert_eq!(gray(10, y), 255, "{interpolate:?}: column 10 is outside the snapped image (row {y})"); + } + for x in 0..11 { + assert_eq!(gray(x, 0), 255, "{interpolate:?}: row 0 is outside the snapped image (column {x})"); } - assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})"); } - for x in 0..11 { - assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})"); +} + +/// A tile draws exactly the pixels of the whole page under it, also where a shrunk image's +/// edge falls on a half pixel. The edges were snapped by rounding halves away from zero, so +/// the tile at x = 5, which moves the left edge from 2.5 to -2.5, snapped it one pixel left of +/// where the whole page did and shifted every column of the image. +#[test] +fn a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does() { + for interpolate in ["/Interpolate true ", ""] { + let ramp: Vec = (0..40 * 40).map(|i| (i % 40 * 6) as u8).collect(); + let content = "q 10 0 0 10 2.5 2.5 cm /Im0 Do Q"; + let image = hex(&ramp); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let req = RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }; + let full = renderer.render(req); + let tile = Tile { x: 5, y: 5, w: 10, h: 10 }; + let part = renderer.render(RenderRequest { tile: Some(tile), ..req }); + assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error); + assert_eq!((full.width, part.width, part.height), (20, 10, 10)); + for y in 0..10usize { + for x in 0..10usize { + let whole = full.rgba[((y + 5) * 20 + x + 5) * 4]; + assert_eq!(part.rgba[(y * 10 + x) * 4], whole, "{interpolate:?}: pixel ({x}, {y}) of the tile"); + } + } } } @@ -6570,28 +6608,132 @@ fn non_interpolated_gray_images_are_not_smoothed_when_minified() { } #[test] -fn non_integer_minification_samples_the_same_columns() { - // Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9. +fn non_integer_minification_averages_the_columns_each_pixel_covers() { + // Ten columns drawn six pixels wide: each pixel covers 10/6 columns. Pixel 0 is 60% of + // column 0 and 40% of column 1 (0.6 × 10 + 0.4 × 20 = 14), pixel 1 is 20% of column 1, + // all of column 2's 60% and 20% of column 3 (30), and so on. Nearest-neighbour read whole + // columns 0, 2, 4, 5, 7 and 9 instead, dropping the four others. let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1)); let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); - assert_eq!(grays, [10, 30, 50, 60, 80, 100]); + assert_eq!(grays, [14, 30, 46, 64, 80, 96]); } #[test] -fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() { - // Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are - // sampled, so the mask must pick the same columns as the colours. +fn non_integer_minification_averages_colour_and_mask_together() { + // Ten gray columns with a soft mask, drawn six pixels wide. Colours are averaged + // premultiplied, over the same columns as the mask, and composited over the white page. + // Pixel 0: 60% of column 0 (gray 10, opaque) and 40% of column 1 (transparent) is + // premultiplied gray 6 at alpha 153, so 6 + 255 × (1 − 153/255) = 108. Pixels 1 and 4 + // cover only transparent columns and show the page. let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255]; let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1)); - // Opaque columns keep their gray; transparent columns show the white page. assert_eq!( page.rgba.as_chunks::<4>().0.to_vec(), - vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]] + vec![[108, 108, 108, 255], [255, 255, 255, 255], [132, 132, 132, 255], [138, 138, 138, 255], [255, 255, 255, 255], [162, 162, 162, 255]] ); } +/// A scan shown at screen size: a stroke one sample wide in an image drawn at a third of its +/// resolution. Nearest-neighbour read columns 1, 4 and 7 and lost the stroke in column 3, which +/// is how the text of 300 dpi scans broke up at 100% zoom. Area averaging keeps it as the +/// third of pixel 1 it covers. +#[test] +fn non_interpolated_minification_keeps_thin_strokes() { + let mut stroke = [255u8; 9]; + stroke[3] = 0; + let page = draw_minified_image("DeviceGray", (9, 1), None, &stroke, None, (3, 1)); + let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); + assert_eq!(grays, [255, 170, 255]); +} + +/// A zoomed scan: an image of page-scan size drawn three times its resolution without +/// `/Interpolate`. Nearest-neighbour turned each sample into a 3 × 3 block, so the edges of +/// scanned text were staircases; it is smoothed (bilinearly) instead, as browsers draw it. +#[test] +fn large_non_interpolated_images_are_smoothed_when_enlarged() { + let side = 512usize; + let half: Vec = (0..side * side).map(|i| if i % side < side / 2 { 0 } else { 255 }).collect(); + let page = draw_minified_image("DeviceGray", (512, 512), None, &half, None, (1536, 1536)); + let row = 768 * 1536; + let edge: Vec = (760..776).map(|x| page.rgba[(row + x) * 4]).collect(); + assert!(edge.iter().any(|v| (32..=223).contains(v)), "a hard edge, not a smoothed one: {edge:?}"); + assert_eq!((page.rgba[(row + 700) * 4], page.rgba[(row + 836) * 4]), (0, 255), "away from the edge"); +} + +/// Small images (icons, QR codes, pixel art) keep their hard pixels when enlarged. +#[test] +fn small_non_interpolated_images_keep_hard_pixels_when_enlarged() { + let checker: Vec = (0..64).map(|i| if (i / 8 + i % 8) % 2 == 0 { 0 } else { 255 }).collect(); + let page = draw_minified_image("DeviceGray", (8, 8), None, &checker, None, (80, 80)); + assert!(page.rgba.as_chunks::<4>().0.iter().all(|px| px[0] == 0 || px[0] == 255), "a small image was smoothed"); +} + +/// A scan shrunk across and stretched down (a fax shown at screen size, zoomed in): the shrunk +/// side is still averaged, and the stretched side is smoothed as a scan enlarged both ways +/// is. Shrinking one side kept the nearest sampler for the other, so its edges were staircases. +#[test] +fn a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched() { + // Top half black; bottom half columns alternating black and white, which averages to gray. + let side = 512usize; + let source: Vec = (0..side * side).map(|i| if i / side < side / 2 || i % 2 == 0 { 0 } else { 255 }).collect(); + let page = draw_minified_image("DeviceGray", (512, 512), None, &source, None, (256, 1536)); + let gray = |x: usize, y: usize| page.rgba[(y * 256 + x) * 4]; + let edge: Vec = (760..776).map(|y| gray(128, y)).collect(); + assert!(edge.iter().any(|v| (16..=112).contains(v)), "a hard edge down the stretched side: {edge:?}"); + assert_eq!(gray(128, 700), 0, "above the edge"); + assert!((0..256).all(|x| gray(x, 836).abs_diff(128) <= 1), "the shrunk side is averaged at 1:1"); +} + +/// An image shrunk across and stretched down (a 204 × 98 dpi fax shown at screen size): the +/// shrunk side is still averaged. The area shrink was asked to enlarge the other side too, +/// refused, and the image fell back to its source resolution and the nearest sampler, which +/// read single columns. +#[test] +fn an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged() { + let ramp: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].repeat(2); + let page = draw_minified_image("DeviceGray", (8, 2), None, &ramp, None, (4, 4)); + let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); + assert_eq!(grays, [15, 35, 55, 75].repeat(4), "each pixel is the mean of the two columns it covers"); +} + +/// Averaging straight colours would tint an opaque sample with the colour of a transparent +/// neighbour: white beside transparent black, shrunk to one pixel, came out gray over the white +/// page. Premultiplied, the transparent sample adds nothing but its transparency. +#[test] +fn non_interpolated_minification_does_not_bleed_transparent_colour() { + let page = draw_minified_image("DeviceGray", (2, 1), None, &[255, 0], Some(&[255, 0]), (1, 1)); + assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), vec![[255, 255, 255, 255]]); +} + +/// The same with a soft mask smaller than its image: red, red, blue, blue under a mask that is +/// opaque, then transparent, shrunk to one pixel, is half-covered red over the white page. +/// Colour and mask were averaged apart and multiplied, so the transparent blue came through +/// as purple (191, 127, 191). +#[test] +fn a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified() { + let content = "q 1 0 0 1 0 0 cm /Im0 Do Q"; + let (image, mask) = ("FF0000FF00000000FF0000FF", "FF00"); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1, + mask.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let [r, g, b, _] = page.rgba.as_chunks::<4>().0.first().copied().unwrap_or_default(); + assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got ({r}, {g}, {b})"); +} + #[test] fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() { let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; @@ -7716,7 +7858,7 @@ fn stitched_render_partitions_match_a_whole_render() { /// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a /// curve, text, and a page shown rotated. /// -/// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing +/// Images are skipped the same way (vendored patch (11)): an image on a tile, one crossing /// tile lines, and one fully off the page. #[test] fn skipping_paths_off_the_canvas_changes_no_pixel() { diff --git a/docs/roadmap.md b/docs/roadmap.md index f376f7cb00..d83744afcf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,7 +22,7 @@ > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font helpers and regression contracts; behavior and estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print-preview font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main and retain Japanese serif/weight selection alongside display-only CJK fallback; estimates unchanged) -> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) @@ -321,6 +321,7 @@ Estimates unchanged. | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | | 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page (a red mark, a card naming the broken formulas, a note for built names); `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | | 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | +| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..367253b4a7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -734,8 +734,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes (it was nearest-neighbour, which dropped them). Enlarged, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat" [[feature]] id = "view.render-shadings-patterns" diff --git a/vendor/README.md b/vendor/README.md index 4fda6483af..1a58ad6c1f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | +| hayro | 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`, 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. Rows are averaged first (contiguous, cheap) and the fewer rows that leaves are 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). 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 on either side, 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 | (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::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` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 0b9cfe779e..120a751281 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -193,6 +193,27 @@ fn image_buffer(len: usize) -> Option> { Some(out) } +/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image, each image sample +/// taking the mask sample under its centre. `None` for an empty side or a buffer over the limits. +fn mask_on_image_grid(mask: &LumaData, width: u32, height: u32) -> Option> { + if width == 0 || height == 0 || mask.width == 0 || mask.height == 0 { + return None; + } + let mut out = image_buffer(source_byte_len(width, height, 1, MAX_IMAGE_PIXELS)?)?; + let mut columns = Vec::new(); + columns.try_reserve_exact(width as usize).ok()?; + columns.extend((0..width).map(|x| nearest_source_index(mask.width, width, x))); + let mask_width = mask.width as usize; + for y in 0..height { + let start = nearest_source_index(mask.height, height, y).checked_mul(mask_width)?; + let row = mask.data.get(start..start.checked_add(mask_width)?)?; + for &x in &columns { + out.push(*row.get(x)?); + } + } + Some(out) +} + /// PdfCraft patch (#624): the source index that PicScale's nearest-neighbour plan copies for /// destination `index` of `dst` samples taken from `src`. It is the same fixed-point centre /// sampling as `ResampleNearestPlan`, so these are the samples a nearest resize would copy. @@ -236,6 +257,177 @@ fn nearest_premultiplied_rgba( Some(out) } +/// PdfCraft patch: the shorter side, in samples, from which an image that did not ask for +/// `/Interpolate` is still smoothed when enlarged. A page scanned at 72 dpi is 612 samples wide; +/// icons, QR codes and pixel art are usually far smaller and keep their hard pixels. +const SMOOTHED_IMAGE_SIDE: u32 = 512; + +/// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights. +const AREA_ONE: u64 = 1 << 16; + +/// PdfCraft patch: the source samples target sample `t` covers when `src` samples shrink to `dst` +/// (`dst <= src`), with their weights in [`AREA_ONE`] units. Target `t` spans `[t·src, (t+1)·src)` +/// and source `s` spans `[s·dst, (s+1)·dst)`, both in units of 1/(src·dst) of the axis, so every +/// overlap is exact. The weights are differences of one rounded running sum, so they add up to +/// exactly [`AREA_ONE`] and a flat area keeps its exact value. +fn area_taps(src: u32, dst: u32, t: u32) -> impl Iterator { + let (src, dst, t) = (u64::from(src), u64::from(dst), u64::from(t)); + // `t < dst <= src <= 2^28` (MAX_IMAGE_PIXELS), so every product below fits in a u64. + let (lo, hi) = (t * src, (t + 1) * src); + let fixed = move |x: u64| (x * AREA_ONE + src / 2) / src; + (lo / dst..hi.div_ceil(dst)).map(move |s| { + let from = (s * dst).max(lo) - lo; + let to = ((s + 1) * dst).min(hi) - lo; + (s as usize, fixed(to) - fixed(from)) + }) +} + +/// PdfCraft patch: [`area_taps`] of every target sample of one axis, worked out once: for each +/// target, the first source sample it covers and the weights of the samples from there on (the +/// samples a target covers are consecutive). There are at most `src + dst` taps in all. +struct AreaAxis { + first: Vec, + /// Where each target's weights start in `weights`, and where the last one's end. + offsets: Vec, + weights: Vec, +} + +impl AreaAxis { + fn new(src: u32, dst: u32) -> Option { + let n = dst as usize; + let (mut first, mut offsets, mut weights) = (Vec::new(), Vec::new(), Vec::new()); + first.try_reserve_exact(n).ok()?; + offsets.try_reserve_exact(n.checked_add(1)?).ok()?; + weights.try_reserve_exact((src as usize).checked_add(n.checked_mul(2)?)?).ok()?; + offsets.push(0); + for t in 0..dst { + let mut start = None; + for (s, w) in area_taps(src, dst, t) { + start.get_or_insert(s); + // A weight is at most AREA_ONE, so it fits in a u32. + weights.push(w as u32); + } + first.push(start?); + offsets.push(weights.len()); + } + Some(Self { first, offsets, weights }) + } + + /// Target `t`'s first source sample and its weights. + fn get(&self, t: usize) -> Option<(usize, &[u32])> { + let (lo, hi) = (*self.offsets.get(t)?, *self.offsets.get(t.checked_add(1)?)?); + Some((*self.first.get(t)?, self.weights.get(lo..hi)?)) + } +} + +/// PdfCraft patch: shrinks `N`-channel samples from `source` to `target` (no side larger than its +/// source side) by area averaging: each target sample is the mean of the source it covers, +/// weighted by how much of it it covers. Nothing is invented between samples, which is what +/// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and +/// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen +/// size lost the strokes of its text. Premultiplied input stays premultiplied. Work memory is one +/// source row of `u32` accumulators, the horizontal taps (at most `sw + 2 × tw`) and a vertically +/// shrunk copy no larger than the source; a side over `MAX_RESAMPLING_SOURCE_SIDE` is refused, so +/// none of it exceeds a few times one source row. `None` if nothing shrinks, the sizes don't fit +/// or a buffer cannot be allocated. +fn area_shrink(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option> { + let ((sw, sh), (tw, th)) = (source, target); + // A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table + // several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file). + if tw == 0 || th == 0 || tw > sw || th > sh || sw > MAX_RESAMPLING_SOURCE_SIDE || sh > MAX_RESAMPLING_SOURCE_SIDE { + return None; + } + let (sw_us, tw_us) = (sw as usize, tw as usize); + if data.len() != source_byte_len(sw, sh, N, MAX_IMAGE_PIXELS)? { + return None; + } + // Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32. + let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8; + // Vertical pass first: groups of whole source rows averaged into each target row. Rows are + // contiguous, so this pass is cheap, and it leaves the horizontal pass, which gathers samples + // per target, a third as many rows when a scan shrinks threefold. + let tall = if th == sh { + None + } else { + let row_len = sw_us * N; + let mut rows = image_buffer(source_byte_len(sw, th, N, MAX_IMAGE_PIXELS)?)?; + let mut acc: Vec = Vec::new(); + acc.try_reserve_exact(row_len).ok()?; + acc.resize(row_len, 0); + for t in 0..th { + acc.fill(0); + for (s, w) in area_taps(sh, th, t) { + let row = data.get(s * row_len..(s + 1) * row_len)?; + // A weight is at most AREA_ONE, so it fits in a u32. + let w = w as u32; + for (a, v) in acc.iter_mut().zip(row) { + *a += w * u32::from(*v); + } + } + rows.extend(acc.iter().map(|a| round(*a))); + } + Some(rows) + }; + if tw == sw { + return tall; + } + let rows = tall.as_deref().unwrap_or(data); + // Horizontal pass: each of those rows shrunk to `tw` samples. The taps depend only on the + // widths, so they are worked out once, not again for each row. + let taps = AreaAxis::new(sw, tw)?; + let mut out = image_buffer(image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?)?; + for row in rows.chunks_exact(sw_us * N) { + for t in 0..tw_us { + let (first, weights) = taps.get(t)?; + let samples = row.get(first * N..(first + weights.len()) * N)?; + let mut acc = [0_u32; N]; + for (w, px) in weights.iter().zip(samples.chunks_exact(N)) { + for (a, v) in acc.iter_mut().zip(px) { + *a += w * u32::from(*v); + } + } + out.extend(acc.map(round)); + } + } + Some(out) +} + +/// PdfCraft patch: [`area_shrink`] for an image's pixel format. If it cannot run, the image keeps +/// its source resolution, as when [`Renderer::resize_image_data`] fails. +fn area_resize( + data: Vec, + source: (u32, u32), + target: (u32, u32), + format: ImagePixelFormat, +) -> (Vec, u32, u32) { + let shrunk = match format { + ImagePixelFormat::Luma => area_shrink::<1>(&data, source, target), + ImagePixelFormat::Rgb => area_shrink::<3>(&data, source, target), + ImagePixelFormat::Rgba => area_shrink::<4>(&data, source, target), + }; + match shrunk { + Some(out) => (out, target.0, target.1), + None => { + log::warn!( + "Image area shrink failed or exceeded its buffer limit; falling back to source data" + ); + (data, source.0, source.1) + } + } +} + +/// PdfCraft patch: premultiplies straight RGBA samples in place. +fn premultiply_rgba(rgba: &mut [u8]) { + let (chunks, _) = rgba.as_chunks_mut::<4>(); + for chunk in chunks { + let [r, g, b, a] = *chunk; + *chunk = AlphaColor::from_rgba8(r, g, b, a) + .premultiply() + .to_rgba8() + .to_u8_array(); + } +} + /// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every /// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on /// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over @@ -423,9 +615,12 @@ fn snap_minified_image( planned: (u32, u32), ) -> Option<(u32, u32, Affine)> { let [a, b, c, d, e, f] = transform.as_coeffs(); + // Halves round up, not away from zero: a tile's origin moves edges by whole pixels, and only + // then do they snap where the whole page snaps them (-2.5 must go to -2 as 2.5 goes to 3). + let snap = |v: f64| (v + 0.5).floor(); // The device span of a source edge from `origin` across `side` samples of `scale`, rounded. let edge = |origin: f64, scale: f64, side: u32| { - let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (p, q) = (snap(origin), snap(origin + scale * f64::from(side))); let (lo, hi) = (p.min(q), p.max(q)); (hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi)) }; @@ -935,17 +1130,51 @@ impl Renderer { return; } let interpolate = image_data.interpolate(); - if let Some(a) = &alpha_data { + let requested_resize = x_scale < 1.0 || y_scale < 1.0; + // PdfCraft patch: an image that did not ask for interpolation and is drawn below its + // resolution is shrunk by area averaging (`area_shrink`), not nearest-neighbour, which + // broke up the text of scans shown at screen size. Type 3 glyph images keep the nearest + // shrink of #624: they are drawn with a frame of their own and never snapped. + let area = !interpolate + && requested_resize + && !self.in_type3_glyph + // Wider sources keep #624's bounded nearest-neighbour shrink (`area_shrink` refuses + // them): skipping them, as a refused shrink at source size would, loses the image. + && source_width.max(source_height) <= MAX_RESAMPLING_SOURCE_SIDE; + let mut alpha_data = alpha_data; + if let Some(a) = alpha_data.take() { if source_byte_len(a.width, a.height, 1, MAX_IMAGE_PIXELS) != Some(a.data.len()) { log::warn!("Image has invalid or over-limit alpha dimensions/data and was skipped"); return; } if a.width != source_width || a.height != source_height || a.interpolate != interpolate { - if let Some(alpha) = alpha_data { - self.draw_image_with_alpha_mask(image_data, alpha); + // PdfCraft patch: a soft mask no larger than its area-shrunk image is sampled onto + // the image's grid, so colour and mask are averaged together, premultiplied. Drawn + // as a mask layer over the image instead, the averaged colour (transparent samples + // included) was multiplied by the averaged mask, and the colour of transparent + // samples showed at the mask's edges. A larger mask carries detail the image does + // not (#1649) and keeps the layer. + let on_grid = (area && a.width <= source_width && a.height <= source_height) + .then(|| mask_on_image_grid(&a, source_width, source_height)) + .flatten(); + match on_grid { + Some(data) => { + alpha_data = Some(LumaData { + data, + width: source_width, + height: source_height, + interpolate, + ..a + }); + } + None => { + self.draw_image_with_alpha_mask(image_data, a); + return; + } } - return; + } else { + alpha_data = Some(a); } } let mut quality = if interpolate { @@ -955,7 +1184,6 @@ impl Renderer { }; let has_alpha = alpha_data.is_some(); let mut may_have_transparency = has_alpha || self.force_images_may_have_transparency(); - let requested_resize = x_scale < 1.0 || y_scale < 1.0; let (new_width, new_height) = match image_resampling_size( source_width, source_height, @@ -985,12 +1213,28 @@ impl Renderer { if requested_resize && self.in_type3_glyph && interpolate { quality = ImageQuality::High; } - // PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's - // (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to - // exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the - // shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale - // and offset, which blurs scans and photos. - let snapped = if interpolate + // PdfCraft patch: an image the size of a page scan is smoothed when drawn above its + // resolution, even without `/Interpolate`: bilinearly, as images that ask for it are. + // Scans rarely ask, and drawn nearest-neighbour each sample of a zoomed scan became a + // block, so the edges of its text were staircases. Bicubic looked the same on a zoomed + // 300 dpi scan and drew its tiles five times slower. Smaller images (icons, QR codes, + // pixel art) keep their hard pixels. A scan shrunk on one side and stretched on the other + // is smoothed too: the area shrink leaves the shrunk side at 1:1, where bilinear adds + // nothing, and the stretched side would otherwise be drawn nearest-neighbour. + if !interpolate + && !self.in_type3_glyph + && (x_scale > 1.0 || y_scale > 1.0) + && source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE + { + quality = ImageQuality::Medium; + } + // PdfCraft patch: an image that is shrunk (filtered or area-averaged), with its axes along + // the device's (upright, mirrored or a quarter turn), lands on whole device pixels. It is + // shrunk once to exactly its snapped device rectangle and drawn 1:1 from the snapped + // corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a second time + // at a fractional scale and offset, which blurs scans and photos (or, by the nearest + // sampler of a non-interpolated image, drops samples again). + let snapped = if (interpolate || area) && requested_resize && !self.in_type3_glyph && (new_width, new_height) != (source_width, source_height) @@ -1000,13 +1244,22 @@ impl Renderer { None }; let (new_width, new_height) = snapped.map_or((new_width, new_height), |s| (s.0, s.1)); + // PdfCraft patch: an area 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, or a snapped side + // rounded past its source) keeps its samples and is enlarged by the sampler; the snap + // then no longer matches, so the full transform places the image. + let (new_width, new_height) = if area { + (new_width.min(source_width), new_height.min(source_height)) + } else { + (new_width, new_height) + }; let needs_resize = (new_width, new_height) != (source_width, source_height); - // PdfCraft patch (#624): a minified image with a soft mask that is not interpolated is - // nearest-sampled straight into premultiplied target RGBA. That skips the full-resolution - // RGBA copy below and the premultiply pass after it. + // PdfCraft patch (#624): a minified Type 3 glyph image with a soft mask that is not + // interpolated is nearest-sampled straight into premultiplied target RGBA. That skips the + // full-resolution RGBA copy below and the premultiply pass after it. let direct = match &alpha_data { - Some(alpha) if needs_resize && !interpolate => nearest_premultiplied_rgba( + Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba( &image_data, &alpha.data, (source_width, source_height), @@ -1014,7 +1267,7 @@ impl Renderer { ), _ => None, }; - let premultiplied = direct.is_some(); + let mut premultiplied = direct.is_some(); // Preserve the single-channel/RGB fast paths. With alpha, expand directly to RGBA; // every source and alpha length was validated before zipping or allocating. let (data, format) = match (direct, image_data, alpha_data) { @@ -1057,6 +1310,15 @@ impl Renderer { }; let (data, mut img_width, mut img_height) = if premultiplied { (data, new_width, new_height) + } else if needs_resize && area { + let mut data = data; + if matches!(format, ImagePixelFormat::Rgba) { + // Averaging straight colours would bleed the colour of transparent samples into + // their neighbours; premultiplied colours count each by its coverage. + premultiply_rgba(&mut data); + premultiplied = true; + } + area_resize(data, (source_width, source_height), (new_width, new_height), format) } else if needs_resize { self.resize_image_data( data, @@ -1103,14 +1365,7 @@ impl Renderer { rgba }; if has_alpha && !premultiplied { - let (chunks, _) = rgba_data.as_chunks_mut::<4>(); - for chunk in chunks { - let [r, g, b, a] = *chunk; - *chunk = AlphaColor::from_rgba8(r, g, b, a) - .premultiply() - .to_rgba8() - .to_u8_array(); - } + premultiply_rgba(&mut rgba_data); } // Type 3 glyphs get a transparent two-pixel frame for interpolation. Check the padded // RGBA allocation and u16 dimensions too, before narrowing or growing either side. @@ -2635,3 +2890,109 @@ mod snap_tests { assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some()); } } + +/// PdfCraft patch: the area-averaging shrink of non-interpolated images. +#[cfg(test)] +mod area_shrink_tests { + use super::{ + AREA_ONE, AreaAxis, LumaData, MAX_IMAGE_PIXELS, area_shrink, area_taps, mask_on_image_grid, + }; + + fn mask(data: &[u8], width: u32, height: u32) -> LumaData { + LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) } + } + + /// Each image sample takes the mask sample under its centre, at whole and fractional ratios. + #[test] + fn a_mask_is_sampled_onto_the_image_grid() { + assert_eq!(mask_on_image_grid(&mask(&[10, 20], 2, 1), 4, 2), Some(vec![10, 10, 20, 20, 10, 10, 20, 20])); + // Centres of 7 samples over 3: 0.5/7·3 = 0.21 → 0, 1.5/7·3 = 0.64 → 0, 2.5/7·3 = 1.07 → 1, … + assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3], 3, 1), 7, 1), Some(vec![1, 1, 2, 2, 2, 3, 3])); + assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), Some(vec![2, 4])); + } + + #[test] + fn malformed_masks_are_not_sampled() { + assert_eq!(mask_on_image_grid(&mask(&[], 0, 1), 4, 1), None, "an empty mask"); + assert_eq!(mask_on_image_grid(&mask(&[1, 2], 2, 1), 0, 1), None, "an empty image"); + assert_eq!(mask_on_image_grid(&mask(&[1], 2, 1), 4, 1), None, "mask data shorter than its size"); + } + + /// Every target sample's weights add up to exactly one, and together the targets take every + /// source sample exactly once (each `dst / src` of it, in fixed point within rounding). + #[test] + fn taps_are_exact_partitions_of_unity() { + for src in 1..=40_u32 { + for dst in 1..=src { + let mut taken = vec![0_u64; src as usize]; + for t in 0..dst { + let taps: Vec<_> = area_taps(src, dst, t).collect(); + assert_eq!(taps.iter().map(|(_, w)| w).sum::(), AREA_ONE, "{src} → {dst}, target {t}"); + assert!(taps.iter().all(|(_, w)| *w > 0), "{src} → {dst}, target {t}: an empty tap"); + for (s, w) in taps { + taken[s] += w; + } + } + // Source s is dst/src of one target in all; scaled by src/dst, that is one. + for (s, w) in taken.iter().enumerate() { + let share = (*w * u64::from(src)) as f64 / (AREA_ONE * u64::from(dst)) as f64; + assert!((share - 1.0).abs() < 1e-3, "{src} → {dst}: source {s} weighs {share}"); + } + } + } + } + + /// The table the horizontal pass reads holds exactly the taps of every target, in order. + #[test] + fn the_tap_table_holds_every_targets_taps() { + for src in 1..=40_u32 { + for dst in 1..=src { + let axis = AreaAxis::new(src, dst).expect("table"); + for t in 0..dst { + let (first, weights) = axis.get(t as usize).expect("target"); + let expected: Vec<(usize, u64)> = area_taps(src, dst, t).collect(); + let got: Vec<(usize, u64)> = weights.iter().enumerate().map(|(k, w)| (first + k, u64::from(*w))).collect(); + assert_eq!(got, expected, "{src} → {dst}, target {t}"); + } + assert_eq!(axis.get(dst as usize), None, "{src} → {dst}: no target past the last"); + } + } + } + + /// The largest side an image may have still computes in u64 without overflowing. + #[test] + fn taps_at_the_pixel_limit_do_not_overflow() { + let side = MAX_IMAGE_PIXELS as u32; + for (dst, t) in [(1, 0), (3, 2), (side - 1, side - 2), (side, side - 1)] { + assert_eq!(area_taps(side, dst, t).map(|(_, w)| w).sum::(), AREA_ONE, "{side} → {dst}, target {t}"); + } + } + + #[test] + fn flat_images_keep_their_exact_value() { + for (source, target) in [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1))] { + let data = vec![77_u8; (source.0 * source.1 * 3) as usize]; + let out = area_shrink::<3>(&data, source, target).expect("shrinks"); + assert_eq!(out.len(), (target.0 * target.1 * 3) as usize); + assert!(out.iter().all(|v| *v == 77), "{source:?} → {target:?}: {out:?}"); + } + } + + /// A side over the resampling limit is refused before any work buffer is allocated. + #[test] + fn sides_over_the_resampling_limit_are_refused() { + let wide = (super::MAX_RESAMPLING_SOURCE_SIDE + 1) as usize; + assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (wide as u32, 1), (10, 1)), None, "too wide"); + assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (1, wide as u32), (1, 10)), None, "too tall"); + } + + #[test] + fn malformed_shrinks_are_refused() { + let data = vec![0_u8; 4 * 4]; + assert_eq!(area_shrink::<1>(&data, (4, 4), (4, 4)), None, "nothing shrinks"); + assert_eq!(area_shrink::<1>(&data, (4, 4), (5, 2)), None, "a side grows"); + assert_eq!(area_shrink::<1>(&data, (4, 4), (0, 2)), None, "an empty side"); + assert_eq!(area_shrink::<1>(&data[1..], (4, 4), (2, 2)), None, "data shorter than the source"); + assert_eq!(area_shrink::<3>(&data, (4, 4), (2, 2)), None, "data of another channel count"); + } +} From 5ed2f4ba4f34d5eff689727d468186bc18b53350 Mon Sep 17 00:00:00 2001 From: kronbii <145912020+Kronbii@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:50:48 +0200 Subject: [PATCH 05/59] M2: keep an interpolated soft mask smooth on a shrunk image's grid A soft mask no larger than its area-shrunk image is sampled onto the image's grid, and it was always sampled nearest-neighbour: a mask that asks for /Interpolate became a hard step where main drew a ramp (jpeskov's 64 x 64 image at 32 x 32 under a 2 x 2 mask). An interpolated mask is now blended bilinearly onto the grid, centres aligned; one without /Interpolate is still sampled nearest, which keeps the colour-fringe fix. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 29 ++++++++++++ vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 74 ++++++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 7312f3ddaf..f6c0530164 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -6734,6 +6734,35 @@ fn a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified() { assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got ({r}, {g}, {b})"); } +/// A smaller soft mask that asks for `/Interpolate` stays smooth when it is sampled onto the grid +/// of an area-shrunk image. Here a 64 × 64 black image is drawn at 32 × 32 under a 2 × 2 mask, +/// opaque on the left and clear on the right: the mask is enlarged while the image shrinks, and +/// sampled nearest-neighbour onto the image's grid the edge became a hard black and white step. +#[test] +fn an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified() { + let content = "q 32 0 0 32 0 0 cm /Im0 Do Q"; + let (image, mask) = ("00".repeat(64 * 64), "FF00FF00"); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 32 32] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1, + mask.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (32, 32)); + let row: Vec = page.rgba.as_chunks::<4>().0.iter().skip(16 * 32).take(32).map(|[r, ..]| *r).collect(); + let ramp = row.iter().filter(|v| (1..255).contains(*v)).count(); + assert!(ramp >= 8 && row.is_sorted() && row.first() == Some(&0) && row.last() == Some(&255), "a smooth black to white edge, got {row:?}"); +} + #[test] fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() { let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; diff --git a/vendor/README.md b/vendor/README.md index 1a58ad6c1f..0d90ad397e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (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`, 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. Rows are averaged first (contiguous, cheap) and the fewer rows that leaves are 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). 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 on either side, 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 | (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::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` | +| 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. Rows are averaged first (contiguous, cheap) and the fewer rows that leaves are 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). 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 on either side, 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 | (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::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` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 120a751281..c3d1d18520 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -193,27 +193,67 @@ fn image_buffer(len: usize) -> Option> { Some(out) } -/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image, each image sample -/// taking the mask sample under its centre. `None` for an empty side or a buffer over the limits. +/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image (no side smaller +/// than the mask's). Each image sample takes the mask sample under its centre or, for a mask that +/// asks for `/Interpolate`, the bilinear blend of the four around it, so the mask's edges stay as +/// smooth as when it was enlarged on its own. `None` for an empty side, an image side smaller than +/// the mask's, or a buffer over the limits. fn mask_on_image_grid(mask: &LumaData, width: u32, height: u32) -> Option> { if width == 0 || height == 0 || mask.width == 0 || mask.height == 0 { return None; } let mut out = image_buffer(source_byte_len(width, height, 1, MAX_IMAGE_PIXELS)?)?; - let mut columns = Vec::new(); - columns.try_reserve_exact(width as usize).ok()?; - columns.extend((0..width).map(|x| nearest_source_index(mask.width, width, x))); + let columns = grid_axis(mask.width, width, mask.interpolate)?; + let rows = grid_axis(mask.height, height, mask.interpolate)?; let mask_width = mask.width as usize; - for y in 0..height { - let start = nearest_source_index(mask.height, height, y).checked_mul(mask_width)?; - let row = mask.data.get(start..start.checked_add(mask_width)?)?; - for &x in &columns { - out.push(*row.get(x)?); + let row = |y: usize| { + let start = y.checked_mul(mask_width)?; + mask.data.get(start..start.checked_add(mask_width)?) + }; + let one = u64::from(GRID_ONE); + for &(y0, y1, fy) in &rows { + let (top, bottom, fy) = (row(y0)?, row(y1)?, u64::from(fy)); + for &(x0, x1, fx) in &columns { + let fx = u64::from(fx); + let blend = |r: &[u8]| Some(u64::from(*r.get(x0)?) * (one - fx) + u64::from(*r.get(x1)?) * fx); + // At most 255 × GRID_ONE², so the sum fits in a u64 and the result in a u8. + let v = (blend(top)? * (one - fy) + blend(bottom)? * fy + one * one / 2) / (one * one); + out.push(v.min(255) as u8); } } Some(out) } +/// PdfCraft patch: fixed-point unit of [`grid_axis`]'s blend weights. +const GRID_ONE: u32 = 1 << 16; + +/// PdfCraft patch: for each of `dst` samples (`dst >= src`) taken from `src` along one axis, the +/// two source samples it lies between and the weight of the second, in [`GRID_ONE`] units. +/// Centres line up: target `t` lies at `(t + ½)·src/dst − ½` source samples, held to the ends. +/// Without `smooth` both are the nearest sample, so the weight does not matter. +fn grid_axis(src: u32, dst: u32, smooth: bool) -> Option> { + if src == 0 || dst < src { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(dst as usize).ok()?; + let last = u64::from(src - 1) << 16; + for t in 0..dst { + if !smooth { + let s = nearest_source_index(src, dst, t); + out.push((s, s, 0)); + continue; + } + // In u128, as `(2t + 1)·src·2^16` can pass 2^64 for a long side; the quotient is at + // most `src·2^16`, so it fits back in a u64. + let centre = ((2 * u128::from(t) + 1) * u128::from(src) << 16) / (2 * u128::from(dst)); + let at = u64::try_from(centre).ok()?.saturating_sub(u64::from(GRID_ONE / 2)).min(last); + let s = (at >> 16) as usize; + out.push((s, (s + 1).min(src as usize - 1), (at & 0xFFFF) as u32)); + } + Some(out) +} + /// PdfCraft patch (#624): the source index that PicScale's nearest-neighbour plan copies for /// destination `index` of `dst` samples taken from `src`. It is the same fixed-point centre /// sampling as `ResampleNearestPlan`, so these are the samples a nearest resize would copy. @@ -2908,7 +2948,18 @@ mod area_shrink_tests { assert_eq!(mask_on_image_grid(&mask(&[10, 20], 2, 1), 4, 2), Some(vec![10, 10, 20, 20, 10, 10, 20, 20])); // Centres of 7 samples over 3: 0.5/7·3 = 0.21 → 0, 1.5/7·3 = 0.64 → 0, 2.5/7·3 = 1.07 → 1, … assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3], 3, 1), 7, 1), Some(vec![1, 1, 2, 2, 2, 3, 3])); - assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), Some(vec![2, 4])); + } + + /// A mask that asks for `/Interpolate` is blended between the samples around each centre, + /// and held at its ends: the centres of 4 samples over 2 lie at -¼, ¼, ¾ and 1¼. + #[test] + fn an_interpolated_mask_is_blended_onto_the_image_grid() { + let smooth = |data: &[u8], width, height| LumaData { interpolate: true, ..mask(data, width, height) }; + assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 2, 1), 4, 2), Some(vec![0, 64, 191, 255, 0, 64, 191, 255])); + // Centres of 7 samples over 3 lie 3/7 apart, from -2/7 to 2 2/7: a ramp, flat at the ends. + assert_eq!(mask_on_image_grid(&smooth(&[0, 70, 140], 3, 1), 7, 1), Some(vec![0, 10, 40, 70, 100, 130, 140])); + assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 1, 2), 1, 4), Some(vec![0, 64, 191, 255])); + assert_eq!(mask_on_image_grid(&smooth(&[7, 9], 2, 1), 2, 1), Some(vec![7, 9]), "same size is unchanged"); } #[test] @@ -2916,6 +2967,7 @@ mod area_shrink_tests { assert_eq!(mask_on_image_grid(&mask(&[], 0, 1), 4, 1), None, "an empty mask"); assert_eq!(mask_on_image_grid(&mask(&[1, 2], 2, 1), 0, 1), None, "an empty image"); assert_eq!(mask_on_image_grid(&mask(&[1], 2, 1), 4, 1), None, "mask data shorter than its size"); + assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), None, "a mask larger than the image"); } /// Every target sample's weights add up to exactly one, and together the targets take every From c5065554a1946e4ca8895048d70095f68a4ecec5 Mon Sep 17 00:00:00 2001 From: kronbii <145912020+Kronbii@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:07:44 +0200 Subject: [PATCH 06/59] M2: shrink masked images a row at a time; keep near-1:1 scans sharp Review of #3858 (aiongg): - An area-shrunk image with a soft mask, a stencil's ink included, built its full-resolution RGBA before shrinking. A 10718 x 13701 stencil at page size peaked at 1.16 GB (587 MB of RGBA). It is now premultiplied a source row at a time as it is averaged, and the shrink works a target row at a time, so it peaks at 1.03 GB, as the nearest-neighbour path did. Same bytes; about 5% slower per shrink. - An image whose f32 scale passed 1 only by rounding was smoothed bilinearly and blurred by half a pixel at a half-pixel offset. It is now smoothed only above 1.01. - A shrunk /ImageMask stencil keeping its thin strokes (#4800) is now tested. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 53 +++++++ parity/acrobat-features.toml | 4 +- vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 255 ++++++++++++++++++++++++------ 4 files changed, 267 insertions(+), 47 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index f6c0530164..5692634c30 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -6814,6 +6814,59 @@ fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() { assert_eq!(grays[3], 255, "{grays:?}"); } +/// Issue #4800: scanners save the text of a page as a 1-bit `/ImageMask` stencil, painted in the +/// fill colour, and a stencil shrunk by nearest-neighbour lost the strokes thinner than the shrink +/// factor. Here a one-column red stroke of an 8-column stencil is drawn 4 pixels wide: nearest +/// reads columns 1, 3, 5 and 7 and misses it, while the area average makes it a half-covered red. +#[test] +fn a_shrunk_stencil_mask_keeps_thin_strokes() { + // Sample 0 paints (the default `/Decode [0 1]`), so 0xDF is a stroke in column 2. + let content = "q 1 0 0 rg 4 0 0 1 0 0 cm /Im0 Do Q"; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream\nDF>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len() + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (4, 1)); + let px = page.rgba.as_chunks::<4>().0.to_vec(); + let [r, g, b, _] = px.get(1).copied().unwrap_or_default(); + assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}"); + assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}"); +} + +/// A scan drawn at its own resolution keeps its hard pixels when its scale passes 1 only by +/// rounding: `x_scale` is an `f32`, and a 192 dpi scan on a 2× display at 100% can come out a +/// few ulps above 1. Smoothed bilinearly at a half-pixel offset, one-pixel stripes became gray. +/// Here `600.0001` (an `f32` of 600.00012) draws 600 samples 2·10⁻⁷ wider than 1:1. +#[test] +fn a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp() { + let content = "q 600.0001 0 0 600 0.5 0 cm /Im0 Do Q"; + let image: String = (0..600 * 600).map(|i| if i % 2 == 0 { "00" } else { "FF" }).collect(); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 601 600] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let row: Vec = page.rgba.as_chunks::<4>().0.iter().skip(300 * page.width as usize).take(page.width as usize).map(|[r, ..]| *r).collect(); + let gray = row.iter().skip(10).take(580).filter(|v| (16..240).contains(*v)).count(); + assert_eq!(gray, 0, "one-pixel stripes were blurred: {:?}", row.get(10..30)); +} + /// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their /// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels /// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 367253b4a7..a0119d06e2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -734,8 +734,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes (it was nearest-neighbour, which dropped them). Enlarged, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat" +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat" [[feature]] id = "view.render-shadings-patterns" diff --git a/vendor/README.md b/vendor/README.md index 0d90ad397e..28cc7ce9e7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (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. Rows are averaged first (contiguous, cheap) and the fewer rows that leaves are 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). 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 on either side, 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 | (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::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` | +| 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 | (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` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index c3d1d18520..1e46e3b79e 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -302,6 +302,12 @@ fn nearest_premultiplied_rgba( /// icons, QR codes and pixel art are usually far smaller and keep their hard pixels. const SMOOTHED_IMAGE_SIDE: u32 = 512; +/// PdfCraft patch: the scale above which such an image counts as enlarged. A scan drawn at its own +/// resolution can come out a few ulps above 1 (the scale is an `f32`: a 192 dpi scan on a 2× +/// display at 100%), and sampled bilinearly at a half-pixel offset it blurred by half a pixel. +/// Below 1% nearest-neighbour repeats at most one sample in a hundred. +const SMOOTHED_IMAGE_SCALE: f32 = 1.01; + /// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights. const AREA_ONE: u64 = 1 << 16; @@ -365,60 +371,132 @@ impl AreaAxis { /// weighted by how much of it it covers. Nothing is invented between samples, which is what /// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and /// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen -/// size lost the strokes of its text. Premultiplied input stays premultiplied. Work memory is one -/// source row of `u32` accumulators, the horizontal taps (at most `sw + 2 × tw`) and a vertically -/// shrunk copy no larger than the source; a side over `MAX_RESAMPLING_SOURCE_SIDE` is refused, so -/// none of it exceeds a few times one source row. `None` if nothing shrinks, the sizes don't fit -/// or a buffer cannot be allocated. +/// size lost the strokes of its text. Premultiplied input stays premultiplied. `None` if nothing +/// shrinks, the sizes don't fit or a buffer cannot be allocated. fn area_shrink(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option> { + if data.len() != source_byte_len(source.0, source.1, N, MAX_IMAGE_PIXELS)? { + return None; + } + let row_len = (source.0 as usize).checked_mul(N)?; + area_shrink_rows::(source, target, |s, row| { + let start = s.checked_mul(row_len)?; + row.copy_from_slice(data.get(start..start.checked_add(row_len)?)?); + Some(()) + }) +} + +/// PdfCraft patch: [`area_shrink`] of a colour image under its same-size soft mask (a stencil's +/// ink under the stencil, or an image under a mask sampled onto its grid), premultiplied a source +/// row at a time as it is averaged. The result equals premultiplying the expanded RGBA and +/// shrinking that, without building it: a 10718 × 13701 stencil shown at page size peaked at +/// 1.16 GB with the full-resolution RGBA, which is 587 MB. +fn area_shrink_premultiplied( + color: &ImageData, + alpha: &[u8], + source: (u32, u32), + target: (u32, u32), +) -> Option> { + let (samples, channels) = match color { + ImageData::Luma(luma) => (luma.data.as_slice(), 1), + ImageData::Rgb(rgb) => (rgb.data.as_slice(), 3), + }; + if samples.len() != source_byte_len(source.0, source.1, channels, MAX_IMAGE_PIXELS)? + || alpha.len() != source_byte_len(source.0, source.1, 1, MAX_IMAGE_PIXELS)? + { + return None; + } + let width = source.0 as usize; + area_shrink_rows::<4>(source, target, |s, row| { + let start = s.checked_mul(width)?; + let end = start.checked_add(width)?; + let colors = samples.get(start.checked_mul(channels)?..end.checked_mul(channels)?)?; + let alphas = alpha.get(start..end)?; + for ((out, px), a) in row.chunks_exact_mut(4).zip(colors.chunks_exact(channels)).zip(alphas) { + let [r, g, b] = match px { + [v] => [*v; 3], + [r, g, b] => [*r, *g, *b], + _ => return None, + }; + out.copy_from_slice(&AlphaColor::from_rgba8(r, g, b, *a).premultiply().to_rgba8().to_u8_array()); + } + Some(()) + }) +} + +/// PdfCraft patch: the area shrink behind [`area_shrink`], reading source row `s` through +/// `read_row(s, row)` into a buffer of one row. Each target row averages the source rows it +/// covers, and that row is shrunk across at once with taps worked out once per image, so work +/// memory is one source row of samples, one of `u32` accumulators, one shrunk row and the +/// horizontal taps (at most `sw + 2 × tw`): never the image. A side over +/// `MAX_RESAMPLING_SOURCE_SIDE` is refused, so none of it exceeds a few times one source row. +fn area_shrink_rows( + source: (u32, u32), + target: (u32, u32), + mut read_row: impl FnMut(usize, &mut [u8]) -> Option<()>, +) -> Option> { let ((sw, sh), (tw, th)) = (source, target); // A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table // several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file). - if tw == 0 || th == 0 || tw > sw || th > sh || sw > MAX_RESAMPLING_SOURCE_SIDE || sh > MAX_RESAMPLING_SOURCE_SIDE { - return None; - } - let (sw_us, tw_us) = (sw as usize, tw as usize); - if data.len() != source_byte_len(sw, sh, N, MAX_IMAGE_PIXELS)? { + if tw == 0 + || th == 0 + || tw > sw + || th > sh + || (tw, th) == (sw, sh) + || sw > MAX_RESAMPLING_SOURCE_SIDE + || sh > MAX_RESAMPLING_SOURCE_SIDE + { return None; } + let row_len = (sw as usize).checked_mul(N)?; + let zeroed = |len: usize| { + let mut v = image_buffer(len)?; + v.resize(len, 0); + Some(v) + }; + let (mut source_row, mut shrunk_row) = (zeroed(row_len)?, image_buffer(row_len)?); + let mut acc: Vec = Vec::new(); + acc.try_reserve_exact(row_len).ok()?; + acc.resize(row_len, 0); + // The taps depend only on the widths, so they are worked out once, not again for each row. + let taps = if tw == sw { None } else { Some(AreaAxis::new(sw, tw)?) }; + let out_len = match taps { + None => source_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?, + Some(_) => image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?, + }; + let mut out = image_buffer(out_len)?; // Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32. let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8; - // Vertical pass first: groups of whole source rows averaged into each target row. Rows are - // contiguous, so this pass is cheap, and it leaves the horizontal pass, which gathers samples - // per target, a third as many rows when a scan shrinks threefold. - let tall = if th == sh { - None - } else { - let row_len = sw_us * N; - let mut rows = image_buffer(source_byte_len(sw, th, N, MAX_IMAGE_PIXELS)?)?; - let mut acc: Vec = Vec::new(); - acc.try_reserve_exact(row_len).ok()?; - acc.resize(row_len, 0); - for t in 0..th { + // The last source row read: neighbouring target rows share the row on their boundary. + let mut loaded = None; + for t in 0..th { + // Vertical: the whole source rows target row `t` covers, averaged. + let row: &[u8] = if th == sh { + read_row(t as usize, &mut source_row)?; + &source_row + } else { acc.fill(0); for (s, w) in area_taps(sh, th, t) { - let row = data.get(s * row_len..(s + 1) * row_len)?; + if loaded != Some(s) { + read_row(s, &mut source_row)?; + loaded = Some(s); + } // A weight is at most AREA_ONE, so it fits in a u32. let w = w as u32; - for (a, v) in acc.iter_mut().zip(row) { + for (a, v) in acc.iter_mut().zip(&source_row) { *a += w * u32::from(*v); } } - rows.extend(acc.iter().map(|a| round(*a))); - } - Some(rows) - }; - if tw == sw { - return tall; - } - let rows = tall.as_deref().unwrap_or(data); - // Horizontal pass: each of those rows shrunk to `tw` samples. The taps depend only on the - // widths, so they are worked out once, not again for each row. - let taps = AreaAxis::new(sw, tw)?; - let mut out = image_buffer(image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?)?; - for row in rows.chunks_exact(sw_us * N) { - for t in 0..tw_us { - let (first, weights) = taps.get(t)?; + shrunk_row.clear(); + shrunk_row.extend(acc.iter().map(|a| round(*a))); + &shrunk_row + }; + // Horizontal: that row shrunk to `tw` samples. + let Some(taps) = &taps else { + out.extend_from_slice(row); + continue; + }; + for x in 0..tw as usize { + let (first, weights) = taps.get(x)?; let samples = row.get(first * N..(first + weights.len()) * N)?; let mut acc = [0_u32; N]; for (w, px) in weights.iter().zip(samples.chunks_exact(N)) { @@ -1263,7 +1341,7 @@ impl Renderer { // nothing, and the stretched side would otherwise be drawn nearest-neighbour. if !interpolate && !self.in_type3_glyph - && (x_scale > 1.0 || y_scale > 1.0) + && (x_scale > SMOOTHED_IMAGE_SCALE || y_scale > SMOOTHED_IMAGE_SCALE) && source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE { quality = ImageQuality::Medium; @@ -1295,9 +1373,12 @@ impl Renderer { }; let needs_resize = (new_width, new_height) != (source_width, source_height); - // PdfCraft patch (#624): a minified Type 3 glyph image with a soft mask that is not - // interpolated is nearest-sampled straight into premultiplied target RGBA. That skips the - // full-resolution RGBA copy below and the premultiply pass after it. + // PdfCraft patch (#624): a minified image with a soft mask that is neither interpolated nor + // area-averaged (a Type 3 glyph image, or a source wider than MAX_RESAMPLING_SOURCE_SIDE) + // is nearest-sampled straight into premultiplied target RGBA. That skips the + // full-resolution RGBA copy below and the premultiply pass after it. PdfCraft patch: an + // area-averaged one (a stencil's ink, or an image under its mask) skips them too, and is + // premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`). let direct = match &alpha_data { Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba( &image_data, @@ -1305,6 +1386,12 @@ impl Renderer { (source_width, source_height), (new_width, new_height), ), + Some(alpha) if needs_resize && area => area_shrink_premultiplied( + &image_data, + &alpha.data, + (source_width, source_height), + (new_width, new_height), + ), _ => None, }; let mut premultiplied = direct.is_some(); @@ -2935,9 +3022,89 @@ mod snap_tests { #[cfg(test)] mod area_shrink_tests { use super::{ - AREA_ONE, AreaAxis, LumaData, MAX_IMAGE_PIXELS, area_shrink, area_taps, mask_on_image_grid, + AREA_ONE, AreaAxis, ImageData, LumaData, MAX_IMAGE_PIXELS, RgbData, area_shrink, + area_shrink_premultiplied, area_taps, mask_on_image_grid, premultiply_rgba, }; + /// Deterministic samples that vary from one to the next. + fn noise(len: usize, seed: u32) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761) | 1; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 + }) + .collect() + } + + /// The shrink as two whole passes, every row averaged down and then every row across, each + /// rounded to a byte: what `area_shrink` did before it worked a row at a time. + fn two_pass_shrink(data: &[u8], (sw, sh): (u32, u32), (tw, th): (u32, u32)) -> Vec { + let round = |acc: u64| ((acc + AREA_ONE / 2) >> 16).min(255) as u8; + let row_len = sw as usize * N; + let tall: Vec = (0..th) + .flat_map(|t| { + (0..row_len).map(move |i| { + round(area_taps(sh, th, t).map(|(s, w)| w * u64::from(data[s * row_len + i])).sum()) + }) + }) + .collect(); + tall.chunks_exact(row_len) + .flat_map(|row| { + (0..tw).flat_map(move |t| { + (0..N).map(move |c| round(area_taps(sw, tw, t).map(|(s, w)| w * u64::from(row[s * N + c])).sum())) + }) + }) + .collect() + } + + /// Working a row at a time gives the same bytes as two whole passes, in both directions, one + /// direction alone, and with source rows shared by neighbouring targets. + #[test] + fn a_row_at_a_time_shrink_equals_two_whole_passes() { + let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1)), ((31, 17), (12, 7))]; + for (i, (source, target)) in sizes.into_iter().enumerate() { + let gray = noise((source.0 * source.1) as usize, i as u32); + assert_eq!(area_shrink::<1>(&gray, source, target), Some(two_pass_shrink::<1>(&gray, source, target)), "{source:?} → {target:?}"); + let rgba = noise((source.0 * source.1 * 4) as usize, i as u32 + 99); + assert_eq!(area_shrink::<4>(&rgba, source, target), Some(two_pass_shrink::<4>(&rgba, source, target)), "{source:?} → {target:?}"); + } + } + + /// Premultiplying a row at a time as it is averaged gives the same bytes as premultiplying the + /// whole expanded RGBA first and shrinking that, for gray and RGB colour. + #[test] + fn premultiplying_row_by_row_equals_premultiplying_the_whole_image() { + let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((31, 17), (12, 7))]; + for (i, (source, target)) in sizes.into_iter().enumerate() { + let (w, h) = source; + let pixels = (w * h) as usize; + let alpha = noise(pixels, i as u32 + 7); + let gray = noise(pixels, i as u32 + 11); + let rgb = noise(pixels * 3, i as u32 + 13); + let colors = [ + ImageData::Luma(LumaData { data: gray.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }), + ImageData::Rgb(RgbData { data: rgb.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }), + ]; + for color in &colors { + let mut whole: Vec = match color { + ImageData::Luma(_) => gray.iter().zip(&alpha).flat_map(|(g, a)| [*g, *g, *g, *a]).collect(), + ImageData::Rgb(_) => rgb.chunks_exact(3).zip(&alpha).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect(), + }; + premultiply_rgba(&mut whole); + assert_eq!( + area_shrink_premultiplied(color, &alpha, source, target), + area_shrink::<4>(&whole, source, target), + "{source:?} → {target:?}" + ); + } + let short = alpha.get(1..).unwrap_or_default(); + assert_eq!(area_shrink_premultiplied(&colors[0], short, source, target), None, "alpha shorter than the image"); + } + } + fn mask(data: &[u8], width: u32, height: u32) -> LumaData { LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) } } From 863af864a70f18bcf780a298f1e9923ee792c2ac Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:28:43 +0100 Subject: [PATCH 07/59] M1: respect generations in PDF object references and full saves (#1143) * M1: respect generations in COS object references and full saves Resolve stale references to null instead of replacement objects with the same number. Validate complete identities in writer readers, indirect stream lengths and catalog checks, and keep generation-aware length caches. Cover cache reuse, edit overlays, snapshots, incremental and packed/classic full saves, and stream lengths with synthetic regressions and independent hayro checks. Co-Authored-By: Codex * M0: clarify refreshed CI roadmap note Co-authored-by: OpenAI Codex * M1: repair contradictory xref generation references Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M1: cover repaired generations and freed-slot round trips Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M1: document generation repair and bounded diagnostics Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M1: record generation repair regression evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M1: document bounded COS generation repair Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M1: record generation repair parity evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * fix(cos): preserve repaired generations through preflight and saves Keep accepted xref/header aliases across repair-aware edits and snapshots, canonicalize full-save identities, and retain bounded generation diagnostics from older revisions. Add COS, writer/encryption, and real preflight regression coverage. * test(preflight): assert repaired aliases share saved identity Address the review nitpick by comparing reopened HeaderImage and RowImage references after damaged full saves, for classic/packed output and edited/restored snapshots. Focused preflight regression and rustfmt check pass. * M14: update certificate dependencies for RustSec advisories Bump rustls-webpki to0.103.16 and webpki-root-certs to1.0.10. Preserve all other locked packages and dependency lists, manifests, Git pins, and advisory policy. --------- Co-authored-by: MMexicann Co-authored-by: Codex Co-authored-by: FredApps --- README.md | 2 +- ROADMAP.md | 11 + crates/cos/README.md | 24 +- crates/cos/src/document/objects.rs | 160 ++++++- crates/cos/src/document/open.rs | 8 +- crates/cos/src/document/reader.rs | 31 +- crates/cos/src/document/state.rs | 8 +- crates/cos/src/document/tests.rs | 25 +- crates/cos/src/document/xref.rs | 34 +- crates/cos/src/writer.rs | 24 +- crates/cos/tests/it/generations.rs | 475 +++++++++++++++++++ crates/cos/tests/it/main.rs | 1 + crates/cos/tests/it/writer.rs | 31 +- crates/preflight/src/lib.rs | 10 +- crates/preflight/tests/generation_repairs.rs | 81 ++++ docs/roadmap.md | 10 +- parity/acrobat-features.toml | 16 +- 17 files changed, 861 insertions(+), 90 deletions(-) create mode 100644 crates/cos/tests/it/generations.rs create mode 100644 crates/preflight/tests/generation_repairs.rs diff --git a/README.md b/README.md index 3c1b67936a..f9a41cb3ca 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients ### Fearless -Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake. +Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake. diff --git a/ROADMAP.md b/ROADMAP.md index a1432f1120..1f753c500f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,11 @@ > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged) + +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged) + +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) @@ -339,6 +344,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. +- **2026-10-20 (M1, PR #1143 review):** Repair-aware dictionary/preflight edits and snapshot restoration preserve both accepted generations of a damaged live slot. Such edits use the existing full-save fallback; classic and packed rewrites coalesce aliases so page-tree parent links retain one object identity. Explicit generation replacement remains single-generation. Materialized xref diagnostics inspect retained primary and hybrid rows before folding, retaining the 256-object cap plus summary and discarding abandoned-index notes. Synthetic red/green controls cover preflight, undo restoration, equal values, explicit narrowing, encryption packing and four save variants. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. @@ -402,6 +409,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. +- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. +- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged. - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). @@ -780,6 +789,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | | 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | | 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | +| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | @@ -790,6 +800,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | +| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. | | 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | diff --git a/crates/cos/README.md b/crates/cos/README.md index 7b576f293f..398b161625 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -53,7 +53,18 @@ another. Edits never touch the original bytes. - **Every cross-reference form:** classic tables with their quirky subsections, cross-reference streams, hybrid-reference files (`/XRefStm`), and object streams. - **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj` - headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. + headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. A live + in-file reference may match either its xref generation or a same-number object header at + that indexed offset; a missing, free, or neither-matching slot still resolves to null. + Header probes inspect at most 128 bytes at the indexed and header-relative offsets, without + parsing object bodies. On open, disagreements in retained xref revisions are recorded once + per object, up to 256 objects plus an omitted-details summary. This diagnostic scan does + not expand compact xref ranges; the winning xref entry remains authoritative for free + slots. Compressed objects require generation zero. Explicit `set` overlays accept only the + supplied generation; `set_existing` and `update_dict` retain both accepted generations of a + damaged live slot, including through snapshots, and require full rewrite. Headers beyond + this bounded probe are not additionally recovered. Materialized primary and hybrid rows are + diagnosed before folding, so a newer row does not hide a retained disagreement. - **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields `CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. @@ -66,14 +77,17 @@ another. Edits never touch the original bytes. ## Writing -`write_incremental` appends only the edited objects, a cross-reference section **in the same -style as the file's last one**, and a trailer with `/Prev`. The original bytes are never modified, -which is what keeps a signature over an earlier revision valid. +`write_incremental` ordinarily appends only the edited objects, a cross-reference section **in +the same style as the file's last one**, and a trailer with `/Prev`, preserving the original +bytes and signatures over earlier revisions. It delegates to `write_full` when reconstruction, +encryption changes or `full_save_required()` demand a rewrite. Repair-aware edits to a live +xref/header generation disagreement use this fallback rather than append a contradictory row. `write_full` writes only what the trailer can reach, renumbered densely — the Save As and garbage-collecting path. By default (`SaveOptions::object_streams`) it packs non-stream objects into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a -classic table any reader accepts. +classic table any reader accepts. Both forms map accepted xref/header aliases to one output +object, retaining shared identity such as page-tree parent links; unrelated generations stay null. Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 62ef1e74cc..8d9917d749 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,6 +1,8 @@ +use std::collections::HashSet; + use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member}; -use super::xref::{Revision, XrefEntry}; +use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, }; @@ -129,8 +131,8 @@ impl Document { pub fn xref_entry_is_exact(&self, num: u32) -> bool { match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, - Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); + Some(XrefEntry::InFile { offset, generation }) => { + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id == ObjRef::new(num, generation)); // Offsets may count from a header that does not start the file. usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } @@ -151,9 +153,93 @@ impl Document { // ── object access ─────────────────────────────────────────────────────────────────────── - /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10). + /// Fetch an object by reference. Undefined references are `Null` (ISO 32000-2 §7.3.10). + /// A live file slot also accepts its actual header generation when a damaged xref row + /// disagrees; the disagreement is recorded in [`Self::repair_log`]. Free slots never do. pub fn get(&self, r: ObjRef) -> Arc { - self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) + self.try_get_reference(r).unwrap_or_else(|_| Arc::new(Object::Null)) + } + + pub(super) fn reference_matches(&self, r: ObjRef) -> bool { + match self.overlay.get(&r.num) { + Some(Slot::Set(generation, _)) => r.generation == *generation, + Some(Slot::RepairedSet(generation, alias, _)) => r.generation == *generation || r.generation == *alias, + Some(Slot::Freed(_)) => false, + None => match self.entries.lookup(r.num, &self.data) { + Some(XrefEntry::InFile { offset, generation }) => { + r.generation == generation || self.indexed_header_reference(r.num, offset) == Some(r) + } + Some(XrefEntry::InStream { .. }) => r.generation == 0, + None | Some(XrefEntry::Free { .. }) => false, + }, + } + } + + pub(super) fn try_get_reference(&self, r: ObjRef) -> Result, CosError> { + if !self.reference_matches(r) { + return Ok(Arc::new(Object::Null)); + } + self.try_get(r.num) + } + + /// A repair probe, not a whole-file search: only the indexed header (or the same offset + /// relative to a shifted PDF header) can establish this additional generation. A small + /// window bounds hostile whitespace/tokens; ordinary xref-matching reads are unchanged. + pub(super) fn indexed_header_reference(&self, num: u32, offset: u64) -> Option { + let at = |off: usize| { + let bytes = self.data.get(off..off.saturating_add(128).min(self.data.len()))?; + let mut lexer = Lexer::new(bytes, 0); + lexer.skip_ws(); + let number = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?; + lexer.skip_ws(); + let generation = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?; + (number == num && lexer.eat_keyword(b"obj")).then_some(ObjRef::new(number, generation)) + }; + let off = usize::try_from(offset).ok()?; + at(off).or_else(|| if self.header_offset == 0 { None } else { off.checked_add(self.header_offset).and_then(at) }) + } + + /// Add at most one bounded diagnostic per object. False stops diagnostic probing only; + /// parsers must still read the remaining xref rows needed to build their index. + pub(super) fn inspect_xref_generation_repair(&self, num: u32, entry: XrefEntry, recorded: &mut HashSet, log: &mut Vec) -> bool { + let XrefEntry::InFile { offset, generation } = entry else { return true }; + if recorded.contains(&num) { + return true; + } + let Some(header) = self.indexed_header_reference(num, offset) else { return true }; + if header.generation == generation { + return true; + } + if recorded.len() == 256 { + log.push("additional xref/header generation disagreements omitted after 256 objects".into()); + return false; + } + recorded.insert(num); + log.push(format!("object {num}: xref generation {generation} disagrees with object header generation {}", header.generation)); + true + } + + /// Diagnose once at open without expanding compact ranges or decoding an xref twice. + /// Materialized readers retain bounded notes while they read accepted sections, before + /// newer rows hide older ones. Discarded parse attempts/rebuilt indexes carry no notes. + pub(super) fn note_xref_generation_repairs(&self, log: &mut Vec) { + match &*self.entries { + XrefIndex::Materialized(_, notes) => log.extend(notes.iter().cloned()), + XrefIndex::Compact { revisions, .. } => { + let mut recorded = HashSet::new(); + for revision in revisions { + for section in std::iter::once(&revision.primary).chain(revision.hybrid.iter()) { + for num in section.numbers() { + if let Some(entry) = section.lookup(num, &self.data) + && !self.inspect_xref_generation_repair(num, entry, &mut recorded, log) + { + return; + } + } + } + } + } + } } /// Follow a reference if `o` is one; otherwise return `o` itself. An object can be nothing but @@ -388,7 +474,7 @@ impl Document { pub fn try_get(&self, num: u32) -> Result, CosError> { match self.overlay.get(&num) { - Some(Slot::Set(_, o)) => return Ok(o.clone()), + Some(Slot::Set(_, o) | Slot::RepairedSet(_, _, o)) => return Ok(o.clone()), Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)), None => {} } @@ -519,11 +605,11 @@ impl Document { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { let resolve = |r: ObjRef| { - if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { + if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { return known; } - let length = self.try_get(r.num).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); + let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); + LENGTHS.with(|m| m.borrow_mut().insert(r, length)); length }; let off = offset as usize; @@ -640,6 +726,38 @@ impl Document { } } + /// Replace an accepted existing reference without losing a damaged source slot's aliases. + /// Unlike [`Self::set`], this preserves both xref/header generations when they disagree. + /// Such an edit forces a full rewrite so the output has consistent references, rather than + /// appending a new contradictory xref row. Missing, free and stale references are errors. + pub fn set_existing(&mut self, r: ObjRef, obj: impl Into) -> Result<(), CosError> { + if !self.reference_matches(r) { + return Err(CosError::MissingObject(r.num)); + } + let alias = self.alternate_generation(r); + if let Some(alias) = alias { + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::RepairedSet(r.generation, alias, Arc::new(obj))); + } else { + self.set(r, obj); + } + Ok(()) + } + + /// Other accepted generation for a known-live reference to a damaged slot. + pub(super) fn alternate_generation(&self, r: ObjRef) -> Option { + match self.overlay.get(&r.num) { + Some(Slot::RepairedSet(generation, alias, _)) => Some(if r.generation == *generation { *alias } else { *generation }), + Some(_) => None, + None => match self.entries.lookup(r.num, &self.data) { + Some(XrefEntry::InFile { offset, generation }) => self.indexed_header_reference(r.num, offset).and_then(|header| { + (header.generation != generation).then_some(if r.generation == generation { header.generation } else { generation }) + }), + _ => None, + }, + } + } + /// Add a new indirect object and return its reference. pub fn add(&mut self, obj: impl Into) -> ObjRef { let r = ObjRef::new(self.allocate_num(), 0); @@ -699,7 +817,8 @@ impl Document { let generation = current.generation(num); let same = saved_nums.binary_search(&num).is_ok() && self.generation(num) == generation - && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?; + && self.alternate_generation(ObjRef::new(num, generation)) == current.alternate_generation(ObjRef::new(num, generation)) + && saved_reader.try_get(ObjRef::new(num, generation))? == current_reader.try_get(ObjRef::new(num, generation))?; if !same { changed.push(num); } @@ -728,8 +847,14 @@ impl Document { let mut current_reader = current.object_reader(); for num in changed { let generation = current.generation(num); - let object = current_reader.try_get(ObjRef::new(num, 0))?; - self.set(ObjRef::new(num, generation), (*object).clone()); + let object = current_reader.try_get(ObjRef::new(num, generation))?; + if let Some(alias) = current.alternate_generation(ObjRef::new(num, generation)) { + let object = self.adopt((*object).clone()); + self.overlay.insert(num, Slot::RepairedSet(generation, alias, Arc::new(object))); + self.next_num = self.next_num.max(num.saturating_add(1)); + } else { + self.set(ObjRef::new(num, generation), (*object).clone()); + } } *self.trailer_mut() = current.trailer().clone(); Ok(()) @@ -740,14 +865,13 @@ impl Document { let mut obj = (*self.get(r)).clone(); let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?; f(d); - self.set(r, obj); - Ok(()) + self.set_existing(r, obj) } /// Current generation of an object number. pub fn generation(&self, num: u32) -> u16 { match self.overlay.get(&num) { - Some(Slot::Set(g, _)) => *g, + Some(Slot::Set(g, _) | Slot::RepairedSet(g, _, _)) => *g, Some(Slot::Freed(g)) => *g, None => match self.entries.lookup(num, &self.data) { Some(XrefEntry::InFile { generation, .. }) => generation, @@ -759,7 +883,7 @@ impl Document { pub(crate) fn overlay_entries(&self) -> impl Iterator>)> + '_ { self.overlay.iter().map(|(n, s)| match s { - Slot::Set(g, o) => (*n, *g, Some(o.clone())), + Slot::Set(g, o) | Slot::RepairedSet(g, _, o) => (*n, *g, Some(o.clone())), Slot::Freed(g) => (*n, *g, None), }) } @@ -782,7 +906,7 @@ impl Document { if source.peek().is_some_and(|n| *n == num) { source.next(); } - if matches!(slot, Slot::Set(..)) { + if matches!(slot, Slot::Set(..) | Slot::RepairedSet(..)) { numbers.push(num); } } @@ -800,7 +924,7 @@ impl Document { pub(super) fn root_is_catalog(&self) -> bool { let Some(r) = self.root() else { return false }; - matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) + matches!(self.try_get_reference(r).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) } } diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 70bbcc313e..7a4337c8da 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::Credentials; +use super::state::{Credentials, Slot}; use super::xref::XrefIndex; use super::{Arc, BTreeMap, CosError, Dict, Document, HashSet, ObjRef, Object}; @@ -156,6 +156,9 @@ impl Document { } doc.reconstruct(&mut log)?; } + // Header generations are plaintext, including in encrypted files. Inspect only the + // bounded headers, not object bodies, and retain the compact xref representation. + doc.note_xref_generation_repairs(&mut log); doc.repair_log = Arc::new(log); // Reconstruction may have found a different trailer: (re)authenticate against it. if (!ok || !authenticated) @@ -288,8 +291,9 @@ impl Document { } /// The next save must rewrite the whole file (see [`Document::require_full_save`]). + /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { - self.full_save + self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } /// Security was set or removed since the document was opened (the next save applies it). diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index d21bf9403f..e1691ace90 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -17,8 +17,8 @@ impl Document { return Ok((index, trailer, revisions, Vec::new())); } } - let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; - Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) + let (entries, trailer, revisions, oddities, generation_repairs) = self.read_xref_chain_materialized(log)?; + Ok((XrefIndex::Materialized(Arc::new(entries), Arc::new(generation_repairs)), trailer, revisions, oddities)) } fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { @@ -323,7 +323,7 @@ impl Document { pub(super) fn read_xref_chain_materialized( &self, log: &mut Vec, - ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; @@ -343,6 +343,9 @@ impl Document { // At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again. let mut hybrids: HashMap>> = HashMap::new(); let (mut hybrid_rows, mut noted) = (0usize, HashSet::new()); + let mut generation_repairs = Vec::new(); + let mut recorded = HashSet::new(); + let mut diagnose = true; let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -371,6 +374,12 @@ impl Document { } }; revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + for (&num, &entry) in § { + if !diagnose { + break; + } + diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs); + } // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); @@ -390,6 +399,12 @@ impl Document { let mut stm = BTreeMap::new(); let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { + for (&num, &entry) in &stm { + if !diagnose { + break; + } + diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs); + } for (&num, &e) in &stm { match sect.get(&num) { None | Some(XrefEntry::Free { .. }) => { @@ -427,7 +442,7 @@ impl Document { for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { trailer.remove(k); } - Ok((entries, trailer, revisions, oddities)) + Ok((entries, trailer, revisions, oddities, generation_repairs)) } /// Read one section at `off`, adding entries not already known (newer sections win). `odd` @@ -606,7 +621,7 @@ impl Document { } if found > 0 { log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers")); - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled), Arc::default())); } self.located = Arc::new(located); } @@ -631,7 +646,7 @@ impl Document { if entries.is_empty() { return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); } - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()), Arc::default())); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Objects inside object streams. for num in objstms { @@ -648,7 +663,7 @@ impl Document { } } let object_count = entries.len(); - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries), Arc::default())); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a // usable /Root (last in the file, whichever kind), else the catalog itself. One whose @@ -658,7 +673,7 @@ impl Document { // A file whose only trailers are cross-reference streams (and whose `startxref` is gone // or points nowhere) keeps its /Info, /ID and /Encrypt this way. let rank = |at: usize, d: &Dict| { - let root = self.try_get(d.reference(b"Root")?.num).ok()?; + let root = self.try_get_reference(d.reference(b"Root")?).ok()?; let Object::Dict(c) = &*root else { return None }; Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at)) }; diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 96dde01c0f..f68a483290 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -7,7 +7,7 @@ thread_local! { /// Indirect `/Length` values resolved during the current outermost load: a damaged file /// retries a parse several ways, and each retry would resolve the same lengths again /// (exponential in the chain of streams whose lengths point at further streams). - pub(super) static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); + pub(super) static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); } /// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream @@ -65,6 +65,9 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option, len: us #[derive(Clone, Debug, PartialEq)] pub(super) enum Slot { Set(u16, Arc), + /// Both generations accepted from a damaged live source slot survive a repair-aware edit. + /// A full rewrite is required: a new xref slot cannot encode two generations. + RepairedSet(u16, u16, Arc), Freed(u16), } @@ -103,6 +106,7 @@ impl ReadState { a == b && match (x, y) { (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::RepairedSet(g, a, o), Slot::RepairedSet(h, b, p)) => g == h && a == b && Arc::ptr_eq(o, p), (Slot::Freed(g), Slot::Freed(h)) => g == h, _ => false, } @@ -133,7 +137,7 @@ impl ObjectReader { } pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result, CosError> { - let object = self.document.try_get(reference.num); + let object = self.document.try_get_reference(reference); // A missed eviction here only costs a re-parse, so never fail the read on a poisoned // lock. The editing-cache evictors propagate instead; they guard user edits. if let Ok(mut streams) = self.document.objstms.lock() { diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f12437baaf..ad8f86fdea 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -599,7 +599,7 @@ pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { fn assert_compact_matches_materialized(doc: &Document) { assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); - let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let (entries, _, revisions, _, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); assert_eq!(revisions.as_slice(), doc.revisions()); let max = entries.keys().next_back().copied().unwrap_or(0); assert_eq!(doc.entries.max_num(), max); @@ -687,7 +687,7 @@ fn malformed_size_uses_the_materialized_compatibility_path() { let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap(); bytes[at..at + marker.len()].copy_from_slice(b"/Size 2"); let doc = Document::open(Arc::new(bytes)).unwrap(); - assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_))); + assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(..))); assert_eq!(doc.next_num, 4); assert_eq!(doc.object_numbers(), vec![1, 2, 3]); } @@ -1181,15 +1181,18 @@ fn reads_classic_revision_and_edits_are_copy_on_write() { fn object_numbers_merge_source_and_overlay_in_order() { let mut doc = Document::new_empty(); doc.overlay.clear(); - doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([ - (0, XrefEntry::Free { next_generation: 65535 }), - (2, XrefEntry::InFile { offset: 10, generation: 7 }), - (4, XrefEntry::Free { next_generation: 9 }), - (6, XrefEntry::InStream { stream: 80, index: 1 }), - (8, XrefEntry::InFile { offset: 20, generation: 5 }), - (10, XrefEntry::InFile { offset: 30, generation: 3 }), - (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), - ])))); + doc.entries = Arc::new(XrefIndex::Materialized( + Arc::new(BTreeMap::from([ + (0, XrefEntry::Free { next_generation: 65535 }), + (2, XrefEntry::InFile { offset: 10, generation: 7 }), + (4, XrefEntry::Free { next_generation: 9 }), + (6, XrefEntry::InStream { stream: 80, index: 1 }), + (8, XrefEntry::InFile { offset: 20, generation: 5 }), + (10, XrefEntry::InFile { offset: 30, generation: 3 }), + (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), + ])), + Arc::default(), + )); doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 02390ff7da..771df28b80 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -41,7 +41,7 @@ pub(super) struct XrefRevisionSection { #[derive(Clone, Debug)] pub(super) enum XrefIndex { Compact { revisions: Vec, max_num: u32, has_entries: bool }, - Materialized(Arc>), + Materialized(Arc>, Arc>), } pub(super) const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; @@ -149,8 +149,8 @@ impl XrefSection { } } - pub(super) fn extend_numbers(&self, numbers: &mut Vec) { - for range in &self.ranges { + pub(super) fn numbers(&self) -> impl Iterator + '_ { + self.ranges.iter().flat_map(|range| { let available = match &self.data { XrefSectionData::Table => range.count as usize, XrefSectionData::Stream { bytes, widths } => { @@ -158,14 +158,12 @@ impl XrefSection { bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) } }; - for i in 0..available.min(range.count as usize) { - if let Ok(i) = u32::try_from(i) - && let Some(num) = range.start.checked_add(i) - { - numbers.push(num); - } - } - } + (0..available.min(range.count as usize)).filter_map(move |i| u32::try_from(i).ok().and_then(|i| range.start.checked_add(i))) + }) + } + + pub(super) fn extend_numbers(&self, numbers: &mut Vec) { + numbers.extend(self.numbers()); } } @@ -189,20 +187,20 @@ impl XrefRevisionSection { impl XrefIndex { pub(super) fn empty() -> Self { - Self::Materialized(Arc::new(BTreeMap::new())) + Self::Materialized(Arc::new(BTreeMap::new()), Arc::default()) } pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option { match self { Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), - Self::Materialized(entries) => entries.get(&num).copied(), + Self::Materialized(entries, _) => entries.get(&num).copied(), } } pub(super) fn is_empty(&self) -> bool { match self { Self::Compact { has_entries, .. } => !has_entries, - Self::Materialized(entries) => entries.is_empty(), + Self::Materialized(entries, _) => entries.is_empty(), } } @@ -211,14 +209,14 @@ impl XrefIndex { pub(super) fn remove_exhausted(&mut self) -> bool { match self { Self::Compact { .. } => false, - Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), + Self::Materialized(entries, _) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), } } /// Every entry, newest section first, as the materialized reader keeps them. pub(super) fn to_map(&self, file: &[u8]) -> BTreeMap { match self { - Self::Materialized(entries) => entries.as_ref().clone(), + Self::Materialized(entries, _) => entries.as_ref().clone(), Self::Compact { revisions, .. } => { let mut numbers = Vec::new(); for revision in revisions { @@ -232,13 +230,13 @@ impl XrefIndex { pub(super) fn max_num(&self) -> u32 { match self { Self::Compact { max_num, .. } => *max_num, - Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), + Self::Materialized(entries, _) => entries.keys().next_back().copied().unwrap_or(0), } } pub(super) fn object_numbers(&self, file: &[u8]) -> Vec { match self { - Self::Materialized(entries) => { + Self::Materialized(entries, _) => { entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() } Self::Compact { revisions, .. } => { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index dcfcb97997..6c4c71580a 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -307,8 +307,17 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if matches!(*o, Object::Null) { continue; // dangling reference: written as null by renumbering below } - map.insert(r, output_number(order.len())?); - order.push((r, matches!(*o, Object::Stream(_)))); + // Accepted xref/header aliases denote one slot, including page-tree cycles. + // Check for null first: an unrelated or freed generation must remain dangling. + let canonical = ObjRef::new(r.num, reader.document.generation(r.num)); + if let Some(&num) = map.get(&canonical) { + map.insert(r, num); + continue; + } + let num = output_number(order.len())?; + map.insert(r, num); + map.insert(canonical, num); + order.push((canonical, matches!(*o, Object::Stream(_)))); visit_refs(&o, &mut queue); } let version = reader.document.version().chars().take(3).collect::(); @@ -319,13 +328,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro out.extend_from_slice(&[b'%', 0xE2, 0xE3, 0xCF, 0xD3, b'\n']); // The /Encrypt dictionary and everything it references (e.g. indirect /CF crypt-filter // dictionaries) are needed before anything can be decrypted: never inside object streams. - let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); + let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); let mut pending: Vec = Vec::new(); if let Some(encrypt) = trailer_in.get(b"Encrypt") { collect_refs(encrypt, &mut |r| pending.push(r)); } while let Some(r) = pending.pop() { - if encryption_objects.insert(r) { + if let Some(&num) = map.get(&r) + && encryption_objects.insert(num) + { collect_refs(&reader.get(r), &mut |x| pending.push(x)); } } @@ -333,14 +344,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro // damaged and could not be repaired", before it even asks for the password), while other // readers accept it. qpdf keeps the catalog out of object streams too. Verified with // Acrobat DC on macOS (#774). - let root_outside = if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root") } else { None }; + let root_outside = + if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root").and_then(|r| map.get(&r)).copied() } else { None }; let mut rows: BTreeMap = BTreeMap::new(); // Keep only references, not renamed copies of the entire object graph. Standalone // objects still precede object streams, preserving the established output order. let mut packed: Vec<(u32, ObjRef)> = Vec::new(); for (i, (r, is_stream)) in order.iter().enumerate() { let num = output_number(i)?; - let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r); + let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(&num) && root_outside != Some(num); if in_stream { packed.push((num, *r)); } else { diff --git a/crates/cos/tests/it/generations.rs b/crates/cos/tests/it/generations.rs new file mode 100644 index 0000000000..3d56628c93 --- /dev/null +++ b/crates/cos/tests/it/generations.rs @@ -0,0 +1,475 @@ +//! An indirect reference identifies an object number AND its generation (ISO 32000-2 §7.3.10). + +use std::sync::Arc; + +use hayro_syntax::object::{Dict as HayroDict, ObjectIdentifier}; +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, XrefEntry, write_full, write_incremental}; + +/// Object 3 was reused: only 3 1 R refers to the current value, never 3 0 R. +fn reused_object() -> Arc> { + object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 1, "42"), + ]) +} + +fn object_file(objects: &[(u32, u16, &str)]) -> Arc> { + object_file_with_entry(objects, None) +} + +fn object_file_with_entry(objects: &[(u32, u16, &str)], changed: Option<(u32, u16, bool)>) -> Arc> { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, generation, value) in objects { + offsets.push((*num, bytes.len(), *generation)); + bytes.extend_from_slice(format!("{num} {generation} obj\n{value}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + let size = objects.len() + 1; + bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for (num, offset, generation) in offsets { + let (generation, free) = changed.filter(|(n, _, _)| *n == num).map_or((generation, false), |(_, g, free)| (g, free)); + let (offset, kind) = if free { (0, 'f') } else { (offset, 'n') }; + bytes.extend_from_slice(format!("{offset:010} {generation:05} {kind} \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Arc::new(bytes) +} + +fn contradictory_entry(free: bool) -> Arc> { + object_file_with_entry( + &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")], + Some((3, if free { 2 } else { 0 }, free)), + ) +} + +#[test] +fn contradictory_xref_generation_recovers_the_matching_header_and_records_repair() { + for warm in [false, true] { + let doc = Document::open(contradictory_entry(false)).unwrap(); + if warm { + assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42)); + } + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Int(42)); + // Preserve the existing xref-matching behavior, but never accept an unrelated generation. + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); + assert!(!doc.xref_entry_is_exact(3), "a contradictory generation is not an exact xref entry"); + assert!( + doc.repair_log() + .iter() + .any(|line| { line.contains("object 3") && line.contains("xref generation 0") && line.contains("header generation 1") }), + "{:?}", + doc.repair_log() + ); + let repairs = doc.repair_log().to_vec(); + for _ in 0..3 { + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + } + assert_eq!(doc.repair_log(), repairs); + + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 1)); + for generation in [0, 1, 2] { + assert_eq!(*edited.get(ObjRef::new(3, generation)), Object::Null); + } + edited.set(ObjRef::new(3, 2), Object::Int(99)); + assert_eq!(*edited.get(ObjRef::new(3, 1)), Object::Null); + assert_eq!(*edited.get(ObjRef::new(3, 2)), Object::Int(99)); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + } +} + +/// A damaged row may name generation zero while the retained object and catalog use one. +fn contradictory_dictionary() -> Arc> { + object_file_with_entry( + &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "<< /Value 42 >>")], + Some((3, 0, false)), + ) +} + +fn assert_repaired_dictionary(bytes: Vec, value: i32) { + let bytes = Arc::new(bytes); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::>(b"Current").unwrap().get::(b"Value"), Some(value)); + let back = Document::open(bytes).unwrap(); + let catalog = back.get(back.root().unwrap()); + let current = back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()); + assert_eq!(current.as_dict().unwrap().int(b"Value"), Some(i64::from(value))); +} + +#[test] +fn repaired_header_references_survive_full_saves_and_dictionary_edits() { + let original = contradictory_dictionary(); + let doc = Document::open(original.clone()).unwrap(); + for object_streams in [false, true] { + assert_repaired_dictionary(write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 42); + } + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(3, 1), |dict| dict.set(b"Value".to_vec(), Object::Int(77))).unwrap(); + assert_eq!(edited.generation(3), 1); + let incremental = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(edited.full_save_required(), "repair-aware dictionary edits normalize on full rewrite"); + assert_repaired_dictionary(incremental, 77); + for object_streams in [false, true] { + assert_repaired_dictionary(write_full(&edited, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 77); + } + let mut restored = doc.clone(); + restored.overlay_changes_from(&edited).unwrap(); + assert_repaired_dictionary(write_incremental(&restored, &SaveOptions::default()).unwrap(), 77); +} + +#[test] +fn free_xref_slots_never_revive_retained_object_headers() { + for warm in [false, true] { + let doc = Document::open(contradictory_entry(true)).unwrap(); + assert!(matches!(doc.xref_entry(3), Some(XrefEntry::Free { next_generation: 2 }))); + if warm { + assert_eq!(*doc.try_get(3).unwrap(), Object::Null); + } + for generation in [0, 1, 2] { + assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Null); + } + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Null); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + } + // Control: the retained bytes really are a readable object when its slot is live. + let live = Document::open(contradictory_entry(false)).unwrap(); + assert_eq!(*live.try_get(3).unwrap(), Object::Int(42)); +} + +#[test] +fn incremental_generation_identity_is_visible_to_an_independent_reader() { + let original = reused_object(); + let mut doc = Document::open(original.clone()).unwrap(); + doc.set(ObjRef::new(3, 1), Object::Int(77)); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.starts_with(&original)); + let oracle = hayro_syntax::Pdf::new(Arc::new(bytes)).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(77)); + assert_eq!(catalog.get::(b"Stale"), None); +} + +#[test] +fn generation_repair_diagnostics_are_bounded_without_disabling_recovery() { + let mut objects = vec![(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>")]; + objects.extend((3..303).map(|num| (num, 1, "42"))); + let bytes = object_file(&objects); + let bytes = String::from_utf8(bytes.as_ref().clone()).unwrap().replace("00001 n \n", "00000 n \n"); + for materialized in [false, true] { + let bytes = if materialized { bytes.replace("/Size 303", "/Size 302") } else { bytes.clone() }; + let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert_eq!(doc.repair_log().len(), 257); + assert!(doc.repair_log().last().unwrap().contains("omitted after 256 objects")); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(302, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(302, 2)), Object::Null); + assert_eq!(doc.repair_log().len(), 257); + } +} + +#[test] +fn references_require_the_current_generation_even_with_a_warm_cache() { + let bytes = reused_object(); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 0)), None); + assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 1)), Some(42)); + + let doc = Document::open(bytes).unwrap(); + assert!(doc.repair_log().is_empty()); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 0))), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); +} + +#[test] +fn overlay_generation_changes_do_not_retarget_old_references_or_clones() { + let mut doc = Document::open(reused_object()).unwrap(); + let snapshot = doc.clone(); + let old = ObjRef::new(3, 1); + let new = ObjRef::new(3, 2); + doc.free(old); + assert_eq!(*doc.get(old), Object::Null); + doc.set(new, Object::Int(99)); + assert_eq!(*doc.get(old), Object::Null); + assert_eq!(*doc.get(new), Object::Int(99)); + assert_eq!(*snapshot.get(old), Object::Int(42)); + assert_eq!(*snapshot.get(new), Object::Null); + + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(*back.get(old), Object::Null); + assert_eq!(*back.get(new), Object::Int(99)); +} + +#[test] +fn restoring_snapshot_preserves_changes_to_nonzero_generation_objects() { + for generation in [1, 2] { + let mut saved = Document::open(reused_object()).unwrap(); + let mut current = saved.clone(); + let reference = ObjRef::new(3, generation); + current.set(reference, Object::Int(99)); + current.update_dict(current.root().unwrap(), |catalog| catalog.set(b"Current".to_vec(), Object::Ref(reference))).unwrap(); + + saved.overlay_changes_from(¤t).unwrap(); + assert_eq!(*saved.get(reference), Object::Int(99), "generation {generation}"); + let back = Document::open(Arc::new(write_incremental(&saved, &SaveOptions::default()).unwrap())).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(*back.get(reference), Object::Int(99), "generation {generation}"); + let catalog = back.get(back.root().unwrap()); + assert_eq!(*back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()), Object::Int(99)); + } +} + +#[test] +fn full_save_does_not_turn_stale_references_into_live_ones() { + let doc = Document::open(reused_object()).unwrap(); + for object_streams in [false, true] { + let saved = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(); + let saved = Arc::new(saved); + let oracle = hayro_syntax::Pdf::new(saved.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(42)); + assert_eq!(catalog.get::(b"Stale"), None); + let back = Document::open(saved).unwrap(); + assert!(back.repair_log().is_empty()); + let catalog = back.get(back.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + assert_eq!(catalog.get(b"Stale"), Some(&Object::Null)); + assert_eq!(*back.resolve(catalog.get(b"Current").unwrap()), Object::Int(42)); + } +} + +#[test] +fn compressed_objects_only_accept_generation_zero() { + let doc = Document::open(reused_object()).unwrap(); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let current = catalog.as_dict().unwrap().reference(b"Current").unwrap(); + assert_eq!(current.generation, 0); + assert!(matches!(back.xref_entry(current.num), Some(XrefEntry::InStream { .. }))); + assert_eq!(*back.get(current), Object::Int(42)); + assert_eq!(*back.get(ObjRef::new(current.num, 1)), Object::Null); +} + +#[test] +fn indirect_stream_lengths_require_the_referenced_generation() { + for generation in [0, 1] { + // The embedded endstream is data when /Length resolves, but the existing missing- + // length recovery stops there when the reference is stale. + let stream = format!("<< /Length 4 {generation} R >>\nstream\nA\nendstream\nB\nendstream"); + let doc = Document::open(object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 0, &stream), + (4, 1, "13"), + ])) + .unwrap(); + let object = doc.get(ObjRef::new(3, 0)); + let Object::Stream(stream) = &*object else { panic!("expected stream") }; + assert_eq!(&stream.raw[..], if generation == 1 { b"A\nendstream\nB".as_slice() } else { b"A".as_slice() }); + } +} + +/// A newer valid row must not hide damage in a retained older revision. +fn shadowed_generation_disagreement(materialized: bool, shifted: bool) -> Arc> { + let original = contradictory_dictionary(); + let text = std::str::from_utf8(&original).unwrap(); + let previous: usize = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse().unwrap(); + let mut bytes = (*original).clone(); + let offset = bytes.len(); + bytes.extend_from_slice(b"3 1 obj\n<< /Value 77 >>\nendobj\n"); + let xref = bytes.len(); + let size = if materialized { 3 } else { 4 }; + bytes.extend_from_slice( + format!("xref\n3 1\n{offset:010} 00001 n \ntrailer\n<< /Size {size} /Root 1 0 R /Prev {previous} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + if shifted { + bytes.splice(..0, b"junk before the header\n".iter().copied()); + } + Arc::new(bytes) +} + +#[test] +fn retained_generation_disagreements_are_reported_in_materialized_mode_too() { + for materialized in [false, true] { + for shifted in [false, true] { + let doc = Document::open(shadowed_generation_disagreement(materialized, shifted)).unwrap(); + assert_eq!(doc.revisions().len(), 2, "the xref chain is retained"); + assert!(!doc.reconstructed()); + assert_eq!(doc.get(ObjRef::new(3, 1)).as_dict().unwrap().int(b"Value"), Some(77)); + assert!( + doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1")), + "materialized={materialized}, shifted={shifted}: {:?}", + doc.repair_log() + ); + } + } +} + +#[test] +fn repair_aware_edits_keep_both_aliases_and_full_save_normalizes_them() { + let mut doc = Document::open(contradictory_entry(false)).unwrap(); + let snapshot = doc.clone(); + doc.set_existing(ObjRef::new(3, 0), Object::Int(77)).unwrap(); + for generation in [0, 1] { + assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Int(77)); + assert_eq!(*snapshot.get(ObjRef::new(3, generation)), Object::Int(42)); + } + let state = doc.read_state(); + assert!(doc.set_existing(ObjRef::new(3, 2), Object::Int(99)).is_err()); + assert!(state.matches(&doc)); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); + let mut restored = snapshot.clone(); + restored.overlay_changes_from(&doc).unwrap(); + for changed in [&doc, &restored] { + assert!(changed.full_save_required()); + for bytes in [ + write_incremental(changed, &SaveOptions::default()).unwrap(), + write_full(changed, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(), + ] { + let bytes = Arc::new(bytes); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(77)); + assert_eq!(catalog.get::(b"Stale"), Some(77), "both source aliases stay live"); + let back = Document::open(bytes).unwrap(); + assert!(back.repair_log().is_empty(), "normalized output has no generation repairs"); + } + } + // Explicit replacement keeps its narrower, caller-supplied generation contract. + doc.set(ObjRef::new(3, 2), Object::Int(99)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Int(99)); +} + +#[test] +fn hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them() { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, generation, body) in [(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{num} {generation} obj\n{body}\nendobj\n").as_bytes()); + } + let hybrid = bytes.len(); + offsets.push(hybrid); + bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [3 1] /Length 7 >>\nstream\n"); + bytes.push(1); + bytes.extend_from_slice(&(offsets[2] as u32).to_be_bytes()); + bytes.extend_from_slice(&0u16.to_be_bytes()); + bytes.extend_from_slice(b"\nendstream\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for (i, offset) in offsets.into_iter().enumerate() { + let generation = u16::from(i == 2); + bytes.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R /XRefStm {hybrid} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(!doc.reconstructed()); + assert!(doc.xref_oddities().iter().any(|(_, num)| *num == 3), "the conflicting hybrid row is set aside"); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null, "the table still determines accepted references"); + assert!(doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1"))); +} + +#[test] +fn discarded_xref_repair_notes_do_not_survive_object_header_reconstruction() { + let bytes = + String::from_utf8((*contradictory_dictionary()).clone()).unwrap().replace("/Root 1 0 R", "/Root 99 0 R").replace("/Size 4", "/Size 3"); + let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert!(doc.reconstructed()); + assert!(doc.repair_log().iter().all(|line| !line.contains("xref generation")), "{:?}", doc.repair_log()); +} + +#[test] +fn restoring_equal_values_still_restores_the_accepted_generation_aliases() { + for edited in [false, true] { + for value in [42, 77] { + let mut current = Document::open(contradictory_entry(false)).unwrap(); + if edited { + current.set_existing(ObjRef::new(3, 0), Object::Int(42)).unwrap(); + } + let mut saved = Document::open(object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 0, &value.to_string()), + ])) + .unwrap(); + assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null); + saved.overlay_changes_from(¤t).unwrap(); + for generation in [0, 1] { + assert_eq!(*saved.get(ObjRef::new(3, generation)), Object::Int(42), "edited={edited}, value={value}"); + } + assert!(saved.full_save_required()); + } + } +} + +#[test] +fn repaired_page_tree_aliases_keep_one_identity_in_full_saves() { + let source = object_file_with_entry( + &[ + (1, 0, "<< /Type /Catalog /Pages 2 1 R /Stale 2 2 R >>"), + (2, 1, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + (3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>"), + ], + Some((2, 0, false)), + ); + for edited in [false, true] { + let mut doc = Document::open(source.clone()).unwrap(); + if edited { + doc.update_dict(ObjRef::new(2, 0), |dict| dict.set(b"Edited".to_vec(), Object::Bool(true))).unwrap(); + } + let mut restored = Document::open(source.clone()).unwrap(); + restored.overlay_changes_from(&doc).unwrap(); + for current in [&doc, &restored] { + for object_streams in [false, true] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let mut saves = vec![write_full(current, &opts).unwrap()]; + if edited { + saves.push(write_incremental(current, &opts).unwrap()); + } + for bytes in saves { + let back = Document::open(Arc::new(bytes)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let pages_ref = catalog.reference(b"Pages").unwrap(); + let pages = back.get(pages_ref); + let child_ref = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].as_ref().unwrap(); + let child = back.get(child_ref); + assert_eq!(child.as_dict().unwrap().reference(b"Parent"), Some(pages_ref), "edited={edited}, packed={object_streams}"); + assert!(matches!(catalog.get(b"Stale"), Some(Object::Null)), "unrelated generations stay null"); + assert!(back.repair_log().is_empty()); + } + } + } + } +} + +#[test] +fn restoring_explicit_sets_removes_old_generation_aliases() { + for value in [42, 77] { + let mut current = Document::open(contradictory_entry(false)).unwrap(); + current.set(ObjRef::new(3, 0), Object::Int(42)); + for repaired_overlay in [false, true] { + let mut saved = Document::open(contradictory_entry(false)).unwrap(); + if repaired_overlay { + saved.set_existing(ObjRef::new(3, 0), Object::Int(value)).unwrap(); + } + saved.overlay_changes_from(¤t).unwrap(); + assert_eq!(*saved.get(ObjRef::new(3, 0)), Object::Int(42)); + assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null, "value={value}, overlay={repaired_overlay}"); + } + } +} diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs index 52cf05d6c2..1bd7ee1fd6 100644 --- a/crates/cos/tests/it/main.rs +++ b/crates/cos/tests/it/main.rs @@ -3,6 +3,7 @@ mod bounded_writer; mod corpus; mod encryption; +mod generations; mod proptests; mod shared_streams; mod writer; diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index eb03523189..2716b9242e 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -358,11 +358,32 @@ fn objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams() { let cf = doc.get(enc_ref).as_dict().unwrap().get(b"CF").cloned().unwrap(); let cf_ref = doc.add(cf); doc.update_dict(enc_ref, |d| d.set(b"CF".to_vec(), Object::Ref(cf_ref))).unwrap(); - let packed = write_full(&doc, &SaveOptions::default()).unwrap(); - let back = Document::open(Arc::new(packed.clone())).expect("reopens"); - assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); - assert_eq!(title(&back), b"Caf\xe9 (draft)"); - assert_eq!(hayro_pages(&packed), 4); + // Visit this alias from Root before the encryption walk reaches /CF's other alias. + doc.update_dict(doc.root().unwrap(), |d| d.set(b"CFProbe".to_vec(), Object::Ref(cf_ref))).unwrap(); + let classic = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(classic.clone())).unwrap(); + let cf_num = saved.get(saved.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap().num; + for damaged in [false, true] { + let mut bytes = classic.clone(); + if damaged { + // Equal-length edits preserve the xref offsets and its generation-zero row. + for (from, to) in [(format!("\n{cf_num} 0 obj"), format!("\n{cf_num} 1 obj")), (format!("/CF {cf_num} 0 R"), format!("/CF {cf_num} 1 R"))] + { + let start = bytes.windows(from.len()).position(|w| w == from.as_bytes()).unwrap(); + bytes[start..start + from.len()].copy_from_slice(to.as_bytes()); + } + } + let doc = Document::open(Arc::new(bytes)).unwrap(); + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(packed.clone())).expect("reopens"); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(title(&back), b"Caf\xe9 (draft)"); + assert_eq!(hayro_pages(&packed), 4); + let probe = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap(); + let encrypt = back.get(back.trailer().reference(b"Encrypt").unwrap()); + assert_eq!(encrypt.as_dict().unwrap().reference(b"CF"), Some(probe)); + assert!(matches!(back.xref_entry(probe.num), Some(pdfcraft_cos::XrefEntry::InFile { .. })), "crypt filters must stay outside object streams"); + } } #[test] diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..1fb7272b33 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -894,7 +894,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Result Result Result Arc> { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let bodies = [ + "<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Resources << /XObject << /Im 5 1 R >> >> >>", + "null", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /Interpolate true /Length 1 >>\nstream\nX\nendstream", + ]; + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(bytes.len()); + let generation = u16::from(i == 4); + bytes.extend_from_slice(format!("{} {generation} obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for (i, offset) in offsets.into_iter().enumerate() { + let generation = u16::from(i == 4 && !damaged); + bytes.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Arc::new(bytes) +} + +fn assert_images(doc: &Document, converted: bool, dual: bool) { + let root = doc.get(doc.root().unwrap()); + let catalog = root.as_dict().unwrap(); + for key in [b"HeaderImage".as_slice(), b"RowImage".as_slice()] { + let value = doc.resolve(catalog.get(key).unwrap()); + if key == b"RowImage" && !dual { + assert_eq!(*value, Object::Null); + } else { + let Object::Stream(image) = &*value else { panic!("{key:?} became {value:?}") }; + assert_eq!(image.dict.get(b"Interpolate"), Some(&Object::Bool(!converted))); + assert_eq!(&image.raw[..], b"X"); + } + } + assert_eq!(*doc.resolve(catalog.get(b"StaleImage").unwrap()), Object::Null); +} + +#[test] +fn preflight_preserves_repaired_image_references_in_snapshots_and_saves() { + for damaged in [false, true] { + let original = image_file(damaged); + let mut doc = Document::open(original.clone()).unwrap(); + assert_images(&doc, false, damaged); + let snapshot = doc.clone(); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|line| line.contains("cleared image interpolation"))); + assert_images(&doc, true, damaged); + assert_eq!(doc.full_save_required(), damaged); + assert_images(&snapshot, false, damaged); + let mut restored = snapshot.clone(); + restored.overlay_changes_from(&doc).unwrap(); + assert_images(&restored, true, damaged); + for changed in [&doc, &restored] { + for object_streams in [false, true] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let incremental = write_incremental(changed, &opts).unwrap(); + if !damaged { + assert!(incremental.starts_with(&original)); + } + assert_images(&Document::open(Arc::new(incremental)).unwrap(), true, damaged); + let full = write_full(changed, &opts).unwrap(); + let reopened = Document::open(Arc::new(full)).unwrap(); + assert_images(&reopened, true, damaged); + if damaged { + let catalog = reopened.get(reopened.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + assert_eq!(catalog.reference(b"HeaderImage"), catalog.reference(b"RowImage"), "saved aliases must share one object"); + } + } + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 8de636a500..25c0da7e00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,6 +63,10 @@ > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M10: dotted-leader contents entries export as complete paragraphs; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6: calculation scripts read their notation only from a comment, and a regular expression after a condition as one; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) + +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged) + +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) > @@ -78,6 +82,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) > @@ -204,7 +209,7 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration preserves edited nonzero-generation objects through incremental save/reopen. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. For damaged live rows, either the xref generation or a same-number header at its indexed offset may identify the object; neither-match and free slots remain null. Header probes read at most 128 bytes per candidate offset, and opening records at most 256 object disagreements plus a summary without expanding compact xref rows. Synthetic dictionary-edit and independent save/reopen controls cover this repair policy. Repair-aware preflight edits and restored snapshots retain both accepted aliases, with full rewrite for repaired edits; classic/packed output coalesces them to one object identity. Materialized diagnostics inspect retained primary/hybrid rows before folding, with the same cap. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | @@ -352,6 +357,8 @@ Estimates unchanged. | 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | | 2026-10-19 | minor | M4 #3309: named-destination pruning keeps large documents usable; 60,000-name and deep-tree regressions verify targets through undo/redo and both save modes. Estimates unchanged. | | 2026-10-19 | minor | M6: Text field table (rows and columns of text fields as one edit, `form_add_field_table`); estimates unchanged | +| 2026-10-20 | patch | M1, #1143 review: preserve accepted aliases through preflight/snapshots, normalize them to one output identity with full-save fallback for repaired edits, and report retained materialized/hybrid generation disagreements before folding. Synthetic red/green and four-save controls; estimates unchanged. | +| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | @@ -364,6 +371,7 @@ Estimates unchanged. | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-16 | minor | M5 #2669, item 5: the Comments panel groups replies and counts all page roots once per frame. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | +| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. | | 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 223fc66850..af283bed6e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -70,8 +70,8 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired"] -notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them" +evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction"] +notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim." [[feature]] id = "core.xref-streams" @@ -82,8 +82,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::indirect_stream_lengths_require_the_referenced_generation", "crates/cos/tests/it/generations.rs::compressed_objects_only_accept_generation_zero", "crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] +notes = "Compressed objects accept generation zero only; indirect stream lengths are resolved by their complete reference. Synthetic regression coverage." [[feature]] id = "core.hybrid-xref" @@ -298,8 +298,8 @@ milestone = "M1" status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] -evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::full_save_does_not_turn_stale_references_into_live_ones", "crates/cos/tests/it/generations.rs::repaired_page_tree_aliases_keep_one_identity_in_full_saves", "crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] +notes = "Full rewrites coalesce accepted repair aliases to one output object, preserving page-tree parent identity. They preserve reference-generation identity: stale references become null while live replacement objects remain reachable. Synthetic classic and object-stream save tests; this does not establish general rendering fidelity." [[feature]] id = "core.object-stream-compression" @@ -334,8 +334,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::free_xref_slots_never_revive_retained_object_headers", "crates/cos/tests/it/generations.rs::restoring_equal_values_still_restores_the_accepted_generation_aliases", "crates/cos/tests/it/generations.rs::restoring_explicit_sets_removes_old_generation_aliases", "crates/preflight/tests/generation_repairs.rs::preflight_preserves_repaired_image_references_in_snapshots_and_saves", "crates/cos/tests/it/generations.rs::repaired_header_references_survive_full_saves_and_dictionary_edits", "crates/cos/tests/it/generations.rs::incremental_generation_identity_is_visible_to_an_independent_reader", "crates/cos/tests/it/generations.rs::restoring_snapshot_preserves_changes_to_nonzero_generation_objects", "crates/cos/tests/it/generations.rs::overlay_generation_changes_do_not_retarget_old_references_or_clones", "crates/cos/tests/it/generations.rs::references_require_the_current_generation_even_with_a_warm_cache", "crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] +notes = "Generation-aware references stay distinct across warm caches, edit overlays and snapshots, including restoration and incremental save/reopen of edited nonzero-generation objects. A damaged live xref row may additionally resolve the matching same-number object header; unrelated generations and free slots remain null. Repair-aware edits preserve both aliases and require full rewrite; explicit replacement keeps only its supplied generation. Synthetic preflight/snapshot and classic/packed full/incremental controls include an independent reader. Broader real-file fidelity remains tracked separately." [[feature]] id = "core.write-validity" From 0f99608112988b3ecb231ae85d88009ccb7872ce Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:11 +1030 Subject: [PATCH 08/59] Contributors: credit helenkwok as Helen Kwok --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index fecfb68c90..8ab196abc9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -183,3 +183,6 @@ sync_github_name = true [people.Combiler01] sync_github_name = true + +[people.helenkwok] +real_name = "Helen Kwok" From d5b8a352487bbbaef15d3d586b96394b6300f74a Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:11 +1030 Subject: [PATCH 09/59] feat(web): page host mode, so a page can host PdfCraft in its own document (#4731) data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; host::Bridge holds a Transport (Parent or Page) with one send, and save, dirty, ready and failure reporting are shared. ?host=parent is checked first and unchanged. Hosting guide documents it. --- ROADMAP.md | 2 + apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft-web/src/main.rs | 240 ++++++++++++++++++++++++---------- packaging/web/README.md | 48 +++++++ 4 files changed, 224 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1f753c500f..f82d8c13a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -271,6 +272,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; anything else, including `?host=parent` unframed, keeps the normal app with downloads. It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index ad8243d594..83ef1ca9f0 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -16,5 +16,5 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] } js-sys = "0.3" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 73d14f4a6c..91da171fba 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -5,18 +5,11 @@ #[cfg(target_arch = "wasm32")] fn main() { - use eframe::wasm_bindgen::JsCast; use pdfcraft_ui_egui::PdfCraftApp; let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() }; wasm_bindgen_futures::spawn_local(async move { - let Some(canvas) = web_sys::window() - .and_then(|w| w.document()) - .and_then(|d| d.get_element_by_id("pdfcraft")) - .and_then(|e| e.dyn_into::().ok()) - else { - return; - }; + let Some(canvas) = app_canvas() else { return }; let started = eframe::WebRunner::new() .start( canvas, @@ -54,9 +47,10 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); - // `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such - // as the Nextcloud app) passes files in and takes saves back (`host` below). - if query_param("host").as_deref() == Some("parent") + // Host modes (`host` below): `?host=parent`, a same-origin page embedding + // PdfCraft in an iframe (such as the Nextcloud app), or `data-host="page"` + // on the canvas, a host that runs the module in its own document. + if host_requested() && let Some(bridge) = host::Bridge::new() { // `?author=`: the host's name for the user signs their comments, @@ -81,7 +75,7 @@ fn main() { .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); - if query_param("host").as_deref() == Some("parent") + if host_requested() && let Some(bridge) = host::Bridge::new() { bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); @@ -123,8 +117,37 @@ async fn fetch_bytes(url: &str) -> Result, String> { Ok(js_sys::Uint8Array::new(&buf).to_vec()) } -/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a -/// `type`: +/// The app's canvas: the one element `index.html` and every embedding page must provide, with +/// the id the app looks up. +#[cfg(target_arch = "wasm32")] +fn app_canvas() -> Option { + use eframe::wasm_bindgen::JsCast; + web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::().ok()) +} + +/// Whether a host mode was asked for: `?host=parent` (the iframe bridge) or +/// `data-host="page"` on the canvas (the host document itself). Whether a bridge can actually +/// be built is [`host::Bridge::new`]'s answer. +#[cfg(target_arch = "wasm32")] +fn host_requested() -> bool { + query_param("host").as_deref() == Some("parent") || page_host_enabled(app_canvas().and_then(|c| c.get_attribute("data-host")).as_deref()) +} + +/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on +/// (`data-host="page"`). Everything else — `Page`, ` page`, `parent` (the URL opt-in), empty +/// or missing — keeps the normal app with downloads. +#[cfg(any(target_arch = "wasm32", test))] +fn page_host_enabled(data_host: Option<&str>) -> bool { + data_host == Some("page") +} + +/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. Both are +/// opt-in and carry the same messages; only the transport differs, so only how an event goes +/// out and how `open` comes in change. +/// +/// `?host=parent` — the `postMessage` bridge for a same-origin page that embeds PdfCraft in an +/// iframe (such as the Nextcloud app). Messages are plain objects with a `type`, posted to the +/// parent window: /// /// - `pdfcraft:ready` (to the parent): the app is listening; send files now. /// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or @@ -136,23 +159,52 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// /// Only the parent window on the app's own origin is heard or answered. With a parent on /// another origin there is no bridge: the app runs as if unframed and saves download. +/// +/// `data-host="page"` on the app's canvas — for a host +/// that loads the wasm-bindgen module into its own document and renders on one of its own +/// canvases, with no iframe. The same messages travel as DOM `CustomEvent`s dispatched on that +/// canvas: the event name carries the `type`, and `detail` holds the same fields without it +/// (`pdfcraft:ready` carries `version`, `pdfcraft:save` carries `name`, `saveAs` and a `bytes` +/// `ArrayBuffer`). The host opens a file by dispatching a `pdfcraft:open` `CustomEvent` on the +/// canvas with `detail: { name, bytes }`. Events do not bubble, so nothing outside the canvas +/// hears them, and the canvas belongs to the document that loaded the module, so there is no +/// origin check. An unframed page that asks for `?host=parent` still gets no host mode: only +/// the exact attribute value `page` opts in. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + use super::page_host_enabled; + + #[derive(Clone)] + enum Transport { + /// A same-origin parent frame: send by `postMessage` with the origin, listen for + /// `message` events on the window. + Parent { window: web_sys::Window, parent: web_sys::Window, origin: String }, + /// The host document itself: send by dispatching a `CustomEvent` on the app's canvas, + /// and listen on it for `pdfcraft:open`. + Page { canvas: web_sys::HtmlCanvasElement }, + } + #[derive(Clone)] pub struct Bridge { - window: web_sys::Window, - parent: web_sys::Window, - origin: String, + transport: Transport, } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to), or when the parent - /// is on another origin: messages only go to this origin, so the browser would drop - /// every save without an error while the document showed as saved (#2167). + /// Picks the transport: `?host=parent` needs a framing page with a same-origin parent; + /// otherwise `data-host="page"` on the app's canvas opts the host document itself in. `None` when the parent is on another origin (or when + /// `?host=parent` is used unframed): messages only go to this origin, so the browser + /// would drop every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; + let canvas = super::app_canvas(); + if super::query_param("host").as_deref() != Some("parent") { + if page_host_enabled(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref()) { + return Some(Self { transport: Transport::Page { canvas: canvas? } }); + } + return None; + } let parent = window.parent().ok().flatten()?; if js_sys::Object::is(&parent, &window) { return None; @@ -162,59 +214,83 @@ mod host { if parent.location().origin().ok()? != origin { return None; } - Some(Self { window, parent, origin }) + Some(Self { transport: Transport::Parent { window, parent, origin } }) } - fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> { - let sent = match transfer { - Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)), - None => self.parent.post_message(message, &self.origin), - }; - sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}"))) + /// One outbound message, whatever the transport. `fields` is the payload: the parent + /// transport wraps it with a `type` in the posted object, the page transport puts it in + /// the event's `detail` as-is (the event name carries the type). `transfer` hands an + /// `ArrayBuffer` to the receiver instead of copying it (parent transport). + fn send(&self, name: &str, fields: &[(&str, JsValue)], transfer: Option<&JsValue>) -> Result<(), String> { + match &self.transport { + Transport::Parent { parent, origin, .. } => { + let mut all = Vec::with_capacity(fields.len().saturating_add(1)); + all.push(("type", JsValue::from_str(name))); + all.extend_from_slice(fields); + let Some(m) = message(&all) else { return Err("couldn't build the message".to_string()) }; + let sent = match transfer { + Some(t) => parent.post_message_with_transfer(&m, origin, &js_sys::Array::of1(t)), + None => parent.post_message(&m, origin), + }; + sent.map_err(|e| js_error(&e)) + } + Transport::Page { canvas } => { + let Some(detail) = message(fields) else { return Err("couldn't build the message".to_string()) }; + let init = web_sys::CustomEventInit::new(); + // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in + // web-sys 0.3.106 and fail clippy -D warnings; use the setters. + init.set_bubbles(false); + init.set_detail(&detail); + let event = web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| js_error(&e))?; + // The bool says whether a listener called `preventDefault`; not a send failure. + canvas.dispatch_event(&event).map(|_| ()).map_err(|e| js_error(&e))?; + Ok(()) + } + } } pub fn post_ready(&self) { - if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) { - let _ = self.post(&m, None); - } + let _ = self.send("pdfcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None); } - /// The app couldn't start (no WebGL, for example): let the parent say so. + /// The app couldn't start (no WebGL, for example): let the host say so. pub fn post_failed(&self, error: &str) { - if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) { - let _ = self.post(&m, None); - } + let _ = self.send("pdfcraft:failed", &[("error", error.into())], None); } - /// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download. + /// Hear `pdfcraft:open` — from the same-origin parent (a `message` on the window) or + /// from the host document (a `pdfcraft:open` on the canvas) — and queue the file like a + /// `?file=` download. pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { - let parent = self.parent.clone(); - let origin = self.origin.clone(); - let on_message = Closure::::new(move |event: web_sys::MessageEvent| { - if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { - return; + match &self.transport { + Transport::Parent { window, parent, origin } => { + let parent = parent.clone(); + let origin = origin.clone(); + let on_message = Closure::::new(move |event: web_sys::MessageEvent| { + if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { + return; + } + let data = event.data(); + if string(&data, "type").as_deref() != Some("pdfcraft:open") { + return; + } + queue_open(&data, &inbox, &ctx); + }); + if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { + // The listener lives as long as the page. + on_message.forget(); + } } - let data = event.data(); - if string(&data, "type").as_deref() != Some("pdfcraft:open") { - return; + Transport::Page { canvas } => { + let on_open = Closure::::new(move |event: web_sys::Event| { + let Some(open) = event.dyn_ref::() else { return }; + queue_open(&open.detail(), &inbox, &ctx); + }); + if canvas.add_event_listener_with_callback("pdfcraft:open", on_open.as_ref().unchecked_ref()).is_ok() { + // The listener lives as long as the page. + on_open.forget(); + } } - let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); - let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return }; - let bytes = if let Some(array) = bytes.dyn_ref::() { - array.to_vec() - } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes).to_vec() - } else { - return; - }; - if let Ok(mut q) = inbox.lock() { - q.push((name, bytes)); - } - ctx.request_repaint(); - }); - if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_message.forget(); } } @@ -222,10 +298,7 @@ mod host { let bridge = self.clone(); Box::new(move |name: &str, bytes: &[u8], save_as: bool| { let buffer = js_sys::Uint8Array::from(bytes).buffer(); - let m = - message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())]) - .ok_or_else(|| "couldn't build the message".to_string())?; - bridge.post(&m, Some(&buffer)) + bridge.send("pdfcraft:save", &[("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())], Some(&buffer)) }) } @@ -235,14 +308,30 @@ mod host { Box::new(move |dirty: bool| { if dirty != last { last = dirty; - if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) { - let _ = bridge.post(&m, None); - } + let _ = bridge.send("pdfcraft:dirty", &[("dirty", dirty.into())], None); } }) } } + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or + /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. + fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { + let name = string(data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; + let bytes = if let Some(array) = bytes.dyn_ref::() { + array.to_vec() + } else if bytes.is_instance_of::() { + js_sys::Uint8Array::new(&bytes).to_vec() + } else { + return; + }; + if let Ok(mut q) = inbox.lock() { + q.push((name, bytes)); + } + ctx.request_repaint(); + } + fn message(fields: &[(&str, JsValue)]) -> Option { let m = js_sys::Object::new(); for (key, value) in fields { @@ -254,9 +343,26 @@ mod host { fn string(data: &JsValue, key: &str) -> Option { js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() } + + fn js_error(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) + } } #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); } + +#[cfg(test)] +mod page_host_tests { + use super::page_host_enabled; + + #[test] + fn only_the_exact_value_page_enables_the_page_host_mode() { + assert!(page_host_enabled(Some("page"))); + for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] { + assert!(!page_host_enabled(off), "{off:?} must keep host mode off"); + } + } +} diff --git a/packaging/web/README.md b/packaging/web/README.md index 7aaa49d6a5..9e32c3602e 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -78,6 +78,54 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo downloads) are blocked. - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## Same-document host mode (no iframe) + +A host application can also run PdfCraft from its own document: it loads the wasm-bindgen module +itself and gives PdfCraft one of its own canvases, so there is no iframe and no `?host=parent`. +It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas +keeps the id the app looks up (`id="pdfcraft"`). + +The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. +The app dispatches the same messages it would post to a parent — `pdfcraft:ready`, +`pdfcraft:save`, `pdfcraft:dirty`, `pdfcraft:failed` — with the message fields (without the +`type`) as the event's `detail`, and it opens a file when the page dispatches a `pdfcraft:open` +event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself. + +Add the listeners before `init`: the app announces itself with `pdfcraft:ready` while `init` is +still running, and a file sent in reply would otherwise be missed. + +```js +import init from "./pdfcraft-web-.js"; // the wasm-bindgen glue shipped with the app + +const canvas = document.getElementById("pdfcraft"); +canvas.dataset.host = "page"; // or + +canvas.addEventListener("pdfcraft:ready", () => { + // The app is listening; hand it a document now, if you have one. + canvas.dispatchEvent(new CustomEvent("pdfcraft:open", { + detail: { name: "handout.pdf", bytes: pdfBytes }, // ArrayBuffer or Uint8Array + })); +}); +canvas.addEventListener("pdfcraft:save", (event) => { + const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer + // Store the file under `name`; `saveAs` is true for Save As. Report your own errors. +}); +canvas.addEventListener("pdfcraft:dirty", (event) => { + unsaved = event.detail.dirty; // warn before leaving, as you like +}); +canvas.addEventListener("pdfcraft:failed", (event) => { + showError(event.detail.error); // the app couldn't start +}); + +await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); +``` + +- Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and + the host decides where the file goes. +- `?author=` still signs the user's comments, as with `?host=parent`. +- An unframed page that uses `?host=parent` keeps the normal app with downloads; only + `data-host="page"` turns this mode on. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 5574a8bf4af089ce96d7188713a63885e4114738 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:48:41 +0800 Subject: [PATCH 10/59] ROADMAP: a framed ?host=parent selects the parent bridge; only other requests keep downloads --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f82d8c13a6..f2d886c3b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -272,7 +272,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged. - **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. -- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; anything else, including `?host=parent` unframed, keeps the normal app with downloads. It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. +- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. From 43efa9a9e39c7971cedbc95bfdc4f2c1a2c79b25 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:04:08 +0800 Subject: [PATCH 11/59] packaging/web: say that ?host=parent takes precedence over data-host="page" --- packaging/web/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packaging/web/README.md b/packaging/web/README.md index 9e32c3602e..728c1a20ae 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -123,8 +123,10 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); - Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and the host decides where the file goes. - `?author=` still signs the user's comments, as with `?host=parent`. -- An unframed page that uses `?host=parent` keeps the normal app with downloads; only - `data-host="page"` turns this mode on. +- An unframed page that uses `?host=parent` keeps the normal app with downloads. + `data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is + present it takes precedence, so a same-origin framed page uses the parent bridge, while an + unframed or cross-origin page gets no bridge and keeps normal downloads. ## Renderer selection and fallback flags From c79e48d6088e0ed1b78a53a7aee24a69cf2b4203 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:30:54 +0800 Subject: [PATCH 12/59] web host mode: refuse an oversized host file before copying it, cap the name, and document trust and limits --- apps/pdfcraft-web/src/main.rs | 27 +++++++++++++++++++++++---- packaging/web/README.md | 6 ++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 91da171fba..2ff9874d5f 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -314,18 +314,37 @@ mod host { } } + /// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits). + const MAX_OPEN_BYTES: u64 = 512 << 20; + + /// The longest file name taken from the host. + const MAX_NAME_CHARS: usize = 255; + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { - let name = string(data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string()); + let name: String = string(data, "name") + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| "document.pdf".to_string()) + .chars() + .take(MAX_NAME_CHARS) + .collect(); let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; - let bytes = if let Some(array) = bytes.dyn_ref::() { - array.to_vec() + let view = if let Some(array) = bytes.dyn_ref::() { + array.clone() } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes).to_vec() + js_sys::Uint8Array::new(&bytes) } else { return; }; + // A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory. + if u64::from(view.length()) > MAX_OPEN_BYTES { + eframe::web_sys::console::warn_1( + &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", view.length(), MAX_OPEN_BYTES >> 20).into(), + ); + return; + } + let bytes = view.to_vec(); if let Ok(mut q) = inbox.lock() { q.push((name, bytes)); } diff --git a/packaging/web/README.md b/packaging/web/README.md index 728c1a20ae..64552b1252 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -128,6 +128,12 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); present it takes precedence, so a same-origin framed page uses the parent bridge, while an unframed or cross-origin page gets no bridge and keeps normal downloads. +### Trust and limits + +- **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin. +- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. +- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From 3df903d516d2dfb3e00103c4960d2cbf9e32752d Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:11:28 +0800 Subject: [PATCH 13/59] pdfcraft-web: speak the host messages through craft-web-host The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); PdfCraft keeps the save and dirty hooks, the open size limit and the author. --- Cargo.lock | 10 ++ apps/pdfcraft-web/Cargo.toml | 2 + apps/pdfcraft-web/src/main.rs | 328 +++++++++------------------------- packaging/web/README.md | 2 + 4 files changed, 101 insertions(+), 241 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 573b215b7c..1bf064f5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1303,6 +1303,15 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-web-host" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -4729,6 +4738,7 @@ dependencies = [ name = "pdfcraft-web" version = "0.18.1" dependencies = [ + "craft-web-host", "eframe", "egui", "js-sys", diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index 83ef1ca9f0..56e10c0c29 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -11,6 +11,8 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } +# The host messages (storytold/craft-libs): one contract and one bridge for the web builds. +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 2ff9874d5f..ffa5a06068 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -47,12 +47,11 @@ fn main() { // eframe never raises a close request on a browser reload or tab close, so `guard_quit` never // prompts; ask the browser to confirm instead while a document has unsaved work (#812). warn_before_unload(app.unsaved_flag.clone()); - // Host modes (`host` below): `?host=parent`, a same-origin page embedding - // PdfCraft in an iframe (such as the Nextcloud app), or `data-host="page"` - // on the canvas, a host that runs the module in its own document. - if host_requested() - && let Some(bridge) = host::Bridge::new() - { + // Host modes (`host` below, over `craft-web-host`): `?host=parent`, a + // same-origin page embedding PdfCraft in an iframe (such as the Nextcloud + // app), or `data-host="page"` on the canvas, a host that runs the module in + // its own document. + if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) { // `?author=`: the host's name for the user signs their comments, // unless they chose a name in PdfCraft (the web default is "Guest"). if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()) @@ -75,9 +74,7 @@ fn main() { .await; if let Err(e) = started { eframe::web_sys::console::error_1(&e); - if host_requested() - && let Some(bridge) = host::Bridge::new() - { + if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) { bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))); } } @@ -125,263 +122,112 @@ fn app_canvas() -> Option { web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::().ok()) } -/// Whether a host mode was asked for: `?host=parent` (the iframe bridge) or -/// `data-host="page"` on the canvas (the host document itself). Whether a bridge can actually -/// be built is [`host::Bridge::new`]'s answer. -#[cfg(target_arch = "wasm32")] -fn host_requested() -> bool { - query_param("host").as_deref() == Some("parent") || page_host_enabled(app_canvas().and_then(|c| c.get_attribute("data-host")).as_deref()) -} - -/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on -/// (`data-host="page"`). Everything else — `Page`, ` page`, `parent` (the URL opt-in), empty -/// or missing — keeps the normal app with downloads. -#[cfg(any(target_arch = "wasm32", test))] -fn page_host_enabled(data_host: Option<&str>) -> bool { - data_host == Some("page") -} - -/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. Both are -/// opt-in and carry the same messages; only the transport differs, so only how an event goes -/// out and how `open` comes in change. +/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages +/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the +/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it. /// -/// `?host=parent` — the `postMessage` bridge for a same-origin page that embeds PdfCraft in an -/// iframe (such as the Nextcloud app). Messages are plain objects with a `type`, posted to the -/// parent window: -/// -/// - `pdfcraft:ready` (to the parent): the app is listening; send files now. -/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or -/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL. -/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is -/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors). -/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. -/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. -/// -/// Only the parent window on the app's own origin is heard or answered. With a parent on -/// another origin there is no bridge: the app runs as if unframed and saves download. -/// -/// `data-host="page"` on the app's canvas — for a host -/// that loads the wasm-bindgen module into its own document and renders on one of its own -/// canvases, with no iframe. The same messages travel as DOM `CustomEvent`s dispatched on that -/// canvas: the event name carries the `type`, and `detail` holds the same fields without it -/// (`pdfcraft:ready` carries `version`, `pdfcraft:save` carries `name`, `saveAs` and a `bytes` -/// `ArrayBuffer`). The host opens a file by dispatching a `pdfcraft:open` `CustomEvent` on the -/// canvas with `detail: { name, bytes }`. Events do not bubble, so nothing outside the canvas -/// hears them, and the canvas belongs to the document that loaded the module, so there is no -/// origin check. An unframed page that asks for `?host=parent` still gets no host mode: only -/// the exact attribute value `page` opts in. +/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an +/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the +/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas +/// is for a host that loads the module into its own document, with no iframe: the same messages +/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present. +/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it +/// says `file: false` and a host sends `bytes`. +/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a +/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied. +/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores +/// the file (and reports its own errors). +/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app +/// couldn't start. #[cfg(target_arch = "wasm32")] mod host { - use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; + use std::rc::Rc; - use super::page_host_enabled; + use craft_web_host::web::{FileData, Host, Message}; - #[derive(Clone)] - enum Transport { - /// A same-origin parent frame: send by `postMessage` with the origin, listen for - /// `message` events on the window. - Parent { window: web_sys::Window, parent: web_sys::Window, origin: String }, - /// The host document itself: send by dispatching a `CustomEvent` on the app's canvas, - /// and listen on it for `pdfcraft:open`. - Page { canvas: web_sys::HtmlCanvasElement }, - } + /// The app id in the host messages (`pdfcraft:ready`). + const APP: &str = "pdfcraft"; - #[derive(Clone)] - pub struct Bridge { - transport: Transport, - } - - impl Bridge { - /// Picks the transport: `?host=parent` needs a framing page with a same-origin parent; - /// otherwise `data-host="page"` on the app's canvas opts the host document itself in. `None` when the parent is on another origin (or when - /// `?host=parent` is used unframed): messages only go to this origin, so the browser - /// would drop every save without an error while the document showed as saved (#2167). - pub fn new() -> Option { - let window = web_sys::window()?; - let canvas = super::app_canvas(); - if super::query_param("host").as_deref() != Some("parent") { - if page_host_enabled(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref()) { - return Some(Self { transport: Transport::Page { canvas: canvas? } }); - } - return None; - } - let parent = window.parent().ok().flatten()?; - if js_sys::Object::is(&parent, &window) { - return None; - } - let origin = window.location().origin().ok()?; - // Reading another origin's location throws, which is `None` too. - if parent.location().origin().ok()? != origin { - return None; - } - Some(Self { transport: Transport::Parent { window, parent, origin } }) - } - - /// One outbound message, whatever the transport. `fields` is the payload: the parent - /// transport wraps it with a `type` in the posted object, the page transport puts it in - /// the event's `detail` as-is (the event name carries the type). `transfer` hands an - /// `ArrayBuffer` to the receiver instead of copying it (parent transport). - fn send(&self, name: &str, fields: &[(&str, JsValue)], transfer: Option<&JsValue>) -> Result<(), String> { - match &self.transport { - Transport::Parent { parent, origin, .. } => { - let mut all = Vec::with_capacity(fields.len().saturating_add(1)); - all.push(("type", JsValue::from_str(name))); - all.extend_from_slice(fields); - let Some(m) = message(&all) else { return Err("couldn't build the message".to_string()) }; - let sent = match transfer { - Some(t) => parent.post_message_with_transfer(&m, origin, &js_sys::Array::of1(t)), - None => parent.post_message(&m, origin), - }; - sent.map_err(|e| js_error(&e)) - } - Transport::Page { canvas } => { - let Some(detail) = message(fields) else { return Err("couldn't build the message".to_string()) }; - let init = web_sys::CustomEventInit::new(); - // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in - // web-sys 0.3.106 and fail clippy -D warnings; use the setters. - init.set_bubbles(false); - init.set_detail(&detail); - let event = web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| js_error(&e))?; - // The bool says whether a listener called `preventDefault`; not a send failure. - canvas.dispatch_event(&event).map(|_| ()).map_err(|e| js_error(&e))?; - Ok(()) - } - } - } - - pub fn post_ready(&self) { - let _ = self.send("pdfcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None); - } - - /// The app couldn't start (no WebGL, for example): let the host say so. - pub fn post_failed(&self, error: &str) { - let _ = self.send("pdfcraft:failed", &[("error", error.into())], None); - } - - /// Hear `pdfcraft:open` — from the same-origin parent (a `message` on the window) or - /// from the host document (a `pdfcraft:open` on the canvas) — and queue the file like a - /// `?file=` download. - pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { - match &self.transport { - Transport::Parent { window, parent, origin } => { - let parent = parent.clone(); - let origin = origin.clone(); - let on_message = Closure::::new(move |event: web_sys::MessageEvent| { - if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { - return; - } - let data = event.data(); - if string(&data, "type").as_deref() != Some("pdfcraft:open") { - return; - } - queue_open(&data, &inbox, &ctx); - }); - if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_message.forget(); - } - } - Transport::Page { canvas } => { - let on_open = Closure::::new(move |event: web_sys::Event| { - let Some(open) = event.dyn_ref::() else { return }; - queue_open(&open.detail(), &inbox, &ctx); - }); - if canvas.add_event_listener_with_callback("pdfcraft:open", on_open.as_ref().unchecked_ref()).is_ok() { - // The listener lives as long as the page. - on_open.forget(); - } - } - } - } - - pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { - let bridge = self.clone(); - Box::new(move |name: &str, bytes: &[u8], save_as: bool| { - let buffer = js_sys::Uint8Array::from(bytes).buffer(); - bridge.send("pdfcraft:save", &[("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())], Some(&buffer)) - }) - } - - pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { - let bridge = self.clone(); - let mut last = false; - Box::new(move |dirty: bool| { - if dirty != last { - last = dirty; - let _ = bridge.send("pdfcraft:dirty", &[("dirty", dirty.into())], None); - } - }) - } - } + /// The name a file gets when the host sent none (the crate says `document` for a blank name). + const DEFAULT_NAME: &str = "document.pdf"; /// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits). const MAX_OPEN_BYTES: u64 = 512 << 20; - /// The longest file name taken from the host. - const MAX_NAME_CHARS: usize = 255; + #[derive(Clone)] + pub struct Bridge { + host: Rc, + } - /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or - /// `Uint8Array` holding a PDF: queue it like a `?file=` download and wake the app. - fn queue_open(data: &JsValue, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { - let name: String = string(data, "name") - .filter(|n| !n.trim().is_empty()) - .unwrap_or_else(|| "document.pdf".to_string()) - .chars() - .take(MAX_NAME_CHARS) - .collect(); - let Ok(bytes) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return }; - let view = if let Some(array) = bytes.dyn_ref::() { - array.clone() - } else if bytes.is_instance_of::() { - js_sys::Uint8Array::new(&bytes) - } else { + impl Bridge { + /// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and + /// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent on + /// another origin (messages only go to this origin, so the browser would drop every save + /// without an error while the document showed as saved, #2167). + pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option { + Some(Self { host: Rc::new(Host::connect(APP, canvas)?) }) + } + + pub fn post_ready(&self) { + let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false); + } + + /// The app couldn't start (no WebGL, for example): let the host say so. + pub fn post_failed(&self, error: &str) { + let _ = self.host.post_failed(error, None); + } + + /// Hear `pdfcraft:open` and queue the file like a `?file=` download. + pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) { + self.host.listen(move |message| { + if let Message::Open { name, data } = message { + queue_open(&name, &data, &inbox, &ctx); + } + }); + } + + pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn { + let host = self.host.clone(); + Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as)) + } + + pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn { + let host = self.host.clone(); + // A host starts out clean: the first report is a change only when it is dirty. + let mut last = false; + Box::new(move |dirty: bool| { + if dirty != last { + last = dirty; + host.post_dirty(dirty); + } + }) + } + } + + /// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a + /// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255 + /// characters); a blank one comes as `document` and becomes `document.pdf`. + fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) { + let name = if name == "document" { DEFAULT_NAME } else { name }.to_string(); + let FileData::Bytes(view) = data else { + eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into()); return; }; // A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory. - if u64::from(view.length()) > MAX_OPEN_BYTES { + if data.size() > MAX_OPEN_BYTES as f64 { eframe::web_sys::console::warn_1( - &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", view.length(), MAX_OPEN_BYTES >> 20).into(), + &format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(), ); return; } - let bytes = view.to_vec(); if let Ok(mut q) = inbox.lock() { - q.push((name, bytes)); + q.push((name, view.to_vec())); } ctx.request_repaint(); } - - fn message(fields: &[(&str, JsValue)]) -> Option { - let m = js_sys::Object::new(); - for (key, value) in fields { - js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?; - } - Some(m) - } - - fn string(data: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() - } - - fn js_error(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:?}")) - } } #[cfg(not(target_arch = "wasm32"))] fn main() { eprintln!("pdfcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/pdfcraft-web"); } - -#[cfg(test)] -mod page_host_tests { - use super::page_host_enabled; - - #[test] - fn only_the_exact_value_page_enables_the_page_host_mode() { - assert!(page_host_enabled(Some("page"))); - for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] { - assert!(!page_host_enabled(off), "{off:?} must keep host mode off"); - } - } -} diff --git a/packaging/web/README.md b/packaging/web/README.md index 64552b1252..3149dc75d5 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -128,6 +128,8 @@ await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); present it takes precedence, so a same-origin framed page uses the parent bridge, while an unframed or cross-origin page gets no bridge and keeps normal downloads. +The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`. + ### Trust and limits - **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin. From 9927a1bd12f11e3e63c13677443622ad54089bd9 Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 5 Nov 2026 12:47:19 +0100 Subject: [PATCH 14/59] M10: page_enhance_scan as its own dispatch method Main split Automation::call into per-tool methods and its clippy cognitive-complexity limit (30) now fails with the inline arm. --- crates/automation/src/dispatch.rs | 62 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index c3203f355e..45df846ebc 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -137,35 +137,7 @@ impl Automation { out["detected"] = json!(given.is_none()); out } - "page_enhance_scan" => { - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => Vec::new(), - }; - let level = match a.opt_int("level")? { - None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level, - Some(l @ 0..=100) => l as u8, - Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), - }; - let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level); - if let Some(on) = a.opt_bool("edge_shadows")? { - settings.edge_shadows = on; - } - if let Some(on) = a.opt_bool("despeckle")? { - settings.despeckle = on; - } - let id = self.doc(&a)?.id; - let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?; - let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>(); - let skipped: Vec = - report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect(); - let mut out = summary(self.doc(&a)?); - out["pages_cleaned"] = json!(numbered(&report.pages)); - out["images"] = json!(report.images); - out["not_scanned"] = json!(numbered(&report.not_scanned)); - out["skipped"] = json!(skipped); - out - } + "page_enhance_scan" => self.page_enhance_scan(&a)?, "page_delete" => { let pages = self.pages(&a, "pages")?; self.apply(&a, Edit::DeletePages { pages })? @@ -948,3 +920,35 @@ impl Automation { Ok(out) } } + +impl Automation { + /// `page_enhance_scan`: Enhance ▸ Scanned document on the chosen pages (all by default). + fn page_enhance_scan(&mut self, a: &Args) -> Result { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => Vec::new(), + }; + let level = match a.opt_int("level")? { + None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level); + if let Some(on) = a.opt_bool("edge_shadows")? { + settings.edge_shadows = on; + } + if let Some(on) = a.opt_bool("despeckle")? { + settings.despeckle = on; + } + let id = self.doc(a)?.id; + let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?; + let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>(); + let skipped: Vec = report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect(); + let mut out = summary(self.doc(a)?); + out["pages_cleaned"] = json!(numbered(&report.pages)); + out["images"] = json!(report.images); + out["not_scanned"] = json!(numbered(&report.not_scanned)); + out["skipped"] = json!(skipped); + Ok(out) + } +} From 9cc1736dc92bc35351b416b93582f4c6cbf96dad Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 5 Nov 2026 12:56:10 +0100 Subject: [PATCH 15/59] Enhance scanned file: decode bi-level images within the budget, DecodeParms resolved --- crates/optimize/src/images.rs | 2 +- crates/optimize/src/samples.rs | 43 +++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9f2782ce1e..159ba04e8c 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -506,7 +506,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBud /// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under /// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression /// bomb, and the image is left alone. -fn allowance(bytes: usize, rows: u32) -> Option { +pub(crate) fn allowance(bytes: usize, rows: u32) -> Option { bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) } diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs index d3d76c7f41..4800da6fd7 100644 --- a/crates/optimize/src/samples.rs +++ b/crates/optimize/src/samples.rs @@ -5,7 +5,7 @@ use pdfcraft_cos::{Document, ImageBudget, Object, Stream}; -use crate::images::{MAX_PIXELS, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded}; +use crate::images::{MAX_PIXELS, allowance, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded}; /// An image's samples, one byte each, row-major. #[derive(Clone, Debug, PartialEq)] @@ -83,7 +83,7 @@ pub fn decode_samples(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> R Some(a) if is_inverted_decode(doc, a) => true, Some(_) => return Err(NotDecoded::Decode), }; - let data = unpack_bits(s, width, height, inverted).ok_or(NotDecoded::Corrupt)?; + let data = unpack_bits(doc, s, width, height, inverted, budget).ok_or(NotDecoded::Corrupt)?; return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted }); } if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) { @@ -107,12 +107,12 @@ fn packed_len(w: u32, h: u32) -> Option { usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?) } -/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn. -fn unpack_bits(s: &Stream, w: u32, h: u32, inverted: bool) -> Option> { +/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn, decoded within `budget` with +/// its `/DecodeParms` resolved, as the 8-bit images are. +fn unpack_bits(doc: &Document, s: &Stream, w: u32, h: u32, inverted: bool, budget: &mut ImageBudget) -> Option> { let stride = usize::try_from(w).ok()?.div_ceil(8); let need = packed_len(w, h)?; - // Some writers pad the data; anything far longer than the image needs is not this image. - let bits = s.decoded_within(need.saturating_add(need / 2).saturating_add(4096)).ok()?; + let bits = budget.decode_filters(doc, s, allowance(need, h)?).ok()?; let bits = bits.get(..need)?; let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); let mut out = Vec::with_capacity(w.checked_mul(h)?); @@ -246,6 +246,37 @@ mod tests { assert_eq!(back.decoded().unwrap(), raw); } + #[test] + fn bilevel_decode_parms_by_reference_decode_like_direct_ones() { + // 10 × 2 with a TIFF predictor: each 1 bit flips the pixel from the one to its left. + let packed = vec![0b1000_0000, 0b0000_0000, 0b0100_0000, 0b0000_0000]; + let parms = || { + let mut p = pdfcraft_cos::Dict::new(); + for (k, v) in [("Predictor", 2), ("Colors", 1), ("BitsPerComponent", 1), ("Columns", 10)] { + p.set(k.as_bytes().to_vec(), Object::Int(v)); + } + Object::Dict(p) + }; + let dict = [ + ("Width", Object::Int(10)), + ("Height", Object::Int(2)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(1)), + ]; + let mut d = doc(); + let mut direct = Stream::flate(image(&dict, vec![]).dict, &packed); + direct.dict.set(b"DecodeParms".to_vec(), parms()); + let mut indirect = direct.clone(); + let r = d.add(parms()); + indirect.dict.set(b"DecodeParms".to_vec(), Object::Ref(r)); + let a = decode_samples(&d, &direct, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + let b = decode_samples(&d, &indirect, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + // Row 1: white from x = 0 on; row 2: black at x = 0, white from x = 1 on. + assert_eq!(&a.data[..10], &[255; 10]); + assert_eq!(&a.data[10..], &[0, 255, 255, 255, 255, 255, 255, 255, 255, 255]); + assert_eq!(b, a); + } + #[test] fn what_is_not_supported_says_why_and_never_panics() { let base = |extra: &[(&str, Object)], raw: Vec| { From 8d569066b8dd5ce97d71fa50dd121b577ed818ce Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:13:56 +0100 Subject: [PATCH 16/59] M5: Escape leaves a Fill & Sign tool, as it leaves a comment tool (#4539) * Escape leaves a Fill & Sign tool, as it leaves a comment tool The first Escape closes the open type box, the next one goes back to Select; Fill & Sign's Text tool otherwise opened a box at every click with no way out by the keyboard. Co-Authored-By: Claude Opus 5.5 * Fill & Sign: a new text box deselects, so two presses of Escape close it and leave the tool Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/tools/comments/input.rs | 6 ++- crates/ui-egui/src/tools/fill_sign.rs | 8 +++- crates/ui-egui/tests/it/fill_sign.rs | 51 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1f753c500f..9c1ecb136d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -299,6 +299,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ac3c2a6878..23a5ab3f06 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -606,9 +606,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } let cv = &mut view.comments; if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - // One step back per press: cancel the gesture, then deselect, then drop the tool. + // One step back per press: cancel the gesture, then deselect, then drop the tool (a + // comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every + // click with no way out by the keyboard). let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); - if !cancelled && matches!(tool, QuickTool::Comment(_)) { + if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) { *tool = QuickTool::Select; } } diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 224ba8b49d..a156dfe3d2 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -491,7 +491,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo match tool { FillTool::Text => { // Clicking away from an open box commits it: the page sees this click before the box - // sees its lost focus, so replacing the box first would drop what was typed. + // sees its lost focus, so replacing the box first would drop what was typed. A new box + // deselects, as a comment tool's note or text box does: Escape then closes the box and + // leaves the tool in two presses. + view.comments.selected = None; let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { FillAction::Edit(Box::new(typed_with_color( @@ -587,7 +590,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, r.request_focus(); t.focus = false; } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Taken here: the same press must not also leave the tool (one step back per press). + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cancel = true; } else if r.lost_focus() { commit = true; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 9f565d834a..b5ad9f0522 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -159,6 +159,57 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() { assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open"); } +/// Escape steps back out of a Fill & Sign tool as out of a comment tool: the first press closes +/// the open type box, the next one leaves the Text tool for Select, so a click opens no new box. +#[test] +fn escape_leaves_the_fill_and_sign_tools() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + assert!(h.state().views[0].fill_text.is_some(), "a box is open"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().views[0].fill_text.is_none(), "the first Escape closes the box"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for the next box"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select, "the next Escape leaves the tool"); + click(&mut h, 50.0, 150.0); + assert!(h.state().views[0].fill_text.is_none(), "no new box"); + assert!(items(&h).is_empty(), "nothing was added: {:?}", items(&h)); + // A mark tool too. + h.state_mut().execute("sign.fill.check"); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); +} + +/// A box opened while a comment is selected deselects it: two presses of Escape still close the +/// box and leave the tool. +#[test] +fn escape_leaves_the_text_tool_in_two_presses_after_a_selection() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(1); + commit_typed_text(&mut h); + assert_eq!(items(&h).len(), 1, "{:?}", items(&h)); + h.state_mut().views[0].fill_text = None; + h.state_mut().views[0].comments.selected = Some((0, 0)); + click(&mut h, 50.0, 150.0); + assert!(h.state().views[0].fill_text.is_some(), "a box is open"); + assert_eq!(h.state().views[0].comments.selected, None, "the new box deselected the comment"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().views[0].fill_text.is_none(), "the first Escape closes the box"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select, "the second Escape leaves the tool"); +} + #[test] fn fill_text_size_controls_new_text_and_survives_settings_restore() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index af283bed6e..6ff5eca28e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5423,8 +5423,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] -notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools"] +notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" From 65aa495c982c105619f1d14a04517b749df51366 Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 5 Nov 2026 13:19:49 +0100 Subject: [PATCH 17/59] Enhance scanned file: re-render the page shown after a successful run --- crates/ui-egui/src/canvas/tests.rs | 37 +++++++++++++++++++ crates/ui-egui/src/workflows/scan_clean_ui.rs | 4 ++ 2 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d5b8312d6c..5260ccc4ed 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2380,3 +2380,40 @@ fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() { assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page"); assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it"); } + +/// #5125: after Enhance scanned file, the page shown is re-rendered, not the raster of the +/// scan before it was cleaned (same scale, so nothing else would replace it). +#[test] +fn enhancing_a_scan_re_renders_the_page_shown() { + let (w, h) = (64usize, 64usize); + let px: Vec = (0..w * h).map(|i| if i % w < 4 { 30 } else { 198 }).collect(); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n", + px.len() + ) + .into_bytes(); + pdf.extend_from_slice(&px); + let content = format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q"); + pdf.extend_from_slice( + format!( + "\nendstream\nendobj\n5 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n", + content.len() + ) + .as_bytes(), + ); + let mut app = PdfCraftApp::new(); + app.open_bytes("scan.pdf", None, pdf).unwrap(); + let (i, doc) = app.active_ids().unwrap(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("before", egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR); + app.views[i].pages.insert(0, PageTex { tag: 1000, tex }); + let job = app.session.scan_clean_job(doc, &[], pdfcraft_engine::scan_clean::ScanCleanSettings::default()).unwrap(); + let cleaned = job.run(|_, _| true).unwrap(); + assert_eq!(cleaned.report.pages, vec![0], "the page is a scan and gets cleaned"); + let progress = + std::sync::Arc::new(std::sync::Mutex::new(crate::scan_clean_ui::ScanCleanProgress { result: Some(Ok(cleaned)), ..Default::default() })); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress }); + app.poll_scan_clean(); + assert!(app.scan_clean_run.is_none()); + assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered"); +} diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs index 64f8ce48eb..81d6764af9 100644 --- a/crates/ui-egui/src/workflows/scan_clean_ui.rs +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -230,6 +230,10 @@ impl PdfCraftApp { self.progress_notice = None; match result.and_then(|cleaned| self.session.apply_scan_clean(doc, cleaned)) { Ok(report) => { + // The page rasters, tiles and thumbnails show the old pixels at the same scale. + for v in self.views.iter_mut().filter(|v| v.id == doc) { + v.invalidate_content(); + } let msg = summary(&report); self.notify(msg); } From 04cee34bfe1f6c64fb581a625b145c5a04391d6f Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:21:29 +0100 Subject: [PATCH 18/59] M5: a double-click on a comment card's text edits it (#4544) * A double-click on a comment card's text edits it On the selected card in the Comments panel only the header took the double-click: the text is a selectable label, so a double-click there selected a word and the editor stayed closed (a tester's report). It now opens the editor too; dragging over the text still selects it for copying. Co-Authored-By: Claude Opus 5.5 * Tests: the double-clicked card's editor is drawn too Co-Authored-By: Claude Opus 5.5 * Review: the double-click test also starts from a card that isn't selected It selected the card before any input, so it never covered what the change is for in the Comments panel as one uses it: a first click on the text of an unselected card selects it, the second edits it. The test now runs both ways. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels/comments_panel.rs | 12 ++++-- crates/ui-egui/tests/it/comments.rs | 47 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index d54daf5717..591a667159 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -433,7 +433,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti buttons_left = ui.min_rect().left(); }); }); - egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { + let body = egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { ui.set_width(ui.available_width()); if editing { if let Some(e) = inline_editor(ui, t, view, key, 2) { @@ -531,7 +531,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // The header selects the card, except where its buttons are. let mut r = head.response.rect; r.max.x = r.max.x.min(buttons_left - 2.0); - r + (r, body.response.rect) }); let rect = frame.response.rect; if selected { @@ -545,7 +545,8 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti ui.add_space(5.0); // The header row selects the card (and shows the comment on the page); unselected cards are // clickable everywhere. - let hit = if selected { frame.inner } else { rect }; + let (head, body) = frame.inner; + let hit = if selected { head } else { rect }; let click = ui .interact(hit, ui.scope_id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) @@ -555,7 +556,10 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.reply.clear(); *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } - if click.double_clicked() && allowed { + // On the selected card a double-click on the text edits it too (dragging over it selects + // words of it, for copying). + let body = (selected && !editing).then(|| ui.interact(body, ui.scope_id().with(("card-body", key)), Sense::click())); + if (click.double_clicked() || body.is_some_and(|b| b.double_clicked())) && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } let menu_id = ui.scope_id().with(("card-menu", key)); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 129bcbf3e4..29396830f7 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -143,6 +143,53 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { } } +/// A double-click on the text of a card in the Comments panel edits it, as one on its header does +/// (a click there would select a word): on a selected card, and on one the first click selects. +#[test] +fn double_clicking_a_cards_text_edits_it() { + for selected in [true, false] { + double_click_a_cards_text(selected); + } +} + +fn double_click_a_cards_text(selected: bool) { + // Frames 1/60 s apart, so two clicks make a double-click. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "comments").unwrap(); + let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment }; + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Edit this note".into(), + author: "Tester".into(), + }))); + app.views[0].comments.selected = selected.then_some((0, 0)); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + assert_eq!(h.state().views[0].comments.selected.is_some(), selected); + let p = h.query_all_by_label("Edit this note").next().expect("the card's text").rect().center(); + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "selected: {selected}"); + assert_eq!(h.state().views[0].comments.editing, Some((0, 0, "Edit this note".into())), "selected: {selected}"); + // And the editor is drawn. + note_editor(&h); +} + #[test] fn dragging_over_text_with_the_highlighter_highlights_it() { let mut h = harness(|app| app.set_option("quick", "highlight").unwrap()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6ff5eca28e..f543284d54 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4118,8 +4118,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/tests/it/comments.rs::undo_and_redo_close_a_reply_editor_left_open"] -notes = "double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines). Undo or Redo closes the panel's editor without saving it, since either can move the comment it was open on" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/tests/it/comments.rs::undo_and_redo_close_a_reply_editor_left_open", "crates/ui-egui/tests/it/comments.rs::double_clicking_a_cards_text_edits_it"] +notes = "double-click a comment or card (its header or its text), or Edit in the menus; double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines). Undo or Redo closes the panel's editor without saving it, since either can move the comment it was open on" [[feature]] id = "comment.copy-comment-text" From 96fe832e12c6de3ba88005496ba3d57cc3039fa9 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:01:18 +0100 Subject: [PATCH 19/59] M5: Shift holds lines level or plumb while drawing (ortho) (#4285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M5: Shift holds lines level or plumb while drawing (ortho) With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud is held the same way from the corner before, rubber band included. Dragging an end or corner of a drawn one with Shift is held the same way; it turned to 45° steps before. The measuring tools keep their 45° steps. measure::snap::ortho next to constrain; comment_add takes `ortho` for line, arrow, polygon, cloud and polyline. Parity comment.draw-ortho. Co-Authored-By: Claude Opus 5.5 * Ortho: over the first corner the rubber band shows the closing side, Shift or not From review: a click there closes the polygon or cloud to its first corner, while the preview with Shift held showed a level or plumb side. The click and the preview now share one rule (closes_at). Co-Authored-By: Claude Opus 5.5 * Rebase: Shift is read where main's split put the drawing input, and the ortho helpers are imported there Co-Authored-By: Claude Opus 5.5 * Rebase: Shift holds a drawn line, not a freehand stroke (a hunk had landed in the wrong branch) Co-Authored-By: Claude Opus 5.5 * Ortho: the parity note names the closing side's exception; the test's doc comment is back above its test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/automation/src/comments.rs | 16 +++- crates/automation/src/tools.rs | 1 + crates/automation/tests/it/automation.rs | 38 +++++++++ crates/measure/src/snap.rs | 6 ++ crates/measure/src/tests.rs | 10 +++ crates/ui-egui/src/tools/comments/geometry.rs | 18 ++-- crates/ui-egui/src/tools/comments/input.rs | 18 ++-- crates/ui-egui/src/tools/comments/paint.rs | 14 +++- crates/ui-egui/tests/it/comments.rs | 83 +++++++++++++++++-- docs/ui-parity.md | 1 + parity/acrobat-features.toml | 14 +++- 12 files changed, 197 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 49f7d4101d..5eaf0f5035 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -302,6 +302,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. +- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 135d0868ee..a1e5e05263 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -253,6 +253,12 @@ impl Automation { if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") { return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into())); } + // Shift while drawing: each end or corner held level or plumb with the one before. + let ortho = a.opt_bool("ortho")?.unwrap_or(false); + if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") { + return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into())); + } + let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p }; let markup = match kind { "highlight" => Some(Markup::Highlight), "underline" => Some(Markup::Underline), @@ -325,7 +331,8 @@ impl Automation { Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size } } "line" | "arrow" => { - let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?); + let f = a.need::<2>("from", "a line")?; + let t = held(f, a.need::<2>("to", "a line")?); let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } @@ -352,10 +359,15 @@ impl Automation { "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?; + let mut last = None; let vertices = pts .iter() .map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::>()).as_deref() { - Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + Some([Some(x), Some(y)]) => { + let p = last.map_or([*x, *y], |l| held(l, [*x, *y])); + last = Some(p); + Ok(to_user(&info, p[0], p[1])) + } _ => Err(wrong()), }) .collect::>>()?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 14b55361c5..0728679c8e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -861,6 +861,7 @@ pub fn tools() -> Vec { "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, + "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, }), &["doc", "page", "type"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 35c4db79c2..5bde9d27f9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6493,6 +6493,44 @@ fn drawing_comments_through_tools() { assert!(matches!(png[0], Content::Png { .. })); } +/// comment_add `ortho`, Shift's hold while drawing: a line's end is held level or plumb with its +/// start, and each corner of connected lines or a polygon with the corner before. +#[test] +fn lines_and_corners_are_held_level_or_plumb_through_the_tool() { + let dir = workdir("ortho"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for args in [ + json!({ "type": "line", "from": [20, 40], "to": [120, 52], "ortho": true }), + json!({ "type": "arrow", "from": [180, 40], "to": [170, 140], "ortho": true }), + json!({ "type": "polyline", "points": [[20, 200], [90, 210], [95, 260], [130, 250]], "ortho": true }), + json!({ "type": "line", "from": [20, 280], "to": [120, 290] }), + ] { + let mut args = args; + args.as_object_mut().unwrap().extend([("doc".to_string(), json!(doc)), ("page".to_string(), json!(1))]); + ok(&mut a, "comment_add", args); + } + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + // In the order added: the list isn't sorted by index. + let mut listed = list["comments"].as_array().unwrap().clone(); + listed.sort_by_key(|c| c["index"].as_u64()); + let points: Vec> = + listed.iter().map(|c| c["points"].as_array().unwrap().iter().map(|p| [p[0].as_f64().unwrap(), p[1].as_f64().unwrap()]).collect()).collect(); + let want: [&[[f64; 2]]; 4] = [ + &[[20.0, 40.0], [120.0, 40.0]], + &[[180.0, 40.0], [180.0, 140.0]], + &[[20.0, 200.0], [90.0, 200.0], [90.0, 260.0], [130.0, 260.0]], + &[[20.0, 280.0], [120.0, 290.0]], + ]; + assert_eq!(points.len(), want.len(), "{points:?}"); + for (got, want) in points.iter().zip(want) { + assert_eq!(got.len(), want.len(), "{got:?}"); + assert!(got.iter().zip(want).all(|(g, w)| (g[0] - w[0]).abs() < 0.01 && (g[1] - w[1]).abs() < 0.01), "{got:?} for {want:?}"); + } + let refused = a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 50, 50], "ortho": true })); + assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}"); +} + #[test] fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { let dir = workdir("signing-graphic"); diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 930ffe3053..b26c389e96 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -189,6 +189,12 @@ pub fn constrain(last: Point, point: Point) -> Point { let angle = (v[1].atan2(v[0]) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; [last[0] + length * angle.cos(), last[1] + length * angle.sin()] } +/// Hold a proposed vertex level or plumb with the last vertex, whichever is nearer (a CAD +/// drawing's ortho mode): the vertex slides onto that axis rather than turning onto it. +pub fn ortho(last: Point, point: Point) -> Point { + let v = sub(point, last); + if v[0].abs() >= v[1].abs() { [point[0], last[1]] } else { [last[0], point[1]] } +} struct Walker<'a> { doc: &'a Document, geometry: Geometry, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 5b277e5546..88906910c7 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -449,6 +449,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(!listing.measurements.is_empty()); } #[test] +fn ortho_holds_a_vertex_level_or_plumb_with_the_last() { + let last = [10.0, 20.0]; + assert_eq!(snap::ortho(last, [50.0, 27.0]), [50.0, 20.0], "mostly across: level"); + assert_eq!(snap::ortho(last, [-30.0, 13.0]), [-30.0, 20.0], "to the left too"); + assert_eq!(snap::ortho(last, [14.0, 80.0]), [10.0, 80.0], "mostly up: plumb"); + assert_eq!(snap::ortho(last, [8.0, -40.0]), [10.0, -40.0], "and down"); + assert_eq!(snap::ortho(last, [30.0, 40.0]), [30.0, 20.0], "a tie stays level"); + assert_eq!(snap::ortho(last, last), last); +} +#[test] fn a_stream_over_the_byte_budget_uses_it_up_once() { // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index ee67b92bb0..4f3b338fe6 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -32,15 +32,21 @@ pub(super) fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option, to: [f64; 2], shift: bool) -> [f64; 2] { + match last { + Some(l) if shift => pdfcraft_engine::measure::snap::ortho(*l, to), + _ => to, + } +} + +/// `points` with point `i` moved to `to`; with Shift, held level or plumb with the point before +/// it (a line's other end; the first corner is held to the second). pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { let mut out = points.to_vec(); let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) }; - let to = match anchor { - Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to), - _ => to, - }; + let to = held(anchor, to, shift); if let Some(p) = out.get_mut(i) { *p = to; } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 23a5ab3f06..caddd3e837 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,8 +1,8 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, - resized, snapped_caret, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped, + resizable, resized, snapped_caret, }; -use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; +use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -346,6 +346,8 @@ impl Gestures<'_> { } fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds a line level or plumb (ortho). + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -374,7 +376,8 @@ impl Gestures<'_> { } } else { points.truncate(1); - points.push(u); + let line = matches!(*tool, CommentTool::Line | CommentTool::Arrow); + points.push(held(points.first(), u, line && shift)); } } if resp.drag_stopped() @@ -408,6 +411,8 @@ impl Gestures<'_> { } fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds the next side of connected lines, a polygon or a cloud level or plumb. + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -434,8 +439,9 @@ impl Gestures<'_> { match cv.gesture.as_mut() { Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + let closes = closes_at(cx, tool, points, p); + let u = held(points.last(), u, shift); + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(cx.to_screen(u)) < 2.0); if closes { let pts = std::mem::take(points); cv.gesture = None; diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 8faae3d02e..f993ecf85b 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,4 +1,4 @@ -use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable}; use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -165,10 +165,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke)); } CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { - // The vertices so far, and a rubber band to the pointer. + // The vertices so far, and a rubber band to where a click would put the next one: the + // first, where the click closes the shape, Shift or not. let mut line = pts.clone(); if let Some(p) = cx.ptr.pos { - line.push(p); + let shift = painter.ctx().input(|i| i.modifiers.shift); + line.push(if closes_at(cx, *tool, points, p) { pts[0] } else { cx.to_screen(held(points.last(), cx.to_user(p), shift)) }); } painter.add(egui::Shape::line(line, stroke)); if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) { @@ -187,6 +189,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin } } +/// Whether a click at `p` (on screen) closes the polygon or cloud drawn so far (`points`, user +/// space): on its first point again, once it has three. +pub(super) fn closes_at(cx: &PageCx<'_>, tool: CommentTool, points: &[[f64; 2]], p: Pos2) -> bool { + points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0 +} + /// The shape a finished drawing gesture makes (`None` if it is too small to mean anything). pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { let (first, last) = (*points.first()?, *points.last()?); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 29396830f7..674cd90d26 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -956,8 +956,8 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() { } /// A line's ends have handles: dragging one with the Select tool changes the line's length and -/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a -/// new line starts there rather than dragging the one just drawn. +/// angle (Shift holds it level or plumb with the other end), as one undo step. The line tool keeps +/// drawing from an end: a new line starts there rather than dragging the one just drawn. #[test] fn line_ends_are_dragged_to_change_length_and_angle() { let mut h = harness(|app| app.set_option("quick", "line").unwrap()); @@ -981,7 +981,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}"); let id = h.state().views[0].id; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment")); - // Shift: held to the nearest 45° from the other end (39° here). + // Shift: held level or plumb with the other end, here level (110 pt across, 90 up). let a = at(&h, 200.0, 160.0); let b = at(&h, 150.0, 190.0); h.hover_at(a); @@ -998,8 +998,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); h.run_steps(1); let p = ends(&h); - let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]); - assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}"); + assert!((p[1][1] - p[0][1]).abs() < 0.01 && (p[1][0] - 150.0).abs() < 1.0, "level: {p:?}"); // Undo puts the line back as it was before the drag. h.state_mut().undo(); h.run_steps(2); @@ -1083,6 +1082,80 @@ fn alt_arrows_navigate_instead_of_nudging_the_selected_comment() { assert!(comments(&h)[0].rect[0] > before[0], "→ nudged it"); } +/// Over the first corner, where a click closes the polygon, the rubber band runs to that corner +/// with Shift held too: the closing side the click will make, not a level or plumb one. +#[test] +fn the_rubber_band_shows_the_closing_side_with_shift_held() { + let _gpu = crate::gpu(); + let mut h = harness(|app| app.set_option("quick", "polygon").unwrap()); + for p in [(40.0, 40.0), (140.0, 40.0), (140.0, 140.0)] { + click_pt(&mut h, p); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.hover_at(at(&h, 40.0, 40.0) + egui::vec2(2.0, 2.0)); + h.run_steps(3); + let pixels = h.render().unwrap(); + // Halfway along the diagonal closing side, (90, 90). + let m = at(&h, 90.0, 90.0); + let drawn = (-2..=2).any(|d: i32| { + let v = pixels.get_pixel((m.x as i32 + d) as u32, (m.y as i32 + d) as u32).0; + v[0] > 150 && v[1] < 100 && v[2] < 100 + }); + assert!(drawn, "the closing side is drawn"); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); +} + +/// Shift holds a new line or arrow level or plumb with where it starts, and each new side of +/// connected lines or a polygon with the corner before (a CAD drawing's ortho mode). Without +/// Shift they go where the pointer is. +#[test] +fn shift_holds_new_lines_and_sides_level_or_plumb() { + let mut h = harness(|app| app.set_option("quick", "line").unwrap()); + let shift = |h: &mut Harness<'static, PdfCraftApp>, on: bool| { + h.event(egui::Event::ModifiersChanged(if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE })); + h.run_steps(1); + }; + let points = |h: &Harness<'static, PdfCraftApp>, subtype: &str| { + // In the order drawn: the list isn't sorted by index. + let mut drawn: Vec<_> = comments(h).into_iter().filter(|a| a.subtype == subtype).collect(); + drawn.sort_by_key(|a| a.index); + drawn.into_iter().map(|a| a.points).collect::>() + }; + let close = |p: [f32; 2], q: [f32; 2]| (p[0] - q[0]).abs() < 1.0 && (p[1] - q[1]).abs() < 1.0; + shift(&mut h, true); + drag_pt(&mut h, (40.0, 100.0), (140.0, 120.0)); + drag_pt(&mut h, (200.0, 40.0), (215.0, 150.0)); + shift(&mut h, false); + drag_pt(&mut h, (40.0, 160.0), (140.0, 190.0)); + let lines = points(&h, "Line"); + assert_eq!(lines.len(), 3, "{lines:?}"); + assert!(close(lines[0][0], [40.0, 100.0]) && close(lines[0][1], [140.0, 100.0]), "mostly across: level: {lines:?}"); + assert!(close(lines[1][0], [200.0, 40.0]) && close(lines[1][1], [200.0, 150.0]), "mostly up: plumb: {lines:?}"); + assert!(close(lines[2][1], [140.0, 190.0]), "no Shift, no hold: {lines:?}"); + + h.state_mut().set_option("quick", "arrow").unwrap(); + shift(&mut h, true); + drag_pt(&mut h, (260.0, 180.0), (150.0, 170.0)); + shift(&mut h, false); + let arrow = points(&h, "Line").pop().expect("an arrow"); + assert!(close(arrow[1], [150.0, 180.0]), "an arrow too, leftwards: {arrow:?}"); + + // Connected lines: each click with Shift is held to the corner before. + h.state_mut().set_option("quick", "polyline").unwrap(); + click_pt(&mut h, (20.0, 20.0)); + shift(&mut h, true); + click_pt(&mut h, (90.0, 30.0)); + click_pt(&mut h, (95.0, 80.0)); + shift(&mut h, false); + click_pt(&mut h, (130.0, 60.0)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let poly = points(&h, "PolyLine").pop().expect("connected lines"); + assert_eq!(poly.len(), 4, "{poly:?}"); + assert!(close(poly[1], [90.0, 20.0]) && close(poly[2], [90.0, 80.0]) && close(poly[3], [130.0, 60.0]), "{poly:?}"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f950e36a94..dbb38ce730 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -49,6 +49,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | | Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped | | Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | +| Shift while drawing a line | holds it to 45° steps | holds it level or plumb (a CAD drawing's ortho): new lines and arrows, each side of connected lines, polygons and clouds, and a dragged end or corner | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c62ad80a2d..d4a8c37392 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4095,7 +4095,19 @@ status = "shipped" commands = [] tools = ["comment_edit"] evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"] -notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." +notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it level or plumb with the point before, comment.draw-ortho), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." + +[[feature]] +id = "comment.draw-ortho" +title = "Shift holds lines level or plumb while drawing (ortho)" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add"] +evidence = ["crates/measure/src/tests.rs::ortho_holds_a_vertex_level_or_plumb_with_the_last", "crates/ui-egui/tests/it/comments.rs::shift_holds_new_lines_and_sides_level_or_plumb", "crates/ui-egui/tests/it/comments.rs::the_rubber_band_shows_the_closing_side_with_shift_held", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::lines_and_corners_are_held_level_or_plumb_through_the_tool"] +notes = "With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before, the rubber band included. Over the first corner, where a click closes a polygon or cloud, the closing side runs straight to that corner, Shift or not. A dragged end or corner of a drawn one is held the same way (comment.reshape). Acrobat holds lines to 45° steps instead; on drawings and plans the level and plumb lines are the ones wanted. Headless: comment_add `ortho`. Measuring tools keep their 45° steps." [[feature]] id = "comment.move-preview" From 983e2f2ad6bfa4b7377163050f9b3fe16af980d3 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:25:56 +0100 Subject: [PATCH 20/59] =?UTF-8?q?M3:=20=E2=86=92=20/=20=E2=86=90=20scroll?= =?UTF-8?q?=20a=20page=20wider=20than=20the=20window=20before=20turning=20?= =?UTF-8?q?it=20(#4540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * → / ← scroll a page wider than the window sideways before turning it On a plan zoomed in, the arrow keys turned the page (#185) and never showed its other side: the view moved sideways only by the scroll bar, the wheel or the Hand. Now → / ← scroll a line that way while there is more of the page, and turn the page at its edge, as ↓ / ↑ do in single-page view. ⌘→ / ⌘← turn it at once; a page that fits across turns as before. Co-Authored-By: Claude Opus 5.5 * Review: → / ← read the current page's or spread's edges, not the widest page's; ui-parity's change record In continuous view the content is as wide as the widest page, so on a narrower current page that shows whole → scrolled toward the wide page's edge instead of turning. The edges are now the current page's (or spread's) as drawn, or the content's. Co-Authored-By: Claude Opus 5.5 * Review: a spread's second page off screen still counts for → / ← The row's edges came from the pages' screen transforms, and a page gets one only while it meets the window. With a zoomed two-page spread scrolled so its second page was still wholly to the right of the window, the row ended with the first page, so the next → turned the spread instead of scrolling to the second page. The edges are now taken from the layout rectangles of every page in the row, placed as the drawn pages are. A TwoUp case in the viewer tests zooms a spread until its page gap sits at the window's right edge and checks that → scrolls on rather than turning. Co-Authored-By: Claude Opus 5.5 * Review: → / ← read the row after continuous view has moved the current page In continuous view the current page is updated while the pages are laid out, after the row's extent was taken, so the edges could describe the previous row and the next → or ← turn the page instead of scrolling the page that had just become current. The row is now read after that update. Co-Authored-By: Claude Opus 5.5 * Review: a page turned by → / ← at its edge starts at the edge the reader comes from Turning at a wide page's right edge with →, the next page kept the horizontal scroll (the same column, as other jumps keep it), so an equally wide page opened at its right edge and the next → turned again without showing its left side; ← had the same in reverse. When plain → or ← turns the page, a page or spread wider than the window now starts at its left (after →) or right (after ←). ⌘→ / ⌘←, Page Down / Page Up and every other jump keep the column. The sideways-arrow test now goes across two wide pages both ways: → scrolls the next page from its left edge, ← turns back to the previous page shown from its right edge. Co-Authored-By: Claude Opus 5.5 * Rebase: the sideways arrow keys through main's PageEnv, PageOut and GlideCx, and shown_row with its layout Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 69 +++++++++++-- crates/ui-egui/src/canvas/navigation.rs | 32 +++++- crates/ui-egui/tests/it/viewer.rs | 132 ++++++++++++++++++++++++ docs/ui-parity.md | 5 +- parity/acrobat-features.toml | 4 +- 6 files changed, 229 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b3b0691e16..17ef6e4d75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -305,6 +305,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. +- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be2dd97d53..96210fb2fd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -492,6 +492,10 @@ pub struct DocView { pub forward: Vec, /// Pending navigation: page and fraction down the page to align with the viewport top. pub goto: Option<(usize, f32)>, + /// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window + /// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right, + /// after ←) instead of keeping the column. + pub(crate) goto_side: Option, /// Pending position within that page from a destination ([`Self::go_to_dest`]): the point /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points /// from the viewport's top-left corner. @@ -499,6 +503,12 @@ pub struct DocView { /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / /// Page Up where the pages scroll. pub key_scroll: f32, + /// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page + /// wider than the window. + pub key_side: f32, + /// As last drawn: whether the view was at its left and at its right edge (both when the + /// pages fit across), so → / ← turn the page there and scroll sideways elsewhere. + side_edges: Option<(bool, bool)>, /// Single-page view, as last drawn: the page shown and whether the view was at its top and /// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273). single_edges: Option<(usize, bool, bool)>, @@ -928,8 +938,11 @@ impl DocView { back: Vec::new(), forward: Vec::new(), goto: None, + goto_side: None, goto_point: None, key_scroll: 0.0, + key_side: 0.0, + side_edges: None, single_edges: None, flash: None, compare_marks: Vec::new(), @@ -1162,6 +1175,7 @@ struct PageEnv<'a> { info: &'a DocInfo, initials: &'a Option, key_scroll: f32, + key_side: f32, middle_gesture: bool, modal: bool, navigation_enabled: bool, @@ -1211,6 +1225,9 @@ struct PageOut { page_action: Option, primary_down: bool, refused: Option, + /// The current page or spread where it is drawn, every page of it: one off screen (the second + /// page of a zoomed spread) has no screen transform, but still decides where the row ends. + row_on_screen: Option, stamp_placed: bool, } @@ -1597,6 +1614,7 @@ fn draw_pages( hand, info, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -1626,7 +1644,7 @@ fn draw_pages( // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution // until after the text widgets when a selection owns that drag. let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide; if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -1779,6 +1797,7 @@ fn draw_pages( page_action: _, primary_down: _, refused, + row_on_screen: _, stamp_placed, } = page_out; let mut page_action: Option = None; @@ -1831,6 +1850,9 @@ fn draw_pages( if view.continuous { view.current = current; } + // The row as it is now: in continuous view the current page may have changed above. + let row_on_screen = + shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); resp.context_menu(|ui| { let is_crop_over_image = tool == QuickTool::EditText && view.image_selection.clone().is_some_and(|sel| { @@ -2006,6 +2028,7 @@ fn draw_pages( page_action, primary_down: ptr.primary_down, refused, + row_on_screen, stamp_placed, }; (wanted, visible_now) @@ -2468,6 +2491,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); } else if let Some((page, frac)) = view.goto.take() { + let side = view.goto_side.take(); let page = page.min(rects.len() - 1); let r = rects[page]; // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() @@ -2483,7 +2507,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // quick-action bar as the layout does. let (x, w) = (view.last_scroll.x, avail.width()); let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + // Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at + // the edge the reader comes from. + let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r); + let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE); + let target = match side { + Some(from_right) if row_right - row_left > w => { + if from_right { + row_right - w + } else { + row_left + } + } + _ if right - left <= w => { + if left >= x && right <= x + w { + x + } else { + r.center().x - w / 2.0 + } + } + _ => x.clamp(left, right - w), + }; if (target - x).abs() > 0.5 { scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); } @@ -2733,6 +2777,7 @@ struct GlideCx { now: f64, may_glide: bool, key_scroll: f32, + key_side: f32, auto_delta: Vec2, zoom_delta: f32, edge_delta: Vec2, @@ -2746,11 +2791,11 @@ struct GlideCx { /// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for /// a reader action. Returns this frame's glide. fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { - let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } let glide = view.fling.step(now); @@ -2832,7 +2877,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { + let key_side = std::mem::take(&mut app.views[index].key_side); + if key_scroll != 0.0 || key_side != 0.0 { app.views[index].end_find_following(); } // The Search panel closed: its search moves to the find bar. @@ -2932,7 +2978,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates); let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page; - let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; let glide = step_glide(ui, view, avail, &motion); // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. @@ -3039,6 +3085,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { info, initials: &initials, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -3089,6 +3136,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { page_action, primary_down, mut refused, + row_on_screen, stamp_placed, } = page_out; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { @@ -3098,6 +3146,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); + // Within a point, as above: at an edge of the scrolled content, or of the current page or + // spread shown whole that way (in continuous view the content is as wide as the widest page, + // and a narrower current page shows whole inside it). + let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0); + let inner = out.inner_rect; + view.side_edges = Some(( + out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0), + out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0), + )); view.viewport_screen = out.inner_rect; view.auto_scroll.paint(ui, avail); diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index bc98a243fa..1a2a0343a6 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do - // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. + // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider + // than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page + // once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← + // turn it at once. let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; - if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { + let line = KEY_SCROLL_LINE * speed.factor(); + // Only an up-to-date view counts: no jump pending. + let sideways = |view: &DocView, right: bool| { + !command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge }) + }; + // Turned at an edge, the next page starts at its left and the previous one at its right, as + // reading on across: on two wide pages → doesn't turn twice without showing the next one's left. + let at_edge = |view: &mut DocView, right: bool| { + if !command && view.goto.is_some() { + view.goto_side = Some(!right); + } + }; + if key(Key::ArrowRight) && sideways(view, true) { + view.key_side += line; + } else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); + if key(Key::ArrowRight) { + at_edge(view, true); + } } - if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { + if key(Key::ArrowLeft) && sideways(view, false) { + view.key_side -= line; + } else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { view.step_page(false); + if key(Key::ArrowLeft) { + at_edge(view, false); + } } // ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In // single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as @@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down / // Page Up keep a screen. if !command { - let line = KEY_SCROLL_LINE * speed.factor(); let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE); let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)]; for (k, by) in steps { diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 6f7d3d3cc8..d383582915 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1484,6 +1484,138 @@ fn up_and_down_turn_pages_in_single_page_view() { assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1"); } +#[test] +fn left_and_right_scroll_a_page_wider_than_the_window_before_turning_it() { + // A plan zoomed in: → / ← turned the page (#185) and so never showed its other side; the + // view moved sideways only by the scroll bar, the wheel or the Hand. + use egui::{Key, Modifiers}; + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].set_zoom(12.0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(v.current).map(|r| r.left())) + }; + let start = h.state().views[0].page_screen_rect(0).map(|r| r.left()).expect("on screen"); + let (page, left) = press(&mut h, Modifiers::NONE, Key::ArrowRight); + assert_eq!(page, 0, "→ scrolls the wide page rather than turning it"); + assert!(left.is_some_and(|l| l < start - 10.0), "the page moved left: {left:?} from {start}"); + // At its right edge the next → turns the page. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowRight).0 == 0 { + n += 1; + assert!(n < 500, "→ never reached the right edge"); + } + assert!(n > 3, "→ scrolled across page 1 before turning ({n} presses)"); + assert_eq!(h.state().views[0].current, 1); + // Page 2 starts at its left edge, as reading on across: → scrolls it rather than turning + // again at once. + let shown = |h: &Harness<'static, PdfCraftApp>, page: usize| { + let v = &h.state().views[0]; + (v.page_screen_rect(page).expect("on screen"), v.viewport_rect()) + }; + let (r, view) = shown(&h, 1); + assert!(r.left() >= view.left() && r.right() > view.right(), "page 2 from its left edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 1, "→ scrolls page 2 from its left edge"); + // ← goes back to that edge and turns back: page 1 comes at its right edge, so the next ← scrolls it. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowLeft).0 == 1 { + n += 1; + assert!(n < 50, "← never turned back from page 2"); + } + assert_eq!(h.state().views[0].current, 0); + let (r, view) = shown(&h, 0); + assert!(r.right() <= view.right() && r.left() < view.left(), "page 1 from its right edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowLeft).0, 0, "← scrolls page 1 from its right edge"); + // ⌘→ turns at once. + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 1, "⌘→ turns the page at once"); + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 2); + // A page that fits across: → turns pages as before. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(6); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 3); +} + +/// Continuous view with pages of different widths: the content is as wide as the widest page, but +/// on a narrow page that shows whole → turns the page; on the wide one it scrolls first. +#[test] +fn left_and_right_follow_the_current_pages_width_in_continuous_view() { + use egui::Key; + 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("mixed.pdf", None, sized_fixture(&[(200, 1200), (3000, 1200), (200, 1200)])).unwrap(); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + assert!(h.state().views[0].continuous); + // Each page taller than the window, so one is the current page. + h.state_mut().views[0].set_zoom(1.0); + h.run_steps(6); + h.state_mut().views[0].go_to_page(0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>| { + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(1).map(|r| r.left())) + }; + assert_eq!(h.state().views[0].current, 0); + let (page, start) = press(&mut h); + assert_eq!(page, 1, "→ on the narrow page shown whole turns it"); + let (page, left) = press(&mut h); + assert_eq!(page, 1, "→ on the wide page scrolls it"); + assert!(left.zip(start).is_some_and(|(l, s)| l < s - 10.0), "the wide page moved left: {left:?} from {start:?}"); +} + +/// A zoomed two-page spread scrolled so its first page ends inside the window and the second is +/// still wholly to the right of it: → scrolls on to the second page rather than turning the +/// spread. (Read from the pages on screen alone, the spread seemed to end with its first page.) +#[test] +fn right_scrolls_to_a_spreads_second_page_off_screen_before_turning() { + use egui::Key; + // The page gap (points on screen). + const GAP: f32 = 14.0; + let build = |zoom: f32| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("spread.pdf", None, sized_fixture(&[(600, 800); 4])).unwrap(); + app.set_option("layout", "two-page").unwrap(); + // Before the first layout: zoomed about the content's top left, scrolled to it. + app.views[0].set_zoom(zoom); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + h.run_steps(4); + h + }; + let shown = + |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].viewport_rect(), h.state().views[0].page_screen_rect(0).expect("page 1 on screen")); + // Zoom until page 1's right edge sits half a gap inside the window's. + let mut zoom = 2.0; + let mut h = build(zoom); + for _ in 0..3 { + let (inner, first) = shown(&h); + zoom *= (inner.right() - GAP / 2.0 - first.left()) / first.width(); + h = build(zoom); + } + let (inner, first) = shown(&h); + assert!(first.right() <= inner.right() && first.right() + GAP > inner.right(), "the spread's gap at the window's edge: {first:?} in {inner:?}"); + assert!(h.state().views[0].page_screen_rect(1).is_none(), "its second page off screen"); + assert!(h.state().views[0].layout == pdfcraft_ui_egui::canvas::PageLayout::TwoUp && !h.state().views[0].continuous); + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + assert!(v.current < 2, "→ doesn't turn the spread: page {}", v.current + 1); + assert!(v.page_screen_rect(1).is_some(), "it scrolls the second page into view"); +} + #[test] fn v_h_and_space_pick_the_quick_tools() { // The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dbb38ce730..69072e259c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -44,7 +44,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped | | Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap | | Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned | -| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | +| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll; → / ← scroll a page wider than the window before turning it | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | | Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped | @@ -83,6 +83,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d4a8c37392..1a3edc4c36 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"] -notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"] +notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" [[feature]] id = "view.goto-page-label" From c3c36f77cf1f1beb8785ad5caccf6cb654f598c5 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:39:36 +0100 Subject: [PATCH 21/59] M5: a comment's text, border or line, and fill each at an opacity of its own (#4542) * A comment's text, border or line, and fill each at an opacity of its own Comment Properties showed one opacity for a whole comment, so a text box's see-through background took its text and border with it, and a rectangle marking an area on a plan couldn't have an opaque line over a faint fill. Now a text box, typewriter text or callout has a Text, Border and Fill opacity, a rectangle, oval, polygon or cloud a Line and Fill opacity (a part not drawn is greyed); everything else keeps one Opacity. The line's stays /CA, the fill's is /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity, each only where it differs from the one it would follow, and the appearance draws them with /GS0 (strokes at the line's, fills at the fill's) and /GS1 for the text, so every viewer shows them. The quick bar's Opacity and StyleAnnotation's set the whole comment's. Engine: Style::{fill_opacity, text_opacity}, Edit::PartOpacity, annot::{set_part_opacity, appearance::part_opacity}, Props::{fill_opacity, text_opacity}. Make Properties Default and the settings carry the parts. Headless: comment_add / comment_edit fill_opacity, text_opacity; comment_list reports opacity, fill_opacity, text_opacity. Four labels in every catalog (AI-assisted translations from English, noted in ATTRIBUTION.toml). Co-Authored-By: Claude Opus 5.5 * Review: the text's opacity slider shows what the text is drawn at; comment_list skips replies' properties; ui-parity's change record A text box without fill and border whose text kept an opacity of its own showed the border's in its Text opacity slider; it now shows the text's, and a change sets the whole comment's. The opacity sliders are named by their labels. comment_list reads a comment's properties only for comments, not for every reply. Co-Authored-By: Claude Opus 5.5 * Rebase: comment_list tells replies by is_reply, as main now threads them main threads replies by their /IRT reference (#4061); the opacity fields skip a reply the same way. Co-Authored-By: Claude Opus 5.5 * Review: a text-only text box made default gives the tool its text's opacity With neither a border nor a fill, a text box's text is the whole comment: Comment Properties shows its drawn opacity (the text's own, when set), and a change there sets the whole comment's. Make Properties Default, and the context menu's Make Current Properties Default, stored /CA instead, so a text box whose text was at 50 % gave the tool 100 %. The default is now the text's drawn opacity in that case. Co-Authored-By: Claude Opus 5.5 * Rebase: the part opacities in main's annot layout (Style's fields, the text box drawing's parts, set_part_opacity exported) Co-Authored-By: Claude Opus 5.5 * Rebase: main's new comment test builds a Style with the part opacities Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 60 +++++++-- crates/annot/src/appearance/symbols.rs | 4 +- crates/annot/src/create.rs | 17 ++- crates/annot/src/info.rs | 9 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 67 +++++++++- crates/annot/src/tests.rs | 83 ++++++++++++- crates/annot/src/types.rs | 13 +- crates/automation/src/comments.rs | 36 +++++- crates/automation/src/tools.rs | 10 +- crates/automation/tests/it/automation.rs | 49 ++++++++ crates/engine/src/edit.rs | 9 ++ crates/engine/src/edit/labels.rs | 8 +- crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/tests.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 4 + crates/ui-egui/src/dialogs/comment_props.rs | 79 +++++++++++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/tools/comments.rs | 9 ++ crates/ui-egui/src/tools/comments/menu.rs | 2 + crates/ui-egui/tests/it/comments.rs | 131 +++++++++++++++++++- crates/ui-egui/tests/it/ui.rs | 22 ++++ crates/xfdf/src/tests.rs | 2 +- docs/ui-parity.md | 5 +- parity/acrobat-features.toml | 12 ++ 27 files changed, 584 insertions(+), 56 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 17ef6e4d75..cf8cacb73e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -306,6 +306,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. +- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 89c0f70f48..dbecae5c6d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -262,19 +262,47 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) { (col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0)) } -fn ext_gstate(opacity: f64, multiply: bool) -> Option { - if opacity >= 1.0 && !multiply { +/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or +/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line); +/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text +/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartOpacity { + pub line: f64, + pub fill: f64, + pub text: f64, +} + +/// The parts' opacities of the comment `d` (see [`PartOpacity`]). +pub fn part_opacity(d: &Dict) -> PartOpacity { + let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0)); + let line = get(b"CA").unwrap_or(1.0); + let fill = get(b"ca").unwrap_or(line); + let text = get(b"PCTextOpacity").unwrap_or(fill); + PartOpacity { line, fill, text } +} + +/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the +/// text's differs from the fill's: the text's for its glyphs. +fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option { + if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply { return None; } - let mut gs = Dict::new(); - gs.set(b"Type".to_vec(), Object::name("ExtGState")); - if multiply { - gs.set(b"BM".to_vec(), Object::name("Multiply")); - } - gs.set(b"CA".to_vec(), Object::Real(opacity)); - gs.set(b"ca".to_vec(), Object::Real(opacity)); + let gs = |stroke: f64, fill: f64| { + let mut gs = Dict::new(); + gs.set(b"Type".to_vec(), Object::name("ExtGState")); + if multiply { + gs.set(b"BM".to_vec(), Object::name("Multiply")); + } + gs.set(b"CA".to_vec(), Object::Real(stroke)); + gs.set(b"ca".to_vec(), Object::Real(fill)); + Object::Dict(gs) + }; let mut res = Dict::new(); - res.set(b"GS0".to_vec(), Object::Dict(gs)); + res.set(b"GS0".to_vec(), gs(parts.line, parts.fill)); + if parts.text != parts.fill { + res.set(b"GS1".to_vec(), gs(parts.line, parts.text)); + } Some(res) } @@ -391,7 +419,9 @@ struct AppearanceContext<'a> { subtype: &'a [u8], rect: [f64; 4], cloudy: bool, + /// The line's opacity (`parts.line`). opacity: f64, + parts: PartOpacity, stroke: Option, width: f64, } @@ -408,7 +438,7 @@ impl AppearanceDrawing { fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); if let Some(gs) = ext_gstate( - context.opacity, + context.parts, context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); @@ -455,7 +485,8 @@ fn appearance_context(d: &Dict) -> Option> { subtype, rect, cloudy, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + opacity: part_opacity(d).line, + parts: part_opacity(d), stroke: color(d, b"C")?, width: border_width(d), }) @@ -904,6 +935,7 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { let d = context.dict; + let parts = context.parts; let rect = context.rect; let stroke = context.stroke; let mut c = std::mem::take(&mut drawing.content); @@ -977,6 +1009,10 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let pad = 2.0 + bw; let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); let q = d.int(b"Q").unwrap_or(0); + // The text at its own opacity, where it has one. + if parts.text != parts.fill { + c.push_str("/GS1 gs\n"); + } c.push_str(&format!( "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", n(rect[0]), diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index 3b33662524..3178f78e94 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; +use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; /// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp /// another program wrote without an appearance. Names are matched byte for byte, as PDF names @@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option { // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. let stroke = color(d, b"C").flatten(); let mut res = Dict::new(); - if let Some(gs) = ext_gstate(opacity, false) { + if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) { res.set(b"ExtGState".to_vec(), Object::Dict(gs)); } // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 1904745e95..298dc67f41 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{n, rich_text}; +use super::modify::{n, rich_text, set_part_keys}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let rect = rect_for(&new.shape, style)?; let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); - let opacity = style.opacity.clamp(0.0, 1.0); - set_non_opaque_style(&mut d, opacity); + if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) { + return Err(AnnotError::Invalid("invalid opacity".into())); + } + let line = style.opacity.clamp(0.0, 1.0); + let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0)); + let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0)); + set_part_keys(&mut d, appearance::PartOpacity { line, fill, text }); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -443,12 +448,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], } } -fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { - if opacity < 1.0 { - dict.set(b"CA".to_vec(), Object::Real(opacity)); - } -} - fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { let Some(rect) = rect else { return Ok(()) }; let mut popup = Dict::new(); diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 1a54dbc077..ce001bcf29 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -217,6 +217,11 @@ pub struct Props { pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its + /// line's or border's). + pub fill_opacity: Option, + /// How opaque a text box's, typewriter text's or callout's text is drawn. + pub text_opacity: Option, } /// The current properties of the comment at `(page, index)`. @@ -239,7 +244,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { author: text_value(doc, d, b"T").unwrap_or_default(), subject: text_value(doc, d, b"Subj").unwrap_or_default(), color, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + opacity: appearance::part_opacity(d).line, width, icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), modified: text_value(doc, d, b"M"), @@ -248,6 +253,8 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fillable: fillable(&subtype), fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), + text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, endings: line_endings_of(d), }) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 4f24d04b08..6d555dbb9c 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -42,7 +42,7 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index a615b8e013..469815af7f 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -558,7 +558,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) } -/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and +/// redraw it. pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; @@ -593,13 +594,10 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha d.set(b"C".to_vec(), rgb(c)); } } + // The whole comment: its fill and text drop opacities of their own. if let Some(o) = opacity { let o = o.clamp(0.0, 1.0); - if o < 1.0 { - d.set(b"CA".to_vec(), Object::Real(o)); - } else { - d.remove(b"CA"); - } + set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o }); } if let Some(w) = width { let old_w = border_width_of(d); @@ -707,6 +705,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option redraw_or_restore(doc, r, before) } +/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an +/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it. +/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for +/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it +/// would otherwise follow. +pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option, text: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if fill.is_some() && !fillable(&subtype) { + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into())); + } + if text.is_some() && subtype != "FreeText" { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into())); + } + if fill.into_iter().chain(text).any(|o| !o.is_finite()) { + return Err(AnnotError::Invalid("invalid opacity".into())); + } + // Check before changing anything: a stale appearance would contradict the new opacity. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype)); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let mut parts = appearance::part_opacity(d); + if let Some(f) = fill { + parts.fill = f.clamp(0.0, 1.0); + } + if let Some(t) = text { + parts.text = t.clamp(0.0, 1.0); + } + set_part_keys(d, parts); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the +/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one +/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one +/// opacity keeps just `/CA`. +pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) { + let appearance::PartOpacity { line, fill, text } = parts; + let mut put = |key: &[u8], value: f64, unless: f64| { + if value == unless { + d.remove(key); + } else { + d.set(key.to_vec(), Object::Real(value)); + } + }; + put(b"CA", line, 1.0); + put(b"ca", fill, line); + put(b"PCTextOpacity", text, fill); +} + /// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. fn ensure_ending_room(d: &mut Dict) { let subtype = d.name(b"Subtype").unwrap_or_default(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2ff781a3a5..91b36550cf 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -40,7 +40,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation { NewAnnotation { page, shape, - style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None }, + style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() }, contents: "hello".into(), author: "Ada".into(), } @@ -356,6 +356,83 @@ fn restyle_and_resize_redraw_the_appearance() { assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_)))); } +/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of +/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and +/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text. +#[test] +fn parts_are_drawn_at_opacities_of_their_own() { + let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64()); + let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> { + let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?; + let obj = doc.get(n); + let Object::Stream(s) = &*obj else { return None }; + let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?; + let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?; + let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?; + let gs = gs.as_dict()?; + Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?)) + }; + let mut doc = fixture(); + let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); + set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap(); + set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[r]; + assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own"); + assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3))); + let ap = ap_content(&doc2, d); + assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}"); + let p = props(&doc2, 0, r).unwrap(); + assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None)); + // The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again. + set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap(); + let d = &list(&doc, 0)[r]; + assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None)); + assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6)); + + // A text box with its border opaque, its background at 20 % and its text opaque, as made. + let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 }); + tb.style.fill = Some([1.0, 1.0, 0.6]); + tb.style.width = 1.0; + (tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0)); + let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[t]; + assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0))); + assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0)))); + let ap = ap_content(&doc2, d); + let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text")); + assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}"); + // A new fill opacity leaves the text as it is drawn. + set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0))); + let p = props(&doc, 0, t).unwrap(); + assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0))); + // All at one opacity again: only /CA stays. + set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None)); + + // Only the parts a comment has, and finite values; nothing changes when refused. + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }), + &meta("l"), + ) + .unwrap(); + assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill"); + assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text"); + let before = list(&doc, 0)[r].clone(); + assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_)))); + assert_eq!(list(&doc, 0)[r], before); + // Another app's comment with /CA alone is drawn at it throughout, as before. + assert_eq!( + appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])), + appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 } + ); +} + #[test] fn shapes_can_be_filled_and_unfilled() { let mut doc = fixture(); @@ -873,8 +950,8 @@ fn cloud_ys(path: &str) -> Vec { fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = fixture(); let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; - let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; - let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; + let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; let i = add_text_replacement( &mut doc, TextReplacement { diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 522995b9f9..2831613091 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -566,6 +566,11 @@ pub struct Style { pub width: f64, /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. pub fill: Option, + /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the + /// line's or border's); `None`: as `opacity`. + pub fill_opacity: Option, + /// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's. + pub text_opacity: Option, } /// The style properties changed by one annotation edit. `None` leaves a property unchanged. @@ -597,7 +602,7 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None } } } @@ -615,7 +620,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), @@ -624,9 +629,9 @@ impl Style { Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), // Red outline while marked; a black box once applied. - Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() }, }; - Self { color, opacity: 1.0, width, fill: None } + Self { color, width, ..Self::default() } } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index a1e5e05263..2ca9fbd540 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -190,6 +190,19 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); + // How opaque it is drawn: as a whole, or its fill and text each at their own. + // Not for replies, which are drawn with the comment they answer. + if !c.is_reply() + && let Some(p) = doc.comment_props(c.page, c.index) + { + v["opacity"] = json!(p.opacity); + if let Some(f) = p.fill_opacity { + v["fill_opacity"] = json!(f); + } + if let Some(t) = p.text_opacity { + v["text_opacity"] = json!(t); + } + } // A line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. // Unchanged on a page the viewer could not read, as `rect` is. @@ -437,6 +450,19 @@ impl Automation { if let Some(o) = a.opt_num("opacity")? { style.opacity = o.clamp(0.0, 1.0); } + // The fill's and a text box's text's own opacities (the line keeps `opacity`). + let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); + let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. }); + if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) { + return Err(ToolError::InvalidArgs( + "fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(), + )); + } + if text_opacity.is_some() && !texts { + return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into())); + } + style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0)); + style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0)); if let Some(w) = a.opt_num("width")? { style.width = w.max(0.0); } @@ -668,6 +694,14 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + // After `opacity`, which sets the whole comment's. + let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); + if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { + return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into())); + } + if fill_opacity.is_some() || text_opacity.is_some() { + edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity }); + } if let Some(size) = a.opt_num("font_size")? { if !(1.0..=400.0).contains(&size) { return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into())); @@ -701,7 +735,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0728679c8e..7b55a80094 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -858,7 +858,9 @@ pub fn tools() -> Vec { "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." }, "color": color(), "fill": color(), - "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity (its line or border's, where its fill or text has its own)." }, + "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." }, + "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, @@ -992,11 +994,13 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), - "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity: its fill and text drop opacities of their own." }, + "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity (rectangle, oval, polygon, cloud, text box, typewriter text, callout); the line or border keeps the comment's." }, + "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 5bde9d27f9..42a88a3633 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// A rectangle's fill and a text box's text each take an opacity of their own through the tools +/// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again. +#[test] +fn comment_parts_take_opacities_of_their_own_through_the_tools() { + let dir = workdir("part-opacity"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "fill": "yellow", "fill_opacity": 0.25 }), + ); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "fill": "#FFFF99", "width": 1, "fill_opacity": 0.2, "text_opacity": 1 }), + ); + let find = |a: &mut Automation, kind: &str| { + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); + list["comments"].as_array().unwrap().iter().find(|c| c["type"] == kind).unwrap().clone() + }; + let rect = find(&mut a, "Square"); + assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64(), rect.get("text_opacity")), (Some(1.0), Some(0.25), None)); + let tb = find(&mut a, "FreeText"); + assert_eq!((tb["opacity"].as_f64(), tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(1.0), Some(0.2), Some(1.0))); + // The fill changes, the text stays; an undo step. + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "fill_opacity": 0.5 })); + let tb = find(&mut a, "FreeText"); + assert_eq!((tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(0.5), Some(1.0))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties"); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + // The whole comment's opacity: everything at it again. + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "opacity": 0.6 })); + let rect = find(&mut a, "Square"); + assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64()), (Some(0.6), Some(0.6))); + // Only the parts a comment has, at 0–1. + for (tool, args) in [ + ("comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [0, 0], "to": [50, 50], "fill_opacity": 0.5 })), + ("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "text_opacity": 0.5 })), + ("comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "fill_opacity": 2 })), + ] { + assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "text_opacity": 0.5 })), + Err(ToolError::Failed(_)) + )); +} + #[test] fn comments_through_tools() { let dir = workdir("comments"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 40745f8c2a..c2cb1b3fd2 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -263,6 +263,15 @@ pub enum Edit { index: usize, fill: Option, }, + /// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an + /// opacity of its own (0–1), the line or border keeping the comment's; `None` leaves that + /// part as it is. `StyleAnnotation`'s opacity sets the whole comment's. + PartOpacity { + page: usize, + index: usize, + fill: Option, + text: Option, + }, /// Change the font size (pt) of a text box, typewriter text or callout. FontSizeAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index e0ec47a2ba..e037ebc620 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -230,9 +230,11 @@ impl Edit { Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } + Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c11904a74e..39c9ace3e0 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -64,6 +64,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { @@ -575,6 +576,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 416cdc1d54..12c31dd7a6 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -68,6 +68,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..5d9e5e971d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2211,7 +2211,7 @@ fn rect_comment(page: usize, rect: [f64; 4]) -> Edit { Edit::AddAnnotation(NewAnnotation { page, shape: Shape::Rectangle { rect }, - style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }, + style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]), ..Style::default() }, contents: "Look here".into(), author: "Reviewer".into(), }) diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 0df7d6f30b..5f9297c2ab 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -116,6 +116,8 @@ impl PdfCraftApp { "tool": t.command(), "color": s.color, "opacity": s.opacity, + "fill_opacity": s.fill_opacity, + "text_opacity": s.text_opacity, "width": s.width, "fill": s.fill, })) @@ -356,6 +358,8 @@ impl PdfCraftApp { if let Some(o) = e["opacity"].as_f64() { self.comment_prefs.set_opacity(tool, o); } + // The fill's and text's own opacities, where saved (older settings have none). + self.comment_prefs.set_part_opacity(tool, e["fill_opacity"].as_f64(), e["text_opacity"].as_f64()); if let Some(w) = e["width"].as_f64() { self.comment_prefs.set_width(tool, w); } diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 35ce510ae7..db08b7b918 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -108,8 +108,13 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // The fill's and text's own opacities where they show (else the opacity is the whole + // comment's). With neither a border nor a fill the text is the whole comment: its drawn + // opacity, which Comment Properties shows, is the whole comment's default. + let (fill, text) = parts_shown(p); // Keep invisible defaults out of tools where a zero stroke would make them disappear. - self.comment_prefs.set_opacity(tool, p.opacity); + self.comment_prefs.set_opacity(tool, p.text_opacity.filter(|_| !text).unwrap_or(p.opacity)); + self.comment_prefs.set_part_opacity(tool, p.fill_opacity.filter(|_| fill), p.text_opacity.filter(|_| text)); if let Some(w) = p.width { self.comment_prefs.set_width(tool, w); } @@ -125,6 +130,20 @@ impl PdfCraftApp { } } +/// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's +/// text, border and fill each do once it has a border or a fill; a filled shape's line and fill +/// do; anything else has one opacity for the whole comment. +pub fn parts_shown(p: &CommentProps) -> (bool, bool) { + let fill = p.fill_opacity.is_some() && p.fill.is_some(); + let text = p.text_opacity.is_some() && (fill || has_border(p)); + (fill, text) +} + +/// A text box's, typewriter text's or callout's border is drawn. +fn has_border(p: &CommentProps) -> bool { + p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0) +} + /// The edits that make the comment match the draft (empty when nothing changed). pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); @@ -143,6 +162,15 @@ pub fn edits(d: &PropsDraft) -> Vec { if e.restylable && e.fillable && e.fill != o.fill { out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill }); } + // After the opacity, which is the whole comment's: the parts shown are set again. + let (fill, text) = parts_shown(e); + let moved = |a: Option, b: Option| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6); + if e.restylable + && (fill || text) + && (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity)) + { + out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); + } if e.restylable && let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size) { @@ -231,12 +259,51 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add(egui::DragValue::new(size).range(1.0..=400.0).speed(0.5).suffix(" pt")); ui.end_row(); } - ui.label(tl!("Opacity")); - let mut pct = e.opacity * 100.0; - if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { - e.opacity = pct / 100.0; + // One opacity for the whole comment, or each part's of its own: a text box's + // text, border and fill, a shape's line and fill. The rows stay put; a part + // not drawn (no fill, no border) is greyed. + let percent = |ui: &mut egui::Ui, label: &str, value: &mut f64, enabled: bool| { + let name = ui.add_enabled(enabled, egui::Label::new(label)); + let mut pct = *value * 100.0; + if ui.add_enabled(enabled, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).labelled_by(name.id).changed() { + *value = pct / 100.0; + } + ui.end_row(); + }; + let (fill, text) = parts_shown(e); + let border = has_border(e); + match (e.text_opacity.is_some(), e.fill_opacity.is_some()) { + (true, _) => { + // With neither a border nor a fill, the text is the whole comment: + // shown at the opacity it is drawn at, a change sets the whole + // comment's (which its parts then take). + match e.text_opacity.as_mut() { + Some(t) if text => percent(ui, tl!("Text opacity"), t, true), + drawn => { + let mut shown = drawn.as_deref().copied().unwrap_or(e.opacity); + let before = shown; + percent(ui, tl!("Text opacity"), &mut shown, true); + if shown != before { + e.opacity = shown; + if let Some(t) = drawn { + *t = shown; + } + } + } + } + if text { + percent(ui, tl!("Border opacity"), &mut e.opacity, border); + } else { + let mut whole = e.opacity; + percent(ui, tl!("Border opacity"), &mut whole, false); + } + } + (false, true) => percent(ui, tl!("Line opacity"), &mut e.opacity, true), + (false, false) => percent(ui, tl!("Opacity"), &mut e.opacity, true), + } + if let Some(f) = e.fill_opacity.as_mut() { + percent(ui, tl!("Fill opacity"), f, fill); } - ui.end_row(); let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 879025b01d..8597e07b5a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -904,6 +904,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } + | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 5d945136e7..348d4b906f 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -353,6 +353,15 @@ impl CommentPrefs { } } + /// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %), + /// for new comments; `None`: as the tool's opacity. + pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option, text: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.fill_opacity = fill.filter(|o| o.is_finite() && tool.has_fill()).map(|o| o.clamp(0.0, 1.0)); + s.text_opacity = text.filter(|o| o.is_finite() && matches!(tool, CommentTool::TextBox | CommentTool::Callout)).map(|o| o.clamp(0.1, 1.0)); + } + } + /// The tool's fill (`None`: no fill). pub fn set_fill(&mut self, tool: CommentTool, fill: Option) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 0e0da5caf3..58252c393a 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -327,8 +327,10 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut percent = (style.opacity * 100.0).round(); ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0))); + // The whole comment's: its fill and text take it too. if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() { prefs.set_opacity(tool, percent / 100.0); + prefs.set_part_opacity(tool, None, None); } }); if tool.has_width() { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 674cd90d26..444db23c84 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1360,6 +1360,91 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() { assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0])); } +/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an +/// opacity of their own in Properties; Make Properties Default carries them into the tool, so +/// the next rectangle is drawn with a see-through fill and an opaque line. +#[test] +fn comment_properties_set_each_parts_opacity() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0])); + h.run_steps(2); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap(); + (p.opacity, p.fill_opacity, p.text_opacity) + }; + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + // The sliders (each labelled by its row, which shares the name). + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity"); + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity"); + assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + d.edited.fill_opacity = Some(0.3); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 0), (1.0, Some(0.3), None)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3)); + h.state_mut().set_option("quick", "square").unwrap(); + h.run_steps(2); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default"); + + // A text box with a border and a fill: its text, border and fill. + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + (style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6])); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Note".into(), + author: "Tester".into(), + }))); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + for label in ["Text opacity", "Border opacity", "Fill opacity"] { + h.get_by_role_and_label(egui::accesskit::Role::Slider, label); + } + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let _gpu = crate::gpu(); + h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap(); + } + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2)); + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque"); + // Without its fill and border, the text is the whole text box: its opacity is the comment's. + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.fill, d.edited.width) = (None, Some(0.0)); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value(); + assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half"); + h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4; + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4))); +} + #[test] fn the_panel_filters_and_sorts() { let mut h = harness(|app| { @@ -2247,6 +2332,43 @@ fn properties_dialog_makes_the_strikethrough_colour_default() { assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}"); } +/// A text box with neither a border nor a fill is all text: Make Properties Default (and the +/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn +/// at, the one Comment Properties shows, not the comment's /CA. +#[test] +fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Faint".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box"); + // As the automation's comment_edit sets it. + assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) })); + h.run_steps(2); + let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity; + assert!((opacity(&h) - 1.0).abs() < 1e-6); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); + // The context menu's way, from a tool at 100 % again. + h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0); + h.state_mut().make_comment_default(0, index); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -2988,7 +3110,14 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { for shape in [pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }] { - let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }; + let style = pdfcraft_engine::Style { + color: [1.0, 0.0, 0.0], + opacity: 1.0, + width: 2.0, + fill: Some([1.0, 0.0, 0.0]), + fill_opacity: None, + text_opacity: None, + }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, shape, diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index c439a51943..12989e9e73 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -911,6 +911,28 @@ fn comment_style_settings_clamp_and_refuse_hostile_values() { assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays"); } +/// A tool's fill's and text's own opacities survive a restart, clamped; a tool without a fill or +/// text keeps none, and the quick bar's Opacity (the whole comment's) clears them. +#[test] +fn part_opacity_defaults_persist_and_clamp() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_part_opacity(CommentTool::Rectangle, Some(0.3), None); + a.comment_prefs.set_part_opacity(CommentTool::TextBox, Some(0.2), Some(1.0)); + a.comment_prefs.set_part_opacity(CommentTool::Line, Some(0.5), Some(0.5)); + let mut b = PdfCraftApp::new(); + b.restore(&a.persist()); + let parts = |app: &PdfCraftApp, tool| { + let s = app.comment_prefs.style(tool); + (s.fill_opacity, s.text_opacity) + }; + assert_eq!(parts(&b, CommentTool::Rectangle), (Some(0.3), None)); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(0.2), Some(1.0))); + assert_eq!(parts(&b, CommentTool::Line), (None, None), "a line has no fill or text of its own"); + b.restore(r#"{"comment_styles":[{"tool":"comment.freetext","fill_opacity":7,"text_opacity":-1}]}"#); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(1.0), Some(0.1)), "clamped; the text stays visible"); +} + #[test] fn zoom_keeps_the_point_under_the_cursor_still() { let mut h = harness(|app| { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 0da17c1e67..52dbe0369b 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -149,7 +149,7 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; - let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; add_text_replacement( &mut src, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 69072e259c..cbb1dcab06 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -76,7 +76,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Print dialog with preview | yes | yes | shipped | | Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | | Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | -| Comment properties and properties bar | yes | yes | shipped | +| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped | | Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | ## Revision history @@ -84,6 +84,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | +| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1a3edc4c36..5cb6e1c56a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3965,6 +3965,18 @@ tools = ["comment_edit"] evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"] notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" +[[feature]] +id = "comment.part-opacity" +title = "A comment's text, border or line, and fill each at an opacity of its own" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "comment_list"] +evidence = ["crates/annot/src/tests.rs::parts_are_drawn_at_opacities_of_their_own", "crates/ui-egui/tests/it/comments.rs::comment_properties_set_each_parts_opacity", "crates/automation/tests/it/automation.rs::comment_parts_take_opacities_of_their_own_through_the_tools", "crates/ui-egui/tests/it/ui.rs::part_opacity_defaults_persist_and_clamp"] +notes = "Comment Properties shows an opacity for each part instead of one for the whole comment: a text box, typewriter text or callout its Text, Border and Fill opacity, a rectangle, oval, polygon or cloud its Line and Fill opacity (a part that isn't drawn, such as an unset fill, is greyed); lines, drawings and the rest keep one Opacity. A see-through fill with an opaque line marks an area on a plan without hiding it. Stored as the line's /CA, the fill's /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity (PdfCraft's own), each only where it differs, and drawn with two graphics states in the appearance, so every viewer shows it; an app that redraws the comment from its keys (Acrobat after editing it) uses one opacity again. The quick bar's Opacity and comment_edit `opacity` set the whole comment's; Make Properties Default carries the parts into the tool (saved with the settings). Headless: comment_add / comment_edit `fill_opacity`, `text_opacity`; comment_list reports `opacity`, `fill_opacity`, `text_opacity`. Acrobat has one opacity per comment." + [[feature]] id = "comment.properties-bar" title = "Comment properties bar" From 6427357fcb184ef4da93e78b3dda6d16bd7a0ab5 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Thu, 5 Nov 2026 21:12:04 +0600 Subject: [PATCH 22/59] Forms: date fields name months in the date language, not always English (#5303) (#5464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Forms: date fields name months in the date language, not always English (#5303) `AFDate_FormatEx("dd-mmm-yy")` wrote "18-Oct-26" in an Italian interface, where a localized Acrobat writes "18-ott-26". Form date formats, `util.printd` and `util.scand` now take their month and weekday names from the date language (Preferences > Date format > Language, which follows the interface language until one is picked), and still read English names typed in. The names are process-wide, so their test runs in its own binary. Co-Authored-By: Claude Opus 5.5 * Forms: dates read back in every date language (#5303) Trying every date language showed what a field wrote not always reading back: names holding digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs (Thai, Hindi, Telugu) fell apart word by word, and Italian, Spanish, Romanian and Albanian share some short weekday and month names ("mar"). The language's names are now found whole in the text first, the longest at each place, and read as their English names; the format's order of `mmm` and `ddd` settles a shared name. Both AFDate parsing and util.scand do this, on text up to 1 KB. A test reads back every language, each name format, and hostile text. Co-Authored-By: Claude Opus 5.5 * Forms: date names no field font can draw are drawn in English (#5303) With the names in the date language, a Russian dd-mmm-yy field drawn in Helvetica showed "18-????.-26": no /DR font held "окт." and the WinAnsi stand-in drew each letter as ?. The same held for Japanese, Chinese, Greek, Arabic, Thai and others, and for Latin names outside WinAnsi (Czech "říj"). Before, the names were English and always drew. An AFDate/AFTime field now formats its value again with English names for a widget whose fonts can't draw the localized text; a custom Format script is run again with util.printd writing English names for such a widget, and its result kept only when it draws whole. A field whose font holds the names keeps them, and scripts otherwise still print the date language. Co-Authored-By: Claude Opus 5.5 * Forms: an English weekday before the month keeps the month (#5303) With Italian dates, "Tue 17 Mar 2026" in a "ddd d mmm yyyy" field read as no date: the language's names were found in the text, but not English ones, so "Tue" left the format's weekday unfilled and "Mar", which Italian shares between Tuesday and March, was taken as the weekday. Both AFDate parsing and util.scand lost the month, and Spanish, Romanian and Albanian ("mar") did the same. English names are now found too and fill their field of the format as localized ones do. A test reads English and mixed weekdays before the month in those languages. Co-Authored-By: Claude Opus 5.5 * Forms: an English date run is drawn even when it prints the value (#5303) A Helvetica field storing "18-Oct-26" whose Format script prints it again as dd-mmm-yy still showed "18-???.-26" in Russian. The English run printed "18-Oct-26", but it was dropped for equalling the stored value, which was how a failed run was told apart: a failing script handed back the value unchanged. The English run now says whether it ran (`None` when the script failed or the runner can't write English dates), and what it made is drawn whenever it draws whole. A test reprints the stored value under Russian, and checks that a failing script keeps its text and is reported undrawn. Co-Authored-By: Claude Opus 5.5 * Forms: a short weekday typed without its dot is the weekday (#5303) With French dates, "mar 6 janv 2026" in a "ddd d mmm yyyy" field read as no date through util.scand, and "mar 06/01/2026" in "ddd dd/mm/yyyy" through AFDate too. French writes Tuesday as "mar.", which a name search only found with its dot; since English names are found as well, the English "Mar" (March) was the only name of that length, so the weekday became a month. Romanian "mar" did the same. A short name ending in a dot is now also found typed without it, as the same kind of name, so the format's order of name fields decides between it and an equal name of the other kind. Both copies of the name search change. A test reads French and Romanian weekdays typed without their dot. Co-Authored-By: Claude Opus 5.5 * Forms: a shared name is the month when no weekday is typed (#5303) With Italian dates, "17 mar 2026" in a "ddd d mmm yyyy" field read as no date, nor did "mar 17, 2026" in "dddd, mmmm d, yyyy". AFDate leaves weekdays out of what it reads (they are shown, not typed), but the name search still expected the format's weekday first, so "mar", which Italian shares between Tuesday and March, filled it and left no month. Spanish and Albanian "mar" and Albanian "sht" (Saturday, September) did the same, and the keystroke check refused those dates. Where such a read finds no date, the names are now read again with no weekday expected, so the shared name is the month; a typed weekday still comes first ("mar 17 mar 2026"). util.scand reads its fields in order and needs the weekday, so it is unchanged. A test types those dates with the weekday left out. Co-Authored-By: Claude Opus 5.5 * Forms: Turkish names typed in capitals are read (#5303) With Turkish dates, "1 MAYIS 2026" and "1 EKİM 2026" in a d mmmm yyyy field read as no date through AFDate and util.scand, and "SALI 6 OCAK 2026" through util.scand. Names were compared lower-cased, which turns I into i, not the dotless ı of "Mayıs" and "Salı", and İ into i with a combining dot above, which "Ekim" doesn't have. Names and typed text are now folded alike, the capital I and the dotted and dotless i all as i and the combining dot dropped, one piece for each char so positions in the text stay aligned: in both copies of the name search, the AFDate month reader and util.scand's name matcher. English reads as before. A test types Turkish names in capitals and English ones as a check. Co-Authored-By: Claude Opus 5.5 * Forms: Greek names typed in capitals are read again (#5303) Folding names char by char turned a capital Σ into σ, so "ΙΑΝΟΥΆΡΙΟΣ" no longer matched "Ιανουάριος", whose last letter is the final ς. The fold now makes ς a σ on both sides, in the forms and JS parsers alike. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/engine/src/dates.rs | 12 + crates/engine/src/js.rs | 31 +- crates/engine/tests/form_date_language.rs | 453 ++++++++++++++++++++++ crates/forms/src/af.rs | 5 +- crates/forms/src/af/format.rs | 202 +++++++++- crates/forms/src/appearance.rs | 19 +- crates/forms/src/scripting.rs | 22 ++ crates/forms/src/values.rs | 17 +- crates/js/src/format.rs | 195 +++++++++- crates/js/src/lib.rs | 1 + crates/js/src/run.rs | 4 +- crates/ui-egui/src/app.rs | 3 + 12 files changed, 932 insertions(+), 32 deletions(-) create mode 100644 crates/engine/tests/form_date_language.rs diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c9382c1b02..5ede67c811 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> { DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim())) } +/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday +/// names in date language `code` from now on, as a localized Acrobat writes its own language's: +/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English. +pub fn use_form_date_language(code: &str) { + let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]); + let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short }; + if pdfcraft_forms::af::date_names() != names { + pdfcraft_forms::af::set_date_names(names); + pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short); + } +} + /// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is /// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`, /// Japanese, Chinese) would become `?` in the file. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d1ee5c15b0..cc78785815 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -199,6 +199,24 @@ impl Scripts for JsRunner { /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { + rc: true, + value: value.to_string(), + changes: Vec::new(), + message: None, + }) + } + + fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { + pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) + } +} + +impl JsRunner { + /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` + /// when the script failed. + fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { let states: Vec<_> = fields .iter() .map(|f| { @@ -212,17 +230,18 @@ impl Scripts for JsRunner { let states = std::sync::Arc::new(states); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); - self.output.absorb(&o); - if o.error.is_some() { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; + if log { + self.output.absorb(&o); } - ScriptResult { + if o.error.is_some() { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), changes: changes(&states, &o.changed), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - } + }) } } diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs new file mode 100644 index 0000000000..1d4293e12c --- /dev/null +++ b/crates/engine/tests/form_date_language.rs @@ -0,0 +1,453 @@ +//! Form date fields name months in the app's date language (#5303). Its own test binary: the +//! names are process-wide, so other tests must not see them change, and its tests take turns. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_engine::dates::use_form_date_language; +use pdfcraft_engine::js::JsRunner; +use pdfcraft_forms::FieldValue; +use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date}; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// Held by each test while it changes the process-wide date names, which start as English (a +/// failed test may have left others). +fn names_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + use_form_date_language("en"); + lock +} + +/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. +fn printd(fmt: &str, typed: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +#[test] +fn form_dates_follow_the_date_language() { + let _names = names_lock(); + let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 }; + let dd_mmm_yy = Format::Date("dd-mmm-yy".into()); + // English until the app says otherwise. + assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October"); + use_form_date_language("it"); + // AFDate_FormatEx("dd-mmm-yy"), what the issue's form used. + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26"); + assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom"); + // What it writes reads back, and so does English typing. + assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18)); + assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18)); + assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18)); + assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok()); + // util.printd writes Italian names; util.scand reads them. + assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too"); + assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026"); + // German short names end in a dot; they are written and read whole. + use_form_date_language("de"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26"); + assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18)); + // An unknown language is English again. + use_form_date_language("xx"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26"); + every_date_language_reads_back_what_it_writes(); + // Hostile typing never panics and stays quick: long, odd and mixed-script text. + use_form_date_language("th"); + let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"]; + for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) { + let started = std::time::Instant::now(); + let _ = parse_date(&t, "dd-mmm-yy"); + let _ = keystroke(&dd_mmm_yy, "date", &t); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed()); + } + use_form_date_language("en"); +} + +/// An English weekday typed before the month fills the format's weekday, so a month name that +/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is +/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303). +#[test] +fn an_english_weekday_before_a_shared_month_name_reads_as_english() { + let _names = names_lock(); + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; + let mut bad = Vec::new(); + for language in ["it", "es", "ro", "sq"] { + use_form_date_language(language); + let ddd_d_mmm = "ddd d mmm yyyy"; + let typed = [ + // What the language writes, then English, then English and localized mixed. + (ddd_d_mmm, format_date(mar17, ddd_d_mmm)), + (ddd_d_mmm, "Tue 17 Mar 2026".into()), + (ddd_d_mmm, "Tuesday 17 Mar 2026".into()), + (ddd_d_mmm, "tue 17 mar 2026".into()), + (ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))), + (ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))), + ("mmm d yyyy ddd", "Mar 17 2026 Tue".into()), + ("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()), + ]; + for (fmt, text) in typed { + if parse_date(&text, fmt) != Some(mar17) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt))); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != "2026-03-17" { + bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error)); + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Every date language: what a date field writes reads back as the same date, for each date +/// letter that writes a name, through the form path and through `util.scand`. Called from the +/// one test, which owns the process-wide names. +fn every_date_language_reads_back_what_it_writes() { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mut bad = Vec::new(); + for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { + use_form_date_language(l.code); + for m in 1..=12u32 { + let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 }; + for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] { + let text = format_date(dt, fmt); + // A short month ending in a number ("Thg 1") glued to the day is ambiguous + // ("Thg 118": January 18 or November 8): nothing can read it back. + if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) { + continue; + } + if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) { + bad.push(format!("{} forms {fmt}: {text:?}", l.code)); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != format!("2026-{m:02}-18") { + bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error)); + } + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::>().join("\n")); +} + +/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with +/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic. +fn form(fields: &[(&str, &str, &str)]) -> Document { + let n = fields.len(); + let first = 6; + let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::>().join(" "); + 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 [{}] >>", refs(first)), + format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + for (i, (name, font, js)) in fields.iter().enumerate() { + let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let y = 700 - 30 * i; + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>", + y + 20 + )); + } + objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into()); + let mut out = b"%PDF-1.7 +" + .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 +{o} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{xref} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + Document::open(Arc::new(out)).expect("opens") +} + +/// The content of field `name`'s normal appearance. +fn appearance(doc: &Document, name: &str) -> Vec { + let all = pdfcraft_forms::fields(doc); + let f = all.iter().find(|f| f.name == name).expect("field"); + let widget = doc.get(f.widgets[0].obj); + let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N"); + match &*doc.get(normal) { + Object::Stream(s) => s.decoded().expect("decodes"), + other => panic!("not a stream: {other:?}"), + } +} + +/// What a Unicode-CMap (UCS-2) font is given for `text`. +fn ucs2(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_be_bytes).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner. +fn fill(doc: &mut Document, name: &str) { + fill_with(doc, name, "18-10-26"); +} + +/// Fills `form`'s field `name` with `value` through the JavaScript runner. +fn fill_with(doc: &mut Document, name: &str, value: &str) { + let mut js = JsRunner::new(doc, "form.pdf"); + pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills"); +} + +/// Month names in the date language that no font of the field can draw are drawn in English, +/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303). +#[test] +fn date_names_no_field_font_can_draw_are_drawn_in_english() { + let _names = names_lock(); + let date = "AFDate_FormatEx(\"dd-mmm-yy\");"; + let fields = [("helv", "Helv", date), ("song", "Song", date)]; + for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] { + use_form_date_language(language); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}"); + let song = appearance(&doc, "song"); + assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}"); + } + // Names Helvetica holds are kept: Italian "ott". + use_form_date_language("it"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj")); + use_form_date_language("en"); +} + +/// A Format script's `util.printd` is run again with English names for a field whose fonts +/// can't draw the date language's, and kept where they can (#5303). +#[test] +fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() { + let _names = names_lock(); + let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";"; + let fields = [("helv", "Helv", script), ("song", "Song", script)]; + use_form_date_language("ru"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !"))); + // A script whose text has no English either keeps it, reported as undrawn. + let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")]; + let mut doc = form(&greeting); + fill(&mut doc, "hi"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]); + // The English run is the appearance's alone: scripts go on printing the date language. + assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26"); + use_form_date_language("en"); +} + +/// A Format script's English run is kept when it ran, even where it prints the stored value +/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed +/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303). +#[test] +fn an_english_run_that_prints_the_stored_value_is_drawn() { + let _names = names_lock(); + let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));"; + use_form_date_language("ru"); + let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]); + fill_with(&mut doc, "helv", "18-Oct-26"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + fill_with(&mut doc, "fails", "18-окт.-26"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]); + use_form_date_language("en"); +} + +/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. +fn scanned(fmt: &str, text: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where +/// the format has one, not the English "Mar" (March) that is the only other name of its length: +/// "mar 6 janv 2026" read as no date in French and Romanian (#5303). +#[test] +fn a_short_weekday_typed_without_its_dot_is_the_weekday() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"), + ("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"), + ("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"), + ("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"), + ("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let got = scanned(fmt, text); + if got != want { + bad.push(format!("{language} scand {fmt}: {text:?} -> {got}")); + } + let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d)); + if read.as_deref() != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the +/// date language shares between a weekday and a month be the month: Italian, Spanish and +/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the +/// weekday, leaving no month (#5303). Typed weekdays still go first. +#[test] +fn a_shared_name_is_the_month_when_the_weekday_is_left_out() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)), + ("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)), + ("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)), + ("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + if keystroke(&Format::Date(fmt.into()), "date", text).is_err() { + bad.push(format!("{language} keystroke {fmt}: {text:?} refused")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis" +/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched. +/// Dotted, dotless and capital i are one letter when names are read (#5303). +#[test] +fn turkish_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("tr"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)), + ("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)), + ("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)), + ("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)), + ("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)), + ("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + // English is read as before, capitals and all. + use_form_date_language("en"); + for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] { + if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" { + bad.push(format!("en {fmt}: {text:?}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the +/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded. +#[test] +fn greek_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("el"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)), + ("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)), + ("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 86c7971c31..f5167374d1 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -20,7 +20,10 @@ mod script; #[cfg(test)] use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; -pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +pub use format::{ + DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in, + js_number, keystroke, make_number, parse_date, set_date_names, validate, +}; use script::{Arg, call}; pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs index df4720bec7..37d2163cb0 100644 --- a/crates/forms/src/af/format.rs +++ b/crates/forms/src/af/format.rs @@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as +/// a localized Acrobat writes them in its own language (#5303). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateNames { + pub months: [&'static str; 12], + pub months_short: [&'static str; 12], + pub days: [&'static str; 7], + pub days_short: [&'static str; 7], +} + +/// English, the names until the app sets others. +pub const ENGLISH_DATE_NAMES: DateNames = DateNames { + months: MONTHS, + months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + days: DAYS, + days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], +}; + +static DATE_NAMES: std::sync::RwLock = std::sync::RwLock::new(ENGLISH_DATE_NAMES); + +/// Sets the names date formats write from now on (the app's date language); typed dates are read +/// in them and in English. +pub fn set_date_names(names: DateNames) { + *DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names; +} + +/// The names date formats write. +pub fn date_names() -> DateNames { + *DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// `text` with the month and weekday names of `lists` (in the order months, short months, +/// weekdays, short weekdays) written as their English names, so a parser that reads English +/// reads them. The longest name wins at each place, case ignored, and only a whole name: one +/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold +/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a +/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian +/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are +/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a +/// localized one does. +fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String { + const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS]; + // No date is this long: hostile text is left as it is rather than scanned name by name. + if text.len() > MAX_DATE_TEXT_BYTES { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short]; + // Each name as folded chars, whether it is a month, and its English name. + let mut names: Vec<(Vec, bool, &str)> = Vec::new(); + for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() { + for (name, english) in list.iter().zip(en.iter()) { + // A short name ending in a dot ("mar.") is also read typed without it, as the same + // kind of name, so that where it equals another ("Mar", March) the format decides. + let bare = name.trim_end_matches('.'); + for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) { + let n: Vec = name.chars().map(fold).collect(); + if !n.is_empty() && n.len() <= MAX_NAME_CHARS { + names.push((n, k % 4 < 2, english)); + } + } + } + } + names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len())); + // The format's name fields in order: true for a month, false for a weekday. + let f: Vec = fmt.chars().collect(); + let mut expected = Vec::new(); + let mut j = 0; + while let Some(&c) = f.get(j) { + let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1); + if run >= 3 && (c == 'm' || c == 'd') { + expected.push(c == 'm'); + } + j = j.saturating_add(run); + } + let mut slot = 0; + // A name's first letter may not continue a word; its last may not run on into a letter, nor + // (a digit) into a number. + let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic()); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let fits = |n: &Vec| { + let end = i.saturating_add(n.len()); + let first = n.first().and_then(|e| e.chars().next()); + let last = n.last().and_then(|e| e.chars().next()); + chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned())) + && !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p)))) + && !match last { + Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit), + Some(l) if l.is_alphabetic() => letter(chars.get(end)), + _ => false, + } + }; + let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len()); + let hit = longest.and_then(|len| { + let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n)); + let want = expected.get(slot).copied(); + let all: Vec<_> = same.by_ref().collect(); + all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied() + }); + match hit { + Some((n, month, english)) => { + if expected.get(slot) == Some(month) { + slot += 1; + } + // Apart from what touches it ("10月18"), but no space added at either end. + if out.ends_with(char::is_alphanumeric) { + out.push(' '); + } + out.push_str(english); + i = i.saturating_add(n.len()); + if chars.get(i).is_some_and(|c| c.is_alphanumeric()) { + out.push(' '); + } + } + None => { + out.push(c); + i += 1; + } + } + } + out +} + +/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless +/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are +/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i, +/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which +/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so +/// positions in the text stay aligned. +fn fold(c: char) -> String { + match c { + 'I' | 'ı' | 'İ' => "i".to_string(), + 'ς' => "σ".to_string(), + _ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(), + } +} + +/// `s` [`fold`]ed char by char. +fn folded(s: &str) -> String { + s.chars().map(fold).collect() +} + +/// Typed dates longer than this are not searched for names. +const MAX_DATE_TEXT_BYTES: usize = 1024; + +/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap). +const MAX_NAME_CHARS: usize = 32; + +/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name, +/// or its whole short name, in `names` or in English. +fn month_named(word: &str, names: &DateNames) -> Option { + let w = folded(word); + let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w)); + let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w); + starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1) +} + /// A date and time read from text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DateTime { @@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec { /// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any /// separators, month names, two-digit years). `None` when it isn't a valid date. pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); + let names = date_names(); + let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short]; + // Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a + // name the language shares between a weekday and a month (Italian "mar") is the month, as + // the names are read again with no weekday expected (#5303). + read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| { + let no_weekday = fmt.replace("dddd", " ").replace("ddd", " "); + (no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten() + }) +} + +/// [`parse_date`] of `english`, typed text whose month and weekday names are English. +fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option { + let t = english.trim(); if t.is_empty() { return None; } @@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } if cur.starts_with(|c: char| c.is_ascii_digit()) { nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); + } else if let Some(m) = month_named(cur, names) { + *month = Some(m); } cur.clear(); }; @@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { /// Format a date/time with Acrobat's date format letters. pub fn format_date(dt: DateTime, fmt: &str) -> String { + format_date_in(dt, fmt, &date_names()) +} + +/// [`format_date`] with month and weekday names from `names`. +pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String { let chars: Vec = fmt.chars().collect(); let mut out = String::new(); let mut i = 0; @@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { ('y', _) => { let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + ('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) { + Some((name, _)) if n >= 4 => out.push_str(name), + Some((_, short)) => out.push_str(short), None => { let _ = write!(out, "{}", dt.m); } @@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { let _ = write!(out, "{}", dt.m); } ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + let list = if n >= 4 { &names.days } else { &names.days_short }; + out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default()); } ('d', 2) => { let _ = write!(out, "{:02}", dt.d); @@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option { /// The text shown for a value (the Format event). pub fn format_value(f: &Format, value: &str) -> String { + format_value_in(f, value, &date_names()) +} + +/// [`format_value`] with month and weekday names from `names`. +pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String { if value.is_empty() { return String::new(); } @@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String { ), None => value.to_string(), }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)), Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c9ff86d17a..8403b729d9 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option field_appearance_fit(doc, f, w, &f.value, true).2 } +/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that +/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in. +fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool { + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi); + enc.first_unencodable(text).is_none() +} + /// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -748,13 +758,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] && values.len() == 1 && f.actions.format != crate::af::Format::None { - formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; + let shown = crate::af::format_value(&f.actions.format, &values[0]); + // Month and weekday names in the date language that no font here can draw ("окт." in + // Helvetica) are drawn in English rather than as `?` (#5303). + let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES); + let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_)); + formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }]; &formatted[..] } else { values }; - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let place = placement(mk_rotation(doc, &wd), page_w, page_h); let (width, height) = (place.layout_w, place.layout_h); diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 7dd7bfde6c..31e6812e6b 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -60,6 +60,21 @@ pub struct ScriptResult { pub trait Scripts { /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult; + + /// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`) + /// because no font of the field can draw the date language's (#5303); what it logs was + /// already logged by the first run. `None` when it can't: no English dates here, or the + /// script failed. What it returns is what the script made, even when that is the value. + fn rerun_with_english_dates( + &mut self, + _event: FieldEvent, + _script: &str, + _target: &Field, + _value: &str, + _fields: &[Field], + ) -> Option { + None + } } /// No script engine (or JavaScript turned off): scripts change nothing. @@ -98,6 +113,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy Some(r.value) } +/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date +/// language's; `None` when the field has no Format script or the English run didn't happen. +pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { + let js = f.actions.scripts.format.as_ref()?; + scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value) +} + /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags). `except` is the event's own field, whose value /// the event sets. diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index cf191c2240..9d670e62db 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -426,9 +426,24 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc }; let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); let mut undrawn = false; + // The Format script again with English month and weekday names, once, for widgets whose + // fonts can't draw the date language's (#5303). + let mut english: Option> = None; for w in &f.widgets { let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + Some(s) => { + let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false); + // Taken when the English run happened and draws whole, even where it is the + // stored value ("18-Oct-26" printed again from itself). + let again = match (&drawn.1, values) { + (Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(), + _ => None, + }; + match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) { + Some(english) if english.1.is_none() => english, + _ => drawn, + } + } None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 7f207b1916..26e2fad989 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/// Month and weekday names (weekdays from Sunday): full, then short. +type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]); + +const ENGLISH: Names = ( + MONTHS, + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + DAYS, + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], +); + +static NAMES: std::sync::RwLock = std::sync::RwLock::new(ENGLISH); + +/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd` +/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303). +/// `util.scand` reads them as well as English. +pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) { + *NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short); +} + +/// `text` with the month and weekday names of `lists` (in the order months, short months, +/// weekdays, short weekdays) written as their English names, so a parser that reads English +/// reads them. The longest name wins at each place, case ignored, and only a whole name: one +/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold +/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a +/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian +/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are +/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a +/// localized one does. +fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String { + const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS]; + // No date is this long: hostile text is left as it is rather than scanned name by name. + if text.len() > MAX_DATE_TEXT_BYTES { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3]; + // Each name as folded chars, whether it is a month, and its English name. + let mut names: Vec<(Vec, bool, &str)> = Vec::new(); + for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() { + for (name, english) in list.iter().zip(en.iter()) { + // A short name ending in a dot ("mar.") is also read typed without it, as the same + // kind of name, so that where it equals another ("Mar", March) the format decides. + let bare = name.trim_end_matches('.'); + for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) { + let n: Vec = name.chars().map(fold).collect(); + if !n.is_empty() && n.len() <= MAX_NAME_CHARS { + names.push((n, k % 4 < 2, english)); + } + } + } + } + names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len())); + // The format's name fields in order: true for a month, false for a weekday. + let f: Vec = fmt.chars().collect(); + let mut expected = Vec::new(); + let mut j = 0; + while let Some(&c) = f.get(j) { + let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1); + if run >= 3 && (c == 'm' || c == 'd') { + expected.push(c == 'm'); + } + j = j.saturating_add(run); + } + let mut slot = 0; + // A name's first letter may not continue a word; its last may not run on into a letter, nor + // (a digit) into a number. + let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic()); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let fits = |n: &Vec| { + let end = i.saturating_add(n.len()); + let first = n.first().and_then(|e| e.chars().next()); + let last = n.last().and_then(|e| e.chars().next()); + chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned())) + && !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p)))) + && !match last { + Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit), + Some(l) if l.is_alphabetic() => letter(chars.get(end)), + _ => false, + } + }; + let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len()); + let hit = longest.and_then(|len| { + let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n)); + let want = expected.get(slot).copied(); + let all: Vec<_> = same.by_ref().collect(); + all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied() + }); + match hit { + Some((n, month, english)) => { + if expected.get(slot) == Some(month) { + slot += 1; + } + // Apart from what touches it ("10月18"), but no space added at either end. + if out.ends_with(char::is_alphanumeric) { + out.push(' '); + } + out.push_str(english); + i = i.saturating_add(n.len()); + if chars.get(i).is_some_and(|c| c.is_alphanumeric()) { + out.push(' '); + } + } + None => { + out.push(c); + i += 1; + } + } + } + out +} + +/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless +/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are +/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i, +/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which +/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so +/// positions in the text stay aligned. +fn fold(c: char) -> String { + match c { + 'I' | 'ı' | 'İ' => "i".to_string(), + 'ς' => "σ".to_string(), + _ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(), + } +} + +/// `s` [`fold`]ed char by char. +fn folded(s: &str) -> String { + s.chars().map(fold).collect() +} + +/// Typed dates longer than this are not searched for names. +const MAX_DATE_TEXT_BYTES: usize = 1024; + +/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap). +const MAX_NAME_CHARS: usize = 32; + +fn names() -> Names { + *NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +std::thread_local! { + /// Whether `util.printd` on this thread writes English names, whatever the date language. + static PRINTD_ENGLISH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts +/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date +/// language's names, which would come out as `?` (#5303). `util.scand` still reads both. +pub fn with_english_printd(english: bool, run: impl FnOnce() -> R) -> R { + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + PRINTD_ENGLISH.with(|e| e.set(self.0)); + } + } + let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english))); + run() +} + +/// Whether [`with_english_printd`] is on for this thread. +pub(crate) fn printd_english() -> bool { + PRINTD_ENGLISH.with(std::cell::Cell::get) +} + +/// The names `util.printd` writes. +fn printd_names() -> Names { + if printd_english() { ENGLISH } else { names() } +} + /// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, /// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu let (y, mo, day, wd, h, mi, sec) = (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); let mut out = String::new(); + let (months, months_short, days, days_short) = printd_names(); let b = fmt.as_bytes(); let mut i = 0; while i < b.len() { @@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu } } b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), + 3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), n => push_num(&mut out, mo + 1, n), }, b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), + 3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), n => push_num(&mut out, day, n), }, b'H' => push_num(&mut out, h, run), @@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { p += 1; } (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { - v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + let (months, months_short, ..) = names(); + v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1)); p += 1; } (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { - named(&DAYS, l)?; + let (.., days, days_short) = names(); + named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?; p += 1; } (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { @@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { /// The index of the name `word` is, in full or as its first three letters, in any case. fn named(names: &[&str], word: &str) -> Option { - let word = word.to_lowercase(); + let word = folded(word); let i = names.iter().position(|n| { - let n = n.to_lowercase(); - word == n || n.get(..3) == Some(word.as_str()) + let n = folded(n); + // A short name may end in a dot ("Okt."); three letters may be cut from any name. + word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::() == word })?; u32::try_from(i).ok() } @@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul return Ok(JsValue::null()); }; let fmt = text(&arg(args, 0), ctx)?; + let (months, months_short, days, days_short) = names(); + let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]); let date = JsDate::new(ctx); let this_year = date.get_full_year(ctx)?.to_number(ctx)?; let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 93f57e7528..0bf4032efd 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -44,6 +44,7 @@ mod run; #[cfg(test)] pub(crate) use format::PRINTD_SCANNED; use format::{printd, printf, printx, scand}; +pub use format::{set_date_names, with_english_printd}; pub(crate) use globals::menu_item; use globals::platform; use host::{arg, as_number, error, function, s, text}; diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 07c0e984bb..ab7e32ed53 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 59026c26aa..fd8d4a8096 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -452,6 +452,9 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + // Form date fields name months in the date language (Preferences ▸ Date format), which + // follows the interface language until one is picked (#5303). + pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code())); #[cfg(not(target_arch = "wasm32"))] { if let Some(worker) = &self.open_worker { From 42bc4f117d849b58cf01f5c65f0aadfa1d4f24cf Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:41:45 +0100 Subject: [PATCH 23/59] M5: copy and paste comments between documents, select several, finish connected lines from the menu (#3484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments copy and paste, within and between documents. ⌘C with comments selected (and no text selected) copies them, each with its replies, as XFDF: into the app's clipboard (every tab and window) and as text onto the system clipboard. ⌘V, Edit ▸ Paste comments or the page's context menu pastes them onto the current page at their own positions, as new comments under names of their own, so nothing is replaced and pasting twice gives two sets; the copies are selected. XFDF with comments from the system clipboard pastes too, so comments travel between processes. pdfcraft_xfdf::export_xfdf_comments writes a selection with its replies; import_comments takes a target page and a name prefix and imports copies (the usual import still replaces same names). Engine: Edit::PasteComments { xfdf, page } (one undo step, comment permission) and Session::export_comments. Tools: comments_copy (read-only) and comments_paste. Several comments select at once: ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds one or takes it out, on the page and in the Comments panel; the one clicked last has the handles. Delete removes them all as one step, a drag moves them together, the context menu copies them. Connected lines, polygons and clouds finish from the right-click menu's Complete (with Undo last point and Cancel drawing), as other editors do, besides Enter and a double-click. Co-authored-by: Claude Fable 5.1 Co-authored-by: FredApps --- ROADMAP.md | 2 + crates/annot/src/appearance.rs | 45 +- crates/annot/src/types.rs | 22 + crates/automation/src/comments.rs | 43 ++ crates/automation/src/dispatch.rs | 2 + crates/automation/src/tools.rs | 19 + crates/automation/tests/it/automation.rs | 46 ++ crates/engine/src/commands.rs | 4 + crates/engine/src/edit.rs | 9 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 9 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/session/files.rs | 13 + crates/engine/src/tests.rs | 36 ++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/clipboard.rs | 96 +++- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/canvas.rs | 89 ++- crates/ui-egui/src/canvas/page_input.rs | 7 +- crates/ui-egui/src/commands.rs | 11 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 125 ++++- crates/ui-egui/src/editing.rs | 31 +- crates/ui-egui/src/hotbar.rs | 15 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/comments_panel.rs | 35 +- crates/ui-egui/src/state.rs | 8 + crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 185 +++++-- crates/ui-egui/src/tools/comments/menu.rs | 85 ++- crates/ui-egui/src/tools/comments/paint.rs | 89 ++- crates/ui-egui/src/tools/comments/state.rs | 71 ++- crates/ui-egui/src/tools/comments/tests.rs | 28 +- crates/ui-egui/src/tools/fill_sign.rs | 2 +- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/commands.rs | 16 + crates/ui-egui/tests/it/comments.rs | 452 ++++++++++++++++ crates/xfdf/src/lib.rs | 2 +- crates/xfdf/src/tests.rs | 321 +++++++++++ crates/xfdf/src/xfdf.rs | 568 +++++++++++++++++--- docs/ui-parity.md | 1 + parity/acrobat-features.toml | 34 +- 44 files changed, 2317 insertions(+), 227 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf8cacb73e..3ce7c13542 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. +- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged. +- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged. - **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index dbecae5c6d..c2f7f5e53d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -876,46 +876,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - let [x0, y0, x1, y1] = draw; let (w, h) = (x1 - x0, y1 - y0); let lw = w.min(h) * 0.12; + // The strokes come from `FillMark::strokes`, which a copy's outline draws too. + let path = |mark: crate::FillMark| { + let ops: Vec = mark + .strokes(draw) + .iter() + .flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" }))) + .collect(); + format!("{} S\n", ops.join(" ")) + }; match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), + b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))), + b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))), b"PCDot" => { let r = w.min(h) * 0.3; c.push_str(&rg(col)); c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); c.push_str("f\n"); } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), + b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))), other => { // Only stamps PdfCraft made: others keep their own artwork. if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 2831613091..935eff8759 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -124,6 +124,28 @@ impl FillMark { let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] } + + /// The mark whose `/Name` this is. + pub fn from_name(n: &[u8]) -> Option { + [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n) + } + + /// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes, + /// and what a copy of it shows while it is being placed. The dot is a filled circle (radius + /// 0.3 × the shorter side, centred) and has none. + pub fn strokes(self, rect: [f64; 4]) -> Vec> { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + match self { + FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]], + FillMark::Cross => vec![ + vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]], + vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]], + ], + FillMark::Dot => Vec::new(), + FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]], + } + } } /// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 2ca9fbd540..6adc95b2ab 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -746,6 +746,49 @@ impl Automation { let (page, index) = self.comment_target(a)?; self.apply(a, Edit::DeleteAnnotation { page, index }) } + + /// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs). + pub(crate) fn comments_copy(&self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let all = self.comments(a)?; + let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into()); + let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?; + let mut which = Vec::with_capacity(list.len()); + for item in list { + let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?))); + let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) }; + let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?); + if !all.iter().any(|c| c.page == page && c.index == index) { + return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1))); + } + which.push((page, index)); + } + let xfdf = self.session.export_comments(id, &which).map_err(failed)?; + Ok(json!({ "xfdf": xfdf, "comments": which.len() })) + } + + /// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments. + pub(crate) fn comments_paste(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let xfdf = a.str("xfdf")?.to_owned(); + if !pdfcraft_engine::is_xfdf_comments(&xfdf) { + return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with )".into())); + } + let offset = match a.nums::<2>("at")? { + Some([x, y]) => { + let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?; + let [cx, cy] = to_user(&info, x, y); + let b = + pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?; + Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0]) + } + None => None, + }; + let before = self.comments(a)?.len(); + let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?; + out["comments"] = json!(self.comments(a)?.len().saturating_sub(before)); + Ok(out) + } } impl Automation { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 45df846ebc..71a979b637 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -867,6 +867,8 @@ impl Automation { "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, "comment_delete" => self.comment_delete(&a)?, + "comments_copy" => self.comments_copy(&a)?, + "comments_paste" => self.comments_paste(&a)?, other => return Err(ToolError::UnknownTool(other.into())), }; Ok(vec![Content::Json(out)]) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7b55a80094..b3b149fbc2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1013,6 +1013,25 @@ pub fn tools() -> Vec { t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t( + "comments_copy", + "Copy comments", + "The XFDF of these comments, each with its replies: what Copy puts on the clipboard. Paste it into this or another document with comments_paste. `comments`: [[page, index], …], 1-based as comment_list reports them.", + ) + .ro() + .with(schema( + json!({ + "doc": doc(), + "comments": { "type": "array", "items": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 }, "minItems": 1 } + }), + &["doc", "comments"], + )), + t( + "comments_paste", + "Paste comments", + "Add the comments of an XFDF text (from comments_copy) to `page` as new comments: copies under names of their own, so nothing in the document is replaced and pasting twice gives two sets; replies keep their copied parent. `at` [x, y] (points, origin at the top-left of the displayed page, y down, as comment_add takes them) puts the centre of the copies' bounding box there; without it they keep their positions. Undoable.", + ) + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "xfdf": { "type": "string" }, "at": point() }), &["doc", "page", "xfdf"])), t( "doc_protect", "Protect with passwords", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 42a88a3633..f0426fa3fc 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10975,3 +10975,49 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { assert!(!out.exists()); assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); } + +/// Copy and paste comments between documents through the tools: the copies carry their replies, +/// land on the asked-for page under their own names (a second paste gives a second set), and a +/// plain text is refused. +#[test] +fn comments_copy_and_paste_between_documents_through_tools() { + let dir = workdir("comments-clipboard"); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" })); + ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "rectangle", "rect": [20, 20, 80, 60], "contents": "Box" })); + ok(&mut a, "comment_reply", json!({ "doc": src, "page": 2, "index": 1, "text": "Done", "author": "Bob" })); + // Positions as comment_list reports them (a note's pop-up takes a position of its own). + let listed = ok(&mut a, "comment_list", json!({ "doc": src, "page": 2 })); + let pairs: Vec = listed["comments"].as_array().unwrap().iter().map(|c| json!([c["page"], c["index"]])).collect(); + assert_eq!(pairs.len(), 2, "{listed}"); + let copied = ok(&mut a, "comments_copy", json!({ "doc": src, "comments": pairs })); + assert_eq!(copied["comments"], 2); + let xfdf = copied["xfdf"].as_str().unwrap().to_owned(); + assert!(xfdf.contains("inreplyto=") && xfdf.contains("> = + placed["comments"].as_array().unwrap().iter().map(|c| c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()).collect(); + let (x0, y0, x1, y1) = + rects.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |(a, b, c, d), r| (a.min(r[0]), b.min(r[1]), c.max(r[2]), d.max(r[3]))); + assert!(((x0 + x1) / 2.0 - 100.0).abs() < 1.0 && ((y0 + y1) / 2.0 - 150.0).abs() < 1.0, "centred at [100, 150]: {rects:?}"); + assert!(matches!(a.call("comments_paste", &json!({ "doc": dst, "page": 1, "xfdf": "hello" })), Err(ToolError::InvalidArgs(_)))); + assert!(a.call("comments_copy", &json!({ "doc": src, "comments": [[9, 9]] })).is_err(), "no such comment"); + assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index bd229656a6..9301201661 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[ // Alt-drag for moving windows (several Linux window managers). // Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership. c("edit.copy", "Copy", EDIT, None, Copying, "copy"), + // Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them, + // ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too. + c("comment.copy", "Copy comments", EDIT, None, Document, "copy"), + c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"), c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index c2cb1b3fd2..e398604766 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -570,6 +570,15 @@ pub enum Edit { name: String, bytes: Arc>, }, + /// Paste copied comments (XFDF from [`Session::export_comments`]) onto `page` as new + /// comments: copies under names of their own, so nothing is replaced and pasting twice + /// gives two sets; replies keep their copied parent. + PasteComments { + xfdf: String, + page: usize, + /// Where the copies go: moved by (dx, dy) in user space, or at their own positions. + offset: Option<[f64; 2]>, + }, /// Remove Hidden Information: the chosen categories. RemoveHidden { which: Vec, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index e037ebc620..0c7046cdbb 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -71,6 +71,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add stamp", "Add 3D model", "Delete comment", + "Paste comments", "Edit comment", "Reply", "Set status None", @@ -211,6 +212,7 @@ impl Edit { Edit::AddCustomStamp { .. } => Label::of("Add stamp"), Edit::AddModel3d { .. } => Label::of("Add 3D model"), Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::PasteComments { .. } => Label::of("Paste comments"), Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), Edit::ReplyToAnnotation { .. } => Label::of("Reply"), // The state's name is part of the wording: a language may need to agree it with the rest. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 39c9ace3e0..27eee7e428 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::AddMeasurement(_) | Edit::AddAnnotation(_) + | Edit::PasteComments { .. } | Edit::AddCustomStamp { .. } | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } @@ -496,6 +497,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddAnnotation(a) => { pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; } + Edit::PasteComments { xfdf, page, offset } => { + // Names from the edit's own id generator: unique to this paste within the document. + let prefix = cx.meta().id; + let report = pdfcraft_xfdf::import_comments(doc, xfdf, Some(*page), Some(&prefix), *offset)?; + if report.comments == 0 { + return Err(EditError::Invalid("the clipboard holds no comments".into())); + } + } Edit::AddCustomStamp { page, rect, name, file, author } => { let src = mark_source(doc, file)?; let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 12c31dd7a6..643f1d66a3 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -53,6 +53,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) | Edit::AddAnnotation(_) + | Edit::PasteComments { .. } | Edit::AddCustomStamp { .. } | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2daa1da400..ca25500ec0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -129,6 +129,7 @@ pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; +pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments}; use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 553ddd6aa3..06e6acbfd1 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -114,6 +114,19 @@ impl Session { .map_err(EditError::Write) } + /// XFDF with these comments (0-based page, position in the page's `/Annots`) and their + /// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in. + pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write) + } + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d9e5e971d..a20314c2e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10185,6 +10185,42 @@ fn need_appearances_buttons_are_displayed_from_their_captions() { } } +/// Copy and paste: the copies of a comment and its reply land on the asked-for page of another +/// document under names of their own (nothing replaced; a second paste gives a second set), +/// undo takes a paste away as one step, and a clipboard without comments is refused. +#[test] +fn pasting_copied_comments_adds_copies_that_undo_as_one_step() { + let (mut s, id) = session_with(2); + s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap(); + let xfdf = s.export_comments(id, &[(1, 0)]).unwrap(); + assert!(xfdf.contains(" &'static str { } enum ClipboardPaste { - Text, + Text(String), Image(std::sync::Arc), } @@ -27,7 +27,8 @@ impl PdfCraftApp { false } - /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). + /// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field + /// has focus). With no text selected, ⌘C copies the selected comments, each with its replies. pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; let pastes: Vec<_> = ctx.input(|input| { @@ -35,7 +36,7 @@ impl PdfCraftApp { .events .iter() .filter_map(|event| match event { - egui::Event::Paste(_) => Some(ClipboardPaste::Text), + egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())), egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())), _ => None, }) @@ -52,7 +53,9 @@ impl PdfCraftApp { || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty()); if copy + && (text_selected || !self.copy_selected_comments(i)) && !self.copy_selected_text(i, ctx) && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) && let Some(id) = self.views.get(i).map(|v| v.id) @@ -89,16 +92,21 @@ impl PdfCraftApp { } } - fn receive_pastes(&mut self, view: usize, pastes: Vec, images_allowed: bool) { + /// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so + /// pictures and copied comments land on the page. + fn receive_pastes(&mut self, view: usize, pastes: Vec, on_canvas: bool) { let page = self.views.get(view).map_or(0, |view| view.current); for paste in pastes { match paste { - ClipboardPaste::Text => { + ClipboardPaste::Text(text) => { // Text fields may own the paste; the canvas cache still observes its contents. self.last_copied_image = None; self.last_copied_file = None; + if on_canvas { + self.paste_comment_text(view, text); + } } - ClipboardPaste::Image(image) if images_allowed => { + ClipboardPaste::Image(image) if on_canvas => { self.paste_received_image(page, &image); } ClipboardPaste::Image(_) => {} @@ -106,6 +114,82 @@ impl PdfCraftApp { } } + /// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments + /// come from the system clipboard when it holds them, from the app's own only when it holds + /// nothing legible (a word copied in another app since is that word, not the comments copied + /// before it). + fn paste_comment_text(&mut self, i: usize, text: String) { + if pdfcraft_engine::is_xfdf_comments(&text) { + self.paste_comments(i, Some(text), None); + } else if text.trim().is_empty() && self.comment_clipboard.is_some() { + self.paste_comments(i, None, None); + } + } + + /// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's + /// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False + /// when nothing is selected. + pub fn copy_selected_comments(&mut self, i: usize) -> bool { + let Some(view) = self.views.get(i) else { return false }; + let keys = view.comments.selection(); + if keys.is_empty() { + return false; + } + let id = view.id; + match self.session.export_comments(id, &keys) { + Ok(xfdf) => { + if let Some(ctx) = &self.ctx { + ctx.copy_text(xfdf.clone()); + } + self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() }); + true + } + Err(e) => { + self.notify_error(e); + false + } + } + } + + /// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's + /// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu) + /// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one + /// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original + /// and a bundle from a large page goes where it fits on a small one. The copies are selected + /// once placed (see `apply_edit`). + pub fn paste_comments(&mut self, i: usize, text: Option, at: Option<(usize, [f64; 2])>) -> bool { + let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else { + self.notify_tr("Copy comments first"); + return false; + }; + let Some(view) = self.views.get(i) else { return false }; + let id = view.id; + let Some(doc) = self.session.get(id) else { return false }; + if !doc.allows_annotation() { + self.notify_tr("The document's security settings don't allow this change"); + return false; + } + let count = pdfcraft_engine::comment_count(&xfdf); + let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else { + self.notify_tr("The clipboard holds no comments"); + return false; + }; + match at { + Some((page, [x, y])) => { + let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0]; + self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) }) + } + None => { + let Some(view) = self.views.get_mut(i) else { return false }; + view.comments.clear_selection(); + let outlines = pdfcraft_engine::comment_outlines(&xfdf); + view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines }); + self.notify_tr("Click where the copied comments go; Escape lets them go"); + true + } + } + } + /// Shared permission gate for copying selected page text from menus and keyboard events. /// After Select All of the document (#2639), pages whose text wasn't read for display are read /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index a49617ee85..35a214d9aa 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -393,7 +393,7 @@ impl PdfCraftApp { let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?; let (p, i): (usize, usize) = (p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?); - v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1))); + v.comments.select_only((p.saturating_sub(1), i.saturating_sub(1))); v.comments.reveal = true; } // `--combine-view list`: Combine files as thumbnails (grid) or as the table (list). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96210fb2fd..2207adbf59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -787,6 +787,8 @@ pub(crate) enum PageMenuAction { MarqueeZoom, Copy, Paste(usize, [f64; 2]), + /// Paste the copied comments, centred at this point of the page. + PasteComments(usize, [f64; 2]), AddComment(usize, [f64; 2]), AddBookmark, TakeSnapshot, @@ -808,6 +810,8 @@ pub(crate) struct PageMenuPerms { pub can_copy: bool, pub can_copy_items: bool, pub can_paste: bool, + /// Copied comments wait to be pasted, and comments may be added. + pub can_paste_comments: bool, pub has_annotations: bool, } @@ -849,6 +853,9 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64 ui, A::Paste(page, at), ); + if perms.can_paste_comments { + pick(ui.button(tl!("Paste comments")), ui, A::PasteComments(page, at)); + } ui.separator(); @@ -1156,6 +1163,10 @@ struct PageEnv<'a> { by_line: &'a String, calc_order: bool, can_copy: bool, + /// Copied comments wait to be pasted. + can_paste_comments: bool, + /// Copied comments hang on the pointer (`Gesture::Place`): the click is theirs. + placing: bool, can_crop: bool, can_fill: bool, can_modify: bool, @@ -1212,6 +1223,8 @@ struct PageEnv<'a> { struct PageOut { canvas_action: Option, clicked_link: Option, + comment_notice: Option<&'static str>, + copy_comments: bool, copy_selected: bool, field_menu: Option, field_placed: bool, @@ -1223,6 +1236,7 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, page_action: Option, + paste_comments_at: Option<(usize, [f64; 2])>, primary_down: bool, refused: Option, /// The current page or spread where it is drawn, every page of it: one off screen (the second @@ -1603,6 +1617,7 @@ fn draw_pages( coarse_scroll, calc_order, can_copy, + can_paste_comments, can_modify, content_h, content_w, @@ -1799,6 +1814,7 @@ fn draw_pages( refused, row_on_screen: _, stamp_placed, + .. } = page_out; let mut page_action: Option = None; let painter = ui.painter(); @@ -1960,8 +1976,18 @@ fn draw_pages( crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; } - if view.comments.selected.is_some() { - canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); + // A selected comment, or connected lines being drawn (Complete, Undo last point): the + // comment menu. + let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points()); + if view.comments.selected.is_some() || drawing_points { + canvas_action = comments::context_menu( + ui, + view, + info, + &|page, index| doc.annotation_deletable(page, index), + prefs, + comments::MenuRights { allowed, can_paste: can_paste_comments, can_modify }, + ); return; } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. @@ -2005,6 +2031,7 @@ fn draw_pages( can_copy, can_copy_items, can_paste, + can_paste_comments: allowed && can_paste_comments, has_annotations: !info.annotations.is_empty(), }; default_page_context_menu(ui, page, at, perms, &mut page_action); @@ -2012,9 +2039,15 @@ fn draw_pages( if !resp.context_menu_opened() { ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); } + // Copy comments / Paste comments from the page's menu or a Comments panel card. + let copy_comments = std::mem::take(&mut view.comments.copy_request); + let paste_comments_at = view.comments.paste_request.take(); + let comment_notice = view.comments.notice.take(); *out = PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, copy_selected, field_menu, field_placed, @@ -2026,6 +2059,7 @@ fn draw_pages( open_signature, open_table, page_action, + paste_comments_at, primary_down: ptr.primary_down, refused, row_on_screen, @@ -2808,6 +2842,15 @@ fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> V glide } +/// The tool the page sees this frame, and whether copied comments hang on the pointer. Those +/// take the next click whatever tool is on: the page sees the Select tool until the bundle is +/// placed or let go, and no form field, added content or field being prepared takes the click +/// either. +fn page_tool(view: &DocView, chosen: QuickTool) -> (QuickTool, bool) { + let placing = matches!(view.comments.gesture, Some(comments::Gesture::Place { .. })); + (if placing { QuickTool::Select } else { chosen }, placing) +} + /// Text selection runs for the Select tool and for the markup tools (highlight…). fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { match tool { @@ -3010,7 +3053,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let sharp_held = hold_sharp_renders(ui, view, scale); view.current_scale_tag = tag; let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; + let (tool, placing) = page_tool(view, app.quick_tool); // Text selection runs for the Select tool and for the markup tools (highlight…). let selects_text = tool_selects_text(tool, preparing); let prefs = &app.comment_prefs; @@ -3018,6 +3061,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let units = app.units; let allowed = doc.allows_annotation(); let can_copy = doc.allows_copying(); + let can_paste_comments = app.comment_clipboard.is_some(); let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let generation = doc.edit_generation(); @@ -3030,11 +3074,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // In Preferences ▸ Date format, or why it can't be stamped into the PDF. let date_text = crate::date_text_for_pdf(&app.session); let by_line = app.session.stamp_by_line(&author); - let custom_stamp = match app.quick_tool { + let custom_stamp = match tool { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, }; let mut open_props: Option<(usize, usize)> = None; + let mut recolour: Option = None; // The table being set up in its window, shown on its own document's page. let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); let can_modify = doc.allows_modification(); @@ -3066,6 +3111,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { by_line: &by_line, calc_order, can_copy, + can_paste_comments, + placing, can_crop, can_fill, can_modify, @@ -3123,6 +3170,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, mut copy_selected, field_menu, field_placed, @@ -3134,6 +3183,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, page_action, + paste_comments_at, primary_down, mut refused, row_on_screen, @@ -3218,6 +3268,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.comments_panel_closed = false; } Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), + Some(comments::CanvasAction::Colour(c)) => recolour = Some(c), Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} @@ -3242,6 +3293,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3264,6 +3319,13 @@ struct FrameRequests { open_table: Option<(usize, [f64; 4])>, form_notice: Option, copy_selected: bool, + /// Copy comments / Paste comments from the page's menu or a Comments panel card, and what + /// the comment tools have to say. + copy_comments: bool, + paste_comments_at: Option<(usize, [f64; 2])>, + comment_notice: Option<&'static str>, + /// A colour picked for every selected comment (Colour in the page's menu). + recolour: Option, remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, form: Arc>, generation: u64, @@ -3286,6 +3348,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3297,6 +3363,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if let Some((p, i)) = open_props { app.open_comment_props(p, i); } + if let Some(c) = recolour { + app.colour_selected_comments(c); + } if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } @@ -3392,6 +3461,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, Some(PageMenuAction::Paste(page, at)) => { app.paste_image_at(page, at); } + Some(PageMenuAction::PasteComments(page, at)) => { + app.paste_comments(index, None, Some((page, at))); + } Some(PageMenuAction::RotateCCW) => { app.views[index].rotate_view(false); } @@ -3480,6 +3552,15 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if copy_selected { app.execute("edit.copy"); } + if copy_comments { + app.execute("comment.copy"); + } + if let Some(at) = paste_comments_at { + app.paste_comments(index, None, Some(at)); + } + if let Some(notice) = comment_notice { + app.notify_tr(notice); + } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 03bd3e807d..d366967b9f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -51,6 +51,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut editing_content, form, info, + placing, prefs, preparing, redact_mark, @@ -116,14 +117,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); - let on_field = if preparing { + let on_field = if preparing && !placing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) + tool == QuickTool::Select && !placing && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -150,7 +151,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } view.content.selected = None; } - let on_content = !on_objects && editing_content && can_modify && { + let on_content = !on_objects && !placing && editing_content && can_modify && { source_blocked || crate::content_ui::page_input( cx, diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b1114f283e..4601986e9a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -26,6 +26,7 @@ pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]"); pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G"); pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G"); pub(crate) const COPY: Shortcut = Shortcut::cmd("C"); +pub(crate) const PASTE: Shortcut = Shortcut::cmd("V"); pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A"); pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←"); pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→"); @@ -95,6 +96,8 @@ impl PdfCraftApp { .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { + "comment.copy" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.comments.selected.is_some()), + "comment.paste" => self.comment_clipboard.is_some(), "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), "view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()), _ => true, @@ -570,6 +573,14 @@ impl PdfCraftApp { } return Some(false); } + "comment.copy" => { + let Some(i) = active else { return Some(false) }; + return Some(self.copy_selected_comments(i)); + } + "comment.paste" => { + let Some(i) = active else { return Some(false) }; + return Some(self.paste_comments(i, None, None)); + } "comment.hide_all" => { if let Some(i) = active { let id = self.views[i].id; diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index b803f2738c..c2e82f98e5 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -96,6 +96,8 @@ impl Host for crate::PdfCraftApp { // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), + // Every selected comment (⌘-click adds), the one with the handles first. + "selected_comments": v.comments.selection().iter().map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })).collect::>(), "comment_composer_open": v.comments.composer.is_some(), })), // The Combine files tab: how its files show, and each grid thumbnail's state. diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index db08b7b918..285423cbca 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -26,6 +26,9 @@ pub struct PropsDraft { /// Acrobat's Make Properties Default box: on OK, new comments of this kind take the edited /// colour, opacity and line width too. pub make_default: bool, + /// The other selected comments, each with its own properties: OK makes the same changes to + /// them where they apply (a colour to every one, a font size to text boxes, …). + pub others: Vec<(usize, usize, CommentProps)>, } const ICONS: [NoteIcon; 14] = NoteIcon::ALL; @@ -121,11 +124,44 @@ impl PdfCraftApp { self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]); } - /// Open Comment Properties for the comment at `(page, index)` of the active document. + /// Give every selected comment of the active document `color`, as one undo step. + pub fn colour_selected_comments(&mut self, color: pdfcraft_engine::Rgb) { + self.colour_selection(color, |_| true); + } + + /// Give the selected comments that `only` picks `color`, as one undo step: those whose look can + /// change and that aren't locked (a locked one would refuse the whole step). + pub(crate) fn colour_selection(&mut self, color: pdfcraft_engine::Rgb, only: impl Fn(&pdfcraft_render::Annotation) -> bool) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let keys = self.views.get(i).map(|v| v.comments.selection()).unwrap_or_default(); + let mut edits: Vec = keys + .into_iter() + .filter(|(p, ix)| doc.info.annotations.iter().any(|a| a.page == *p && a.index == *ix && !a.is_reply() && only(a))) + .filter(|(p, ix)| doc.comment_props(*p, *ix).is_some_and(|c| c.restylable && !c.locked)) + .map(|(page, index)| Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }) + .collect(); + match edits.len() { + 0 => {} + 1 => { + self.apply_edit(edits.remove(0)); + } + _ => { + self.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } + } + } + + /// Open Comment Properties for the comment at `(page, index)` of the active document. When it + /// is one of several selected comments, the changes go to all of them. pub fn open_comment_props(&mut self, page: usize, index: usize) { - let Some((_, id)) = self.active_ids() else { return }; - let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return }; - self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false }); + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let Some(p) = doc.comment_props(page, index) else { return }; + let selection = self.views.get(i).map(|v| &v.comments).filter(|c| c.is_selected((page, index))).map(|c| c.selection()).unwrap_or_default(); + let others = + selection.into_iter().filter(|k| *k != (page, index)).filter_map(|(pg, ix)| doc.comment_props(pg, ix).map(|c| (pg, ix, c))).collect(); + self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false, others }); self.dialog = Some(crate::Dialog::CommentProps); } } @@ -144,48 +180,81 @@ fn has_border(p: &CommentProps) -> bool { p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0) } -/// The edits that make the comment match the draft (empty when nothing changed). +/// The edits that make the comment match the draft (empty when nothing changed), and make the +/// same changes to the other selected comments where they apply. Locks come off first and go on +/// last, as a locked comment refuses other changes; another comment that stays locked is left +/// as it is. pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); + let targets: Vec<(usize, usize, &CommentProps)> = + std::iter::once((d.page, d.index, o)).chain(d.others.iter().map(|(p, i, c)| (*p, *i, c))).collect(); let mut out = Vec::new(); - // A locked comment refuses other changes: unlock first, lock last. - if o.locked && !e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false }); + // Unticking Locked unlocks every selected comment. + let unlocking = o.locked && !e.locked; + for &(page, index, t) in &targets { + if unlocking && t.locked { + out.push(Edit::LockAnnotation { page, index, locked: false }); + } } + for (n, &(page, index, t)) in targets.iter().enumerate() { + if n > 0 && t.locked && !unlocking { + continue; + } + changes(o, e, page, index, t, &mut out); + } + for &(page, index, t) in &targets { + if !o.locked && e.locked && !t.locked { + out.push(Edit::LockAnnotation { page, index, locked: true }); + } + } + out +} + +/// The changes from `o` to `e` (the draft) for the comment at `(page, index)`, whose own +/// properties are `t`: only those its kind has (a line width for shapes and lines, a fill for +/// shapes, a font size for text, …). +fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &CommentProps, out: &mut Vec) { let color = (e.color != o.color).then_some(e.color).flatten(); let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity); - let width = (e.width != o.width).then_some(e.width).flatten(); - let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten(); - if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { - out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings }); + let width = (e.width != o.width).then_some(e.width).flatten().filter(|_| t.width.is_some()); + let endings = + (e.endings != o.endings).then(|| e.endings.clone()).flatten().filter(|ends| t.endings.as_ref().is_some_and(|own| own.len() == ends.len())); + if t.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { + out.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings }); } - if e.restylable && e.fillable && e.fill != o.fill { - out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill }); + if t.restylable && t.fillable && e.fill != o.fill { + out.push(Edit::FillAnnotation { page, index, fill: e.fill }); } - // After the opacity, which is the whole comment's: the parts shown are set again. - let (fill, text) = parts_shown(e); + // After the opacity, which is the whole comment's: the parts shown are set again. Which + // parts show is the comment's own once the draft's fill and line width are on it, so another + // selected comment without a border or a fill keeps one opacity for the whole comment. + let mut after = t.clone(); + if t.fillable && e.fill != o.fill { + after.fill = e.fill; + } + if let Some(w) = width { + after.width = Some(w); + } + let (fill, text) = parts_shown(&after); let moved = |a: Option, b: Option| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6); - if e.restylable + if t.restylable && (fill || text) && (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity)) { - out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); + out.push(Edit::PartOpacity { page, index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); } - if e.restylable + if t.restylable + && t.font_size.is_some() && let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size) { - out.push(Edit::FontSizeAnnotation { page: d.page, index: d.index, size }); + out.push(Edit::FontSizeAnnotation { page, index, size }); } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); - let icon = (e.icon != o.icon).then_some(e.icon).flatten(); + let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); if author.is_some() || subject.is_some() || icon.is_some() { - out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon }); + out.push(Edit::SetAnnotationInfo { page, index, author, subject, icon }); } - if !o.locked && e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true }); - } - out } /// Draw the dialog; returns (apply, cancel). @@ -213,6 +282,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0))); + if !d.others.is_empty() { + let n = (d.others.len() + 1).to_string(); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Changes here apply to all {n} selected comments"), &[("n", &n)])).color(t.text_muted)); + } ui.add_space(6.0); ui.horizontal_wrapped(|ui| { for (tab, label) in diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8597e07b5a..6a788e765d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -28,7 +28,7 @@ fn new_text_typed(v: &crate::DocView) -> bool { } /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` -/// is a deleted annotation's page's annotations before the delete (empty for other edits). +/// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( view: &mut crate::DocView, edit: &Edit, @@ -58,11 +58,12 @@ fn select_after_edit( crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) ) => { - view.comments.selected = None; + view.comments.clear_selection(); } Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). + // Select the new comment alone (appended last among the page's comments). let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.clear_selection(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, @@ -75,6 +76,25 @@ fn select_after_edit( } } Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), + // A group moved together stays selected (its positions don't change), so it + // can be moved or copied again; anything else in a batch clears the selection. + Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} + Edit::Batch { .. } => view.comments.clear_selection(), + // The copies the paste added (and nothing that was there before): select them all. + Edit::PasteComments { page, .. } => { + let mut new: Vec<(usize, usize)> = info + .annotations + .iter() + .filter(|x| x.page == *page && !x.is_reply() && !annots_before.iter().any(|b| b.name == x.name)) + .map(|x| (*page, x.index)) + .collect(); + view.comments.clear_selection(); + if !new.is_empty() { + view.comments.selected = Some(new.remove(0)); + view.comments.also = new; + view.comments.reveal = true; + } + } _ => {} } } @@ -142,7 +162,10 @@ impl PdfCraftApp { // A deletion moves the comments after it on the page: these say where the selected one, // and the one open in the panel's editor, went. let annots_before = match (&edit, self.session.get(id)) { - (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + // A paste selects what it adds: the page's comments before it are the ones it didn't. + (Edit::DeleteAnnotation { page, .. } | Edit::PasteComments { page, .. }, Some(d)) => { + d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect() + } _ => Vec::new(), }; match self.session.apply(id, edit.clone()) { diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 269f0a12c2..f27b1e035b 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -368,19 +368,12 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve } if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); + // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool)) - }) + && let Some((_, id)) = app.active_ids() + && app.session.get(id).is_some_and(|doc| doc.allows_annotation()) { - app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None }); + app.colour_selection(color, |a| comments::made_by(a, tool)); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d9de15d046..5b6465f1df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,8 +147,9 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, - PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt, + CommentClip, CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, + PasswordPrompt, PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, + UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -561,6 +562,8 @@ pub struct PdfCraftApp { pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, + /// Copied comments (XFDF), to paste into this or another document (M14). + pub comment_clipboard: Option, /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. grid_drop: Option, /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 49f2aeba1a..369ad5933d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -141,6 +141,9 @@ const EDIT: &[&str] = &[ PASTE, DELETE, SEP, + "comment.copy", + "comment.paste", + SEP, SELECT_ALL, SEP, "edit.find", diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 591a667159..0ac648d59c 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -239,6 +239,9 @@ pub(crate) fn show( { view.comments.collapsed_pages.retain(|&c| c != p); } + // The cards in the order shown, for a ⇧-click range (folded pages' cards aren't shown). + let order: Vec<(usize, usize)> = + roots.iter().filter(|a| !(sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page))).map(|a| (a.page, a.index)).collect(); let mut page = usize::MAX; let mut group: Option = None; // A card (or page header) out of view that was measured at this width only takes up its @@ -287,7 +290,7 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) { + if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { edit = Some(e); } if virtual_list { @@ -376,6 +379,8 @@ struct CardContext<'a> { // The clockwise `/Rotate` of the comment's page. rotation: u16, thread: &'a [&'a Annotation], + /// The cards in the order shown, for a ⇧-click range. + order: &'a [(usize, usize)], doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -383,11 +388,14 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); - let selected = view.comments.selected == Some(key); + let selected = view.comments.is_selected(key); + // Several comments may be selected; the reply field (one draft) belongs to the one with + // the handles. + let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); let marked = is_marked(thread); @@ -504,7 +512,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti } above = me; } - if selected && allowed { + if primary && allowed { ui.add_space(8.0); egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { // The field fills its line and Post sits right-aligned under it: a field sized @@ -552,7 +560,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { - view.comments.selected = Some(key); + // ⌘/Ctrl-click adds the comment to the selection, as on the page; ⇧-click selects the + // cards from the one with the handles to this one, as lists do. + let modifiers = ui.input(|i| i.modifiers); + if modifiers.shift && !modifiers.command { + view.comments.select_range(order, key); + } else if modifiers.command { + view.comments.toggle(key); + } else { + view.comments.select_only(key); + } view.comments.reply.clear(); *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } @@ -603,10 +620,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.default_request = Some((a.page, a.index)); ui.close(); } + if ui.button(tl!("Copy comment")).clicked() { + // The card's comment alone (the page's menu copies the whole selection). + view.comments.select_only(key); + view.comments.copy_request = true; + ui.close(); + } ui.separator(); // A locked reply can't be deleted, so it keeps the comment it answers too. if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; + view.comments.clear_selection(); edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5d658a591e..915d40e6ec 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -233,6 +233,14 @@ pub struct PagePropsDraft { pub tab_order: Option, } +/// Copied comments: their XFDF (each with its replies), for `Edit::PasteComments`. +#[derive(Clone, Debug, PartialEq)] +pub struct CommentClip { + pub xfdf: String, + /// How many comments (replies not counted) were copied. + pub count: usize, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 348d4b906f..7e6d88b520 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -39,9 +39,9 @@ pub use geometry::{drawn_text_box_rect, text_box_rect}; use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; +pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; -pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index caddd3e837..fa56f7a231 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; +use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; @@ -31,6 +31,39 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } + // Copied comments on the pointer: a click places the bundle, centred where it is clicked, + // on this page; a right-click lets it go. Nothing else on the page takes the click. + if let Some(Gesture::Place { xfdf, bounds, .. }) = cv.gesture.clone() { + if over_page { + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); + } + let cv = &mut view.comments; + if resp.secondary_clicked() { + cv.gesture = None; + return true; + } + if resp.clicked() + && over_page + && cx.allowed + && let Some(p) = pointer + { + let [x, y] = cx.to_user(p); + let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0]; + // Larger than the page it lands on (an A0 bundle on an A4 page): placed as it is, + // and said so. + if let Some(page) = cx.info.pages.get(cx.page) { + let a = page.user_to_view((bounds[0] + offset[0]) as f32, (bounds[1] + offset[1]) as f32); + let b = page.user_to_view((bounds[2] + offset[0]) as f32, (bounds[3] + offset[1]) as f32); + let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs()); + if w > page.width + 1.0 || h > page.height + 1.0 { + cv.notice = Some("The copied comments are larger than this page; they were placed as they are"); + } + } + cv.gesture = None; + view.pending_edit = Some(Edit::PasteComments { xfdf, page: cx.page, offset: Some(offset) }); + } + return true; + } let moving = moving_on(view, cx.page); let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { @@ -216,7 +249,20 @@ fn select_input( consumed = true; } CommentPart::Body if !is_markup(&a.subtype) => { - cv.selected = Some((cx.page, a.index)); + // Dragging one of several selected comments moves them all; any other drag starts + // a selection of its own. + let grabbed = (cx.page, a.index); + if !cv.is_selected(grabbed) { + cv.select_only(grabbed); + } else { + // The grabbed one takes the handles; the one that had them stays selected. + if let Some(previous) = cv.selected.replace(grabbed) + && previous != grabbed + { + cv.also.push(previous); + } + cv.also.retain(|k| *k != grabbed); + } cv.reveal = true; if cx.allowed { cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o }); @@ -232,38 +278,9 @@ fn select_input( if resp.drag_stopped() && let Some(p) = pointer { - match cv.gesture.clone() { - Some(Gesture::Move { page, index, from }) if page == cx.page => { - cv.gesture = None; - let (a, b) = (cx.to_user(from), cx.to_user(p)); - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - if from.distance(p) >= 2.0 { - view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); - } - } - Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { - cv.gesture = None; - if let Some(a) = cx.get(index) { - let from_rect = cx.screen_rect(a); - let shift = ui.input(|i| i.modifiers.shift); - let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); - if r.width() >= 4.0 && r.height() >= 4.0 { - view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); - } - } - } - Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { - cv.gesture = None; - let shift = ui.input(|i| i.modifiers.shift); - let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); - if moved { - view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); - } - } - _ => {} - } + finish_select_gesture(ui, cx, view, p); } + let cv = &mut view.comments; // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { @@ -271,11 +288,16 @@ fn select_input( // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { - cv.selected = Some((cx.page, a.index)); + // ⌘/Ctrl-click (or ⇧-click) adds the comment to the selection, as in Acrobat. + if ui.input(|i| i.modifiers.command || i.modifiers.shift) { + cv.toggle((cx.page, a.index)); + } else { + cv.select_only((cx.page, a.index)); + } cv.reveal = true; consumed = true; } - None if press != PagePress::Elsewhere => cv.selected = None, + None if press != PagePress::Elsewhere => cv.clear_selection(), None => {} } } @@ -284,7 +306,7 @@ fn select_input( && cx.allowed && let Some((a, _)) = owner { - cv.selected = Some((cx.page, a.index)); + cv.select_only((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, at: edit_anchor(cx.info, a), @@ -297,6 +319,57 @@ fn select_input( consumed } +/// The end of a move, resize or reshape the Select tool began on this page: the edit it makes, +/// for the pointer released at `p`. +fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Pos2) { + match view.comments.gesture.clone() { + Some(Gesture::Move { page, index, from }) if page == cx.page => { + view.comments.gesture = None; + let (a, b) = (cx.to_user(from), cx.to_user(p)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if from.distance(p) >= 2.0 { + // The other selected comments on this page (not text markup, which is where + // its text is, nor a locked one, which would refuse the whole step) move with + // the dragged one, as one undo step. + let others: Vec = view + .comments + .also + .iter() + .filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked)) + .map(|(_, i)| *i) + .collect(); + view.pending_edit = Some(if others.is_empty() { + Edit::MoveAnnotation { page, index, dx, dy } + } else { + let edits = std::iter::once(index).chain(others).map(|index| Edit::MoveAnnotation { page, index, dx, dy }).collect(); + Edit::Batch { label: tl!("Move comments").to_owned(), edits } + }); + } + } + Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { + view.comments.gesture = None; + if let Some(a) = cx.get(index) { + let from_rect = cx.screen_rect(a); + let shift = ui.input(|i| i.modifiers.shift); + let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); + if r.width() >= 4.0 && r.height() >= 4.0 { + view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); + } + } + } + Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { + view.comments.gesture = None; + let shift = ui.input(|i| i.modifiers.shift); + let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); + if moved { + view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + } + } + _ => {} + } +} + /// After text selection ran: a markup tool turns a finished selection into a comment; Replace /// Text asks for the replacement. pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { @@ -421,7 +494,18 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // The menu's Complete is taken by the page the drawing is on: every page on screen + // passes here in a frame, and an earlier one must not swallow it. With no drawing + // left, a request is stale. + let drawing_here = match &cv.gesture { + Some(Gesture::Draw { page, .. }) => *page == cx.page, + _ => { + cv.finish_request = false; + false + } + }; + let finish = + resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request)); // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) @@ -615,17 +699,36 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool // One step back per press: cancel the gesture, then deselect, then drop the tool (a // comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every // click with no way out by the keyboard). - let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); + let had_selection = cv.selected.is_some(); + cv.clear_selection(); + let cancelled = cv.gesture.take().is_some() || had_selection; if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) { *tool = QuickTool::Select; } } if allowed - && let Some((page, index)) = cv.selected + && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && let Some(edit) = delete_selected(cv) { - cv.selected = None; - view.pending_edit = Some(Edit::DeleteAnnotation { page, index }); + view.pending_edit = Some(edit); + } +} + +/// The edit that deletes every selected comment (one undo step for several), and clears the +/// selection. Later positions go first, so each deletion leaves the earlier ones where they are. +pub(crate) fn delete_selected(cv: &mut CommentView) -> Option { + let mut keys = cv.selection(); + cv.clear_selection(); + keys.sort_unstable_by(|a, b| b.cmp(a)); + keys.dedup(); + match keys.as_slice() { + [] => None, + [(page, index)] => Some(Edit::DeleteAnnotation { page: *page, index: *index }), + _ => Some(Edit::Batch { + label: tl!("Delete comments").to_owned(), + edits: keys.into_iter().map(|(page, index)| Edit::DeleteAnnotation { page, index }).collect(), + }), } } diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 58252c393a..ecbe36d6ae 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,5 +1,6 @@ +use super::input::delete_selected; use super::paint::edit_anchor; -use super::state::{Composer, ComposerKind}; +use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, icons, theme, tool_for, vec2, @@ -22,8 +23,18 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc ui.separator(); } +/// What the canvas context menu may offer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct MenuRights { + /// Comments may be added and changed. + pub(crate) allowed: bool, + /// Copied comments wait to be pasted. + pub(crate) can_paste: bool, + /// The document's pages may change (Flatten comments draws into them). + pub(crate) can_modify: bool, +} + /// Items of the canvas context menu for the selected comment (or the page). -/// `can_modify`: the document's pages may change (Flatten comments draws into them). /// `deletable`: whether Delete would delete the comment at (page, index) (the engine's /// `Document::annotation_deletable`: unlocked, and no locked reply to it). pub(crate) fn context_menu( @@ -32,11 +43,32 @@ pub(crate) fn context_menu( info: &DocInfo, deletable: &dyn Fn(usize, usize) -> bool, prefs: &CommentPrefs, - allowed: bool, - can_modify: bool, + rights: MenuRights, ) -> Option { + let MenuRights { allowed, can_paste, can_modify } = rights; let mut action = None; + // Connected lines, a polygon or a cloud being drawn: finish it from the menu, as other + // editors do (Enter and a double-click finish it too), take the last point back, or stop. + if let Some(Gesture::Draw { tool, points, .. }) = &view.comments.gesture + && tool.clicks_points() + { + let enough = points.len() >= if *tool == CommentTool::PolyLine { 2 } else { 3 }; + if ui.add_enabled(enough, egui::Button::new(tl!("Complete"))).clicked() { + view.comments.finish_request = true; + ui.close(); + } + if ui.button(tl!("Undo last point")).clicked() { + view.comments.undo_point(); + ui.close(); + } + if ui.button(tl!("Cancel drawing")).clicked() { + view.comments.gesture = None; + ui.close(); + } + return None; + } let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply())); + let several = view.comments.also.len(); match selected { Some(a) => { let (page, index) = (a.page, a.index); @@ -69,14 +101,19 @@ pub(crate) fn context_menu( let keep_open = egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); let _ = egui::containers::menu::SubMenuButton::new(tl!("Colour")).config(keep_open).ui(ui, |ui| { if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) { - action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { - page, - index, - color: Some(c), - opacity: None, - width: None, - endings: None, - }))); + // Several selected: the app gives each of them the colour. + action = Some(if several > 0 { + CanvasAction::Colour(c) + } else { + CanvasAction::Edit(Box::new(Edit::StyleAnnotation { + page, + index, + color: Some(c), + opacity: None, + width: None, + endings: None, + })) + }); ui.close(); } }); @@ -92,10 +129,24 @@ pub(crate) fn context_menu( ui.close(); } ui.separator(); - // The engine's own check: a locked comment, or one with a locked reply, stays. - if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; - action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); + // The comments themselves, to paste into this or another document (⌘C / ⌘V too). + let copy_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add(egui::Button::new(tl!(if several > 0 { "Copy comments" } else { "Copy comment" })).shortcut_text(copy_key)).clicked() { + view.comments.copy_request = true; + ui.close(); + } + let paste_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::PASTE); + if ui.add_enabled(allowed && can_paste, egui::Button::new(tl!("Paste comments")).shortcut_text(paste_key)).clicked() { + let at = view.comments.context_at.unwrap_or((page, [f64::from(a.rect[0]), f64::from(a.rect[1])])); + view.comments.paste_request = Some(at); + ui.close(); + } + ui.separator(); + // The engine's own check, for each selected comment: a locked comment, or one with a + // locked reply, stays. + let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); + if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() { + action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e))); ui.close(); } if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { @@ -140,6 +191,8 @@ pub enum CanvasAction { OpenComments, /// Comment Properties for (page, index). Properties(usize, usize), + /// Give every selected comment this colour. + Colour(Rgb), /// Copy Image: image (page, index in `page_images`) to the clipboard. CopyImage(usize, usize), /// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG). diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index f993ecf85b..c122bca275 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -2,7 +2,7 @@ use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, resh use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, - Stroke, color32, vec2, + Stroke, color32, pos2, vec2, }; use crate::page_router::{CommentPart, Hit, Owner}; @@ -38,6 +38,12 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } + // The comments selected besides the one with the handles: outlined, not resizable. + for a in cv.also.iter().filter(|(p, _)| *p == cx.page).filter_map(|(_, i)| cx.get(*i)) { + for r in cx.screen_rects(a) { + painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + } + } if let Some((page, index)) = cv.selected && page == cx.page && let Some(a) = cx.get(index) @@ -93,6 +99,11 @@ pub(super) fn changing_points( }), (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + // Moved with the rest of the selection: its own move in the batch. + (_, Some(Edit::Batch { edits, .. })) => edits.iter().find_map(|e| match e { + Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + _ => None, + }), _ => None, } } @@ -128,6 +139,82 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { { paint_draw(painter, cx, tool, points); } + // The bundle of copied comments, hanging on the pointer over this page: its box, and each + // comment's outline in its colour, so the drop can be aimed to the line. + if let Some(Gesture::Place { bounds, count, outlines, .. }) = &view.comments.gesture + && let Some(p) = cx.ptr.pos + && cx.xf.rect.contains(p) + { + let [x, y] = cx.to_user(p); + let (dx, dy) = (x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0); + let r = Rect::from_two_pos(cx.to_screen([bounds[0] + dx, bounds[1] + dy]), cx.to_screen([bounds[2] + dx, bounds[3] + dy])); + painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.6)), egui::StrokeKind::Middle); + let at = |q: [f64; 2]| cx.to_screen([q[0] + dx, q[1] + dy]); + for outline in outlines { + paint_outline(painter, outline, &at); + } + let label = crate::i18n::fmt(tl!("Comments: {n}"), &[("n", &count.to_string())]); + painter.text(r.left_top() + vec2(0.0, -4.0), egui::Align2::LEFT_BOTTOM, label, crate::theme::regular(11.0), SELECT_BLUE); + } +} + +/// One copied comment's outline on screen (`at` maps a user-space point, moved to where the +/// bundle hangs), in the comment's colour. +fn paint_outline(painter: &egui::Painter, outline: &pdfcraft_engine::CommentOutline, at: &dyn Fn([f64; 2]) -> Pos2) { + use pdfcraft_engine::CommentOutline as O; + let color = |c: &Option<[f64; 3]>| { + c.map(|[r, g, b]| Color32::from_rgb((r.clamp(0.0, 1.0) * 255.0) as u8, (g.clamp(0.0, 1.0) * 255.0) as u8, (b.clamp(0.0, 1.0) * 255.0) as u8)) + .unwrap_or(SELECT_BLUE) + }; + match outline { + O::Rect { rect, round, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + if *round { + let (cx, cy, rx, ry) = (r.center().x, r.center().y, r.width() / 2.0, r.height() / 2.0); + let points: Vec = + (0..48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|t| pos2(cx + rx * t.cos(), cy + ry * t.sin())).collect(); + painter.add(egui::Shape::closed_line(points, stroke)); + } else { + painter.rect_stroke(r, CornerRadius::ZERO, stroke, egui::StrokeKind::Middle); + } + } + O::Line { points, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + painter.add(egui::Shape::line(points.iter().map(|p| at(*p)).collect(), stroke)); + } + O::Poly { points, closed, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let points: Vec = points.iter().map(|p| at(*p)).collect(); + painter.add(if *closed { egui::Shape::closed_line(points, stroke) } else { egui::Shape::line(points, stroke) }); + } + O::Ink { strokes, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + for s in strokes { + painter.add(egui::Shape::line(s.iter().map(|p| at(*p)).collect(), stroke)); + } + } + O::Quads { quads, color: c } => { + let fill = color(c).gamma_multiply(0.35); + for q in quads { + // Upper left, upper right, lower right, lower left: a closed shape in that order. + let points = vec![at([q[0], q[1]]), at([q[2], q[3]]), at([q[6], q[7]]), at([q[4], q[5]])]; + painter.add(egui::Shape::convex_polygon(points, fill, Stroke::NONE)); + } + } + O::Text { rect, text, size, color: c } => { + // At the page's zoom, wrapped in the box; too small to read at this zoom, left out. + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + let zoom = at([1.0, 0.0]).distance(at([0.0, 0.0])); + let px = *size as f32 * zoom; + if !(px.is_finite() && px >= 3.0) { + return; + } + let pad = 2.0 * zoom; + let galley = painter.layout(text.clone(), egui::FontId::proportional(px.min(400.0)), color(c), (r.width() - 2.0 * pad).max(1.0)); + painter.with_clip_rect(r.intersect(painter.clip_rect())).galley(r.min + vec2(pad, pad), galley, color(c)); + } + } } /// A drawing tool's shape from its points (a live drag, or a released one still settling). diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 9c876d950f..cff7b1d8a6 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -29,6 +29,10 @@ pub enum Gesture { /// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points` /// before the drag, in user space) to where the pointer is. Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> }, + /// Copied comments hanging on the pointer as one bundle (their box `bounds` in user space, + /// `count` of them, their `outlines` drawn along so the drop can be aimed): a click puts + /// their centre there, on the page clicked; Escape or a right-click lets them go. + Place { xfdf: String, bounds: [f64; 4], count: usize, outlines: Vec }, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -127,8 +131,21 @@ impl CommentView { /// Per-document commenting state. #[derive(Clone, Debug, Default)] pub struct CommentView { - /// The selected comment: (page, index in `/Annots`). + /// The selected comment: (page, index in `/Annots`). With several selected (`also`), the one + /// the handles, the properties and the context menu act on. pub selected: Option<(usize, usize)>, + /// The other selected comments (⌘/Ctrl-click or ⇧-click on the page adds one, ⇧-click in the + /// Comments panel a range): Delete, Copy, Properties and a drag act on them with `selected`. + pub also: Vec<(usize, usize)>, + /// Copy the selected comments (set by the menus; the app has the clipboard). + pub copy_request: bool, + /// Paste the copied comments onto this page, centred at this user-space point (set by the + /// page's menu, which knows where it was opened). + pub paste_request: Option<(usize, [f64; 2])>, + /// A notice for the app to show (the page input has no app). + pub notice: Option<&'static str>, + /// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete). + pub finish_request: bool, pub gesture: Option, /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). @@ -187,6 +204,55 @@ pub(crate) enum EditorFocus { } impl CommentView { + /// Every selected comment, the one the handles belong to first. + pub fn selection(&self) -> Vec<(usize, usize)> { + let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect(); + all.extend(self.also.iter().copied().filter(|k| Some(*k) != self.selected)); + all + } + + /// Whether `key` (page, index) is among the selected comments. + pub fn is_selected(&self, key: (usize, usize)) -> bool { + self.selected == Some(key) || self.also.contains(&key) + } + + /// Select `key` alone. + pub fn select_only(&mut self, key: (usize, usize)) { + self.selected = Some(key); + self.also.clear(); + } + + /// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to + /// `key` is selected, and only those, so another ⇧-click from the same comment changes the + /// range. Without a comment with the handles, or with either end not in the list, `key` is + /// added as by ⌘/Ctrl-click. + pub fn select_range(&mut self, order: &[(usize, usize)], key: (usize, usize)) { + let ends = self.selected.and_then(|anchor| Some((order.iter().position(|k| *k == anchor)?, order.iter().position(|k| *k == key)?))); + let Some((a, b)) = ends else { return self.toggle(key) }; + let anchor = self.selected; + self.also = order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied().filter(|k| Some(*k) != anchor).collect(); + } + + /// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the + /// comment clicked last. + pub fn toggle(&mut self, key: (usize, usize)) { + if self.selected == Some(key) { + self.selected = self.also.pop(); + } else if let Some(at) = self.also.iter().position(|k| *k == key) { + self.also.remove(at); + } else { + if let Some(previous) = self.selected.replace(key) { + self.also.push(previous); + } + } + } + + /// Nothing selected. + pub fn clear_selection(&mut self) { + self.selected = None; + self.also.clear(); + } + /// Takes back the last point of a polygon, cloud or connected lines being drawn; the first /// point's removal cancels the drawing. Returns whether there was such a drawing. pub fn undo_point(&mut self) -> bool { @@ -226,6 +292,7 @@ impl CommentView { /// went too. pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); + self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect(); self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); if self.editing.is_none() { self.editor_focus = None; @@ -237,7 +304,7 @@ impl CommentView { /// editor close, its unsaved text with it, rather than Save writing that text over whatever /// took its place. pub(crate) fn history_stepped(&mut self) { - self.selected = None; + self.clear_selection(); self.editing = None; self.editor_focus = None; } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 0f9edd6c22..acb4008d3b 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -33,6 +33,13 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { let to = Some(pos2(60.0, 10.0)); assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); + // Released with the rest of the selection: its own move in the batch. + let together = Edit::Batch { + label: "Move comments".into(), + edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }], + }; + assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch"); } /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit @@ -407,7 +414,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men let mut h = egui_kittest::Harness::new_ui_state( |ui, (view, actions): &mut (DocView, Vec)| { ui.button("Page").context_menu(|ui| { - if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, true, true) { + let rights = MenuRights { allowed: true, can_paste: false, can_modify: true }; + if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, rights) { actions.push(a); } }); @@ -490,6 +498,24 @@ fn drawn_shapes_ignore_slips() { )); } +#[test] +fn shift_click_selects_the_range_from_the_comment_with_the_handles() { + let order = [(0, 3), (0, 1), (0, 0), (1, 2), (1, 0)]; + let mut cv = CommentView::default(); + cv.select_only((0, 1)); + cv.select_range(&order, (1, 2)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 0), (1, 2)], "from the anchor down, the anchor keeps the handles"); + // Another ⇧-click from the same anchor changes the range, upwards too. + cv.select_range(&order, (0, 3)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 3)]); + // Nothing to start from, or a card not in the list: added as by ⌘/Ctrl-click. + let mut none = CommentView::default(); + none.select_range(&order, (0, 0)); + assert_eq!(none.selection(), vec![(0, 0)]); + cv.select_range(&order, (9, 9)); + assert_eq!(cv.selected, Some((9, 9))); +} + #[test] fn text_boxes_fit_their_text() { let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index a156dfe3d2..af64f63adf 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -494,7 +494,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo // sees its lost focus, so replacing the box first would drop what was typed. A new box // deselects, as a comment tool's note or text box does: Escape then closes the box and // leaves the tool in two presses. - view.comments.selected = None; + view.comments.clear_selection(); let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { FillAction::Edit(Box::new(typed_with_color( diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index fffbd9d6d8..0ef5806ee3 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -578,7 +578,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } if let Some((page, annotation, reading, scale)) = select { app.views[index].current = page; - app.views[index].comments.selected = Some((page, annotation)); + app.views[index].comments.select_only((page, annotation)); app.views[index].measure.reading = Some(reading); app.views[index].measure.scale = Some(scale); app.quick_tool = QuickTool::Select; diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 04bee7d423..dd6e7178b0 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,22 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + // Copying comments needs a selected comment; pasting needs copied ones. + if matches!(spec.id, "comment.copy" | "comment.paste") { + let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: String::new(), + }))); + app.views[0].comments.select_only((0, 0)); + if spec.id == "comment.paste" { + assert!(app.copy_selected_comments(0)); + } + } if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) { use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark}; let mut hf = HeaderFooter::default(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 444db23c84..cff01c0014 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -84,6 +84,19 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) { let p = at(h, p.0, p.1); click(h, p); } +/// Click a page point with modifier keys held (⌘-click adds a comment to the selection). +fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); +} /// A text field by its placeholder ("Add a comment", "Add a reply"). fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> { @@ -3716,3 +3729,442 @@ fn other_edit_composers_keep_trimming_trailing_whitespace() { edit_composer_preserves_only_typewriter_whitespace(kind); } } + +/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again; +/// Delete removes every selected comment as one undo step. +#[test] +fn ctrl_click_selects_several_comments_and_delete_removes_them_all() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + assert_eq!(comments(&h).len(), 3); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2))); + // ⌘-click on a selected comment takes it out again. + click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]); + // Delete: both go, as one step. + h.key_press(egui::Key::Delete); + h.run_steps(3); + let left = comments(&h); + assert_eq!(left.len(), 1, "{left:?}"); + assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect); + assert!(h.state().views[0].comments.selection().is_empty()); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments")); + h.state_mut().undo(); + assert_eq!(comments(&h).len(), 3); + // A plain click selects one alone again. + click_pt(&mut h, (120.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); + // On the page ⇧-click adds one too, and takes it out again (a range is the panel's). + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection()); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); +} + +/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the +/// grabbed one takes the handles and the one that had them stays selected. +#[test] +fn dragging_any_selected_comment_moves_the_whole_selection() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles"); + // Drag the first one (not the one with the handles) 30 points up. + let before = comments(&h); + drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0)); + let after = comments(&h); + assert_eq!(after.len(), 3); + for (a, b) in before.iter().zip(&after) { + assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect); + } + let selection = h.state().views[0].comments.selection(); + assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}"); + assert_eq!(selection[0], (0, 0), "the grabbed one has the handles"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); + // A locked one in the selection stays where it is; the others still move (it would refuse + // the whole step otherwise, and nothing would move). + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selection().len(), 3); + let before = comments(&h); + drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0)); + let after = comments(&h); + // By index: the list isn't kept in index order. + let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there"); + for i in 0..3 { + let moved = bottom(&after, i) - bottom(&before, i); + if i == 1 { + assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}"); + } else { + assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}"); + } + } + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); +} + +/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text +/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp. +#[test] +fn copied_comments_take_the_click_from_the_tool_that_is_on() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + let id = h.state().views[0].id; + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().views[0].comments.select_only((0, 0)); + assert!(h.state_mut().execute("comment.copy")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len(); + for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] { + h.state_mut().quick_tool = tool; + h.run_steps(2); + let before = count(&h); + assert!(h.state_mut().execute("comment.paste")); + click_pt(&mut h, at); + assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else"); + assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens"); + assert!(h.state().views[0].comments.gesture.is_none()); + let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed"); + assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}"); + assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click"); + } + // Afterwards the tool takes its clicks again. + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + h.run_steps(2); + click_pt(&mut h, (100.0, 200.0)); + assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again"); +} + +/// Two boxes on page 1 of the active document, and a reply to the first. +fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) { + for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] { + let shape = pdfcraft_engine::Shape::Rectangle { rect }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Box".into(), + author: "Tester".into(), + }))); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() })); + h.run_steps(2); +} + +/// Copied comments paste into another document on its current page, each with its replies, as +/// new comments (a second paste gives a second set); the copies are selected afterwards. +#[test] +fn copied_comments_paste_into_another_document() { + let mut h = harness(|app| { + app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.active = Some(0); + }); + let (source, target) = (h.state().views[0].id, h.state().views[1].id); + add_two_boxes_and_a_reply(&mut h); + // Adding a comment selects it; with nothing selected there is nothing to copy. + h.state_mut().views[0].comments.clear_selection(); + assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy"); + { + let cv = &mut h.state_mut().views[0].comments; + cv.select_only((0, 0)); + cv.also.push((0, 1)); + } + assert!(h.state_mut().execute("comment.copy")); + assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2)); + // Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click + // places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40). + h.state_mut().active = Some(1); + h.run_steps(2); + assert!(h.state_mut().execute("comment.paste")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len(); + assert_eq!(count(&h), 0, "nothing placed yet"); + assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. }))); + click_pt(&mut h, (150.0, 100.0)); + let pasted = h.state().session.get(target).unwrap().info.annotations.clone(); + assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}"); + assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}"); + let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)"); + assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect); + assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy"); + assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected"); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments")); + assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched"); + // The page menu's Paste comments places the bundle where the menu was opened: page 2 here. + h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0])); + h.run_steps(3); + assert_eq!(count(&h), 6); + let second: Vec<_> = + h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect(); + assert_eq!(second.len(), 2); + let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0; + assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}"); + // ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(count(&h), 6, "nothing placed"); + h.state_mut().undo(); + assert_eq!(count(&h), 3); + // XFDF arriving from the system clipboard pastes too, even without the app's clipboard. + let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf; + h.state_mut().comment_clipboard = None; + assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled"); + h.event(egui::Event::Paste(xfdf.clone())); + h.run_steps(3); + click_pt(&mut h, (200.0, 120.0)); + assert_eq!(count(&h), 6); + h.event(egui::Event::Paste("just text".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments"); + // Nor when comments were copied earlier: a word copied in another app since is that word. + h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 }); + h.event(egui::Event::Paste("a word from elsewhere".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer"); + // Nothing legible on the system clipboard: the app's own copied comments. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); +} + +/// Connected lines (and polygons) finish from the right-click menu too, as other editors do; +/// the menu also takes the last point back and cancels the drawing. +#[test] +fn connected_lines_are_completed_from_the_context_menu() { + let mut h = harness(|app| app.set_option("quick", "polyline").unwrap()); + for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(comments(&h).len(), 0, "still drawing"); + let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + }; + right_click(&mut h, (120.0, 100.0)); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "PolyLine"); + // A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work. + h.state_mut().set_option("quick", "polygon").unwrap(); + for p in [(150.0, 150.0), (200.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + right_click(&mut h, (220.0, 100.0)); + assert!(h.get_by_label("Complete").accesskit_node().is_disabled()); + h.get_by_label("Undo last point").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 1); + right_click(&mut h, (220.0, 100.0)); + h.get_by_label("Cancel drawing").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 0); + assert!(h.state().views[0].comments.gesture.is_none()); + assert_eq!(comments(&h).len(), 1, "nothing else was drawn"); + // On the second page, with the first on screen in the same frame: Complete reaches the page + // the drawing is on (the first page passes first and must not take the request). + h.state_mut().set_option("quick", "polyline").unwrap(); + h.state_mut().set_option("zoom", "50").unwrap(); + crate::support::settle(&mut h); + assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too"); + let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()) + }; + for (x, y) in [(20.0, 100.0), (60.0, 80.0)] { + let p = at2(&h, x, y); + click(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + let p = at2(&h, 150.0, 150.0); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 2, "{c:?}"); + assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}"); + assert!(h.state().views[0].comments.gesture.is_none()); +} + +/// Properties on one of several selected comments changes all of them, each where it applies: the +/// colour goes to every one, the line width to the shapes, the font size to the text box alone; +/// a locked one stays as it is. One undo step. Colouring the selection works the same way. +#[test] +fn properties_change_every_selected_comment_where_it_applies() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + let shapes = [ + Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }, + Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] }, + Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 }, + Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] }, + ]; + for shape in shapes { + let style = Style::default_for(&shape); + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true })); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2), (0, 3)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Changes here apply to all 4 selected comments"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.others.len(), 3); + d.edited.color = Some([0.0, 0.0, 1.0]); + d.edited.width = Some(4.0); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + for index in [0, 1] { + let p = props(&h, index); + assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}"); + } + assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour"); + let locked = props(&h, 3); + assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step"); + // From the text box: its font size changes, and the shapes, which have none, keep their look. + h.state_mut().views[0].comments.selected = Some((0, 2)); + h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)]; + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2).font_size, Some(20.0)); + assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0))); + // Colouring the selection (the context menu's Colour with several selected). + h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]); + h.run_steps(2); + for index in [0, 1, 2] { + assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}"); + } + assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0])); +} + +/// Comment Properties' text opacity, set with several comments selected, goes to the other text +/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box, +/// whose one opacity is the whole comment's, as it was. +#[test] +fn part_opacity_changes_every_selected_comment_that_shows_the_part() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() { + let x = 20.0 + 100.0 * n as f64; + let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 }; + let style = Style { width, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + let text_only = props(&h, 2); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4); + h.get_by_label("OK").click(); + h.run_steps(3); + for index in [0, 1] { + let text = props(&h, index).text_opacity.expect("a text box's text opacity"); + assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}"); + } + let after = props(&h, 2); + assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look"); +} + +/// Escape with a Fill & Sign tool on and several comments selected deselects them all first, +/// then drops the tool. +#[test] +fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + add_two_boxes_and_a_reply(&mut h); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1)]; + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + let c = &h.state().views[0].comments; + assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 16c554d72f..c42d414918 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -29,9 +29,9 @@ pub use fdf::export_fdf; use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf}; #[cfg(test)] use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS}; -pub use xfdf::export_xfdf; #[cfg(test)] use xfdf::{LOCKED, find_by_name}; +pub use xfdf::{Outline, comment_count, comment_outlines, comments_bounds, export_xfdf, export_xfdf_comments, import_comments, is_xfdf_comments}; use xfdf::{apply_values, import_xfdf}; #[cfg(test)] diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 52dbe0369b..96e9179faa 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -4,6 +4,7 @@ use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; +use super::xfdf::PDFCRAFT_NS; use super::*; /// Two blank pages with three fields (text, check box, list). @@ -2099,3 +2100,323 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() { let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count(); assert_eq!(popups, 0); } + +/// Copy: the chosen comments travel with their replies and nothing else. +#[test] +fn exporting_selected_comments_takes_them_and_their_replies() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + assert!(xfdf.contains(""), "no field values"); + assert!(is_xfdf_comments(&xfdf)); + assert!(!is_xfdf_comments("") && !is_xfdf_comments("just text")); + // A position that is no comment exports nothing but a valid, empty file. + let empty = export_xfdf_comments(&src, &[(7, 7)], "form.pdf"); + assert!(empty.contains("\n"), "{empty}"); +} + +/// Paste: the copies land on the asked-for page under names of their own, so nothing is +/// replaced and a second paste gives a second set; the reply keeps its copied parent. +#[test] +fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + let mut dst = blank(); + let r = import_comments(&mut dst, &xfdf, Some(1), Some("paste1"), None).unwrap(); + assert_eq!((r.comments, r.fields), (2, 0), "{r:?}"); + let first: Vec<_> = summaries(&dst); + assert!(first.iter().all(|s| s.page == 1), "every copy is on page 2: {first:?}"); + let parent = first.iter().find(|s| s.subtype == "Text").unwrap(); + let reply = first.iter().find(|s| s.in_reply_to.is_some()).expect("the reply came along"); + assert_eq!(reply.in_reply_to.as_deref(), Some("paste1-1"), "and replies to the copied note, by its new name ({parent:?})"); + // Again: two sets, not one replaced set. + import_comments(&mut dst, &xfdf, Some(0), Some("paste2"), None).unwrap(); + let second = summaries(&dst); + assert_eq!(second.len(), 4, "{second:?}"); + assert_eq!(second.iter().filter(|s| s.page == 0).count(), 2); + // The copies' names are their own, not the originals'. + let xfdf2 = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(!xfdf2.contains("name=\"n1\"") && !xfdf2.contains("name=\"r1\""), "{xfdf2}"); + assert!(xfdf2.contains("name=\"paste1-1\"") && xfdf2.contains("inreplyto=\"paste1-1\""), "{xfdf2}"); + // A page the document doesn't have is refused, and nothing changes. + assert!(import_comments(&mut dst, &xfdf, Some(9), Some("paste3"), None).is_err()); + assert_eq!(summaries(&dst).len(), 4); + // Without a target page the copies keep their own pages; without a prefix the import is + // the usual one (same names replace). + let mut other = blank(); + import_comments(&mut other, &xfdf, None, Some("p"), None).unwrap(); + assert!(summaries(&other).iter().all(|s| s.page == 0)); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy"); +} + +/// Other tools write comments straight into the page's `/Annots` array, without an object of +/// their own: they copy like any other, and two source comments sharing a name give two copies. +#[test] +fn comments_written_directly_into_the_page_array_copy_and_shared_names_give_two_copies() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 60.0, 40.0])); + direct.set(b"Contents".to_vec(), PdfString::text("drawn elsewhere")); + let mut twin = direct.clone(); + twin.set(b"NM".to_vec(), PdfString::text("shared")); + let mut other_twin = twin.clone(); + other_twin.set(b"Rect".to_vec(), arr(&[100.0, 10.0, 150.0, 40.0])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(direct), Object::Dict(twin), Object::Dict(other_twin)]))) + .unwrap(); + assert_eq!(summaries(&doc).len(), 3); + let xfdf = export_xfdf_comments(&doc, &[(0, 0)], "x.pdf"); + assert!(xfdf.contains("\n"), "a position that is no annotation copies nothing"); +} + +/// A selected comment without a name of its own (other tools leave /NM out) gets one for the +/// export, so its replies still find it when the copies are pasted. +#[test] +fn replies_to_a_selected_comment_without_a_name_follow_it_into_the_copy() { + let meta = |id: &str| Meta { date: None, id: id.into() }; + let mut doc = blank(); + let shape = Shape::Rectangle { rect: [10.0, 10.0, 60.0, 40.0] }; + add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "nameless".into(), author: "Ada".into() }, + &meta("gone"), + ) + .unwrap(); + let index = summaries(&doc)[0].index; + add_reply(&mut doc, 0, index, "Agreed", "Bob", &meta("r1")).unwrap(); + // The parent loses its /NM, as another tool may have left it. + let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let parent = annots[index].as_ref().unwrap(); + doc.update_dict(parent, |d| { + d.remove(b"NM"); + }) + .unwrap(); + assert_eq!(threads(&doc)[0].0, None, "{:?}", threads(&doc)); + // A comment written straight into the page array already carries the name the parent would + // get: the parent's export name steers clear of it. + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[100.0, 100.0, 150.0, 140.0])); + direct.set(b"NM".to_vec(), PdfString::text(&format!("pdfcraft-0-{index}"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |p| { + if let Some(Object::Array(annots)) = p.get_mut(b"Annots") { + annots.push(Object::Dict(direct)); + } + }) + .unwrap(); + let xfdf = export_xfdf_comments(&doc, &[(0, index)], "x.pdf"); + let given = format!("pdfcraft-0-{index}x"); + assert!(xfdf.contains(&format!("name=\"{given}\"")) && xfdf.contains(&format!("inreplyto=\"{given}\"")), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), None).unwrap(); + let copies = threads(&dst); + assert_eq!(copies.len(), 2, "{copies:?}"); + let parent_name = copies.iter().find(|(_, irt, _)| irt.is_none()).and_then(|(n, _, _)| n.clone()); + assert!(parent_name.is_some(), "{copies:?}"); + let reply_to = copies.iter().find(|(_, irt, _)| irt.is_some()).and_then(|(_, irt, _)| irt.clone()); + assert_eq!(reply_to, parent_name, "the reply follows its copied parent: {copies:?}"); +} + +/// A paste placed elsewhere: every copy moves by the offset, rect, quads, ink and vertices +/// alike; the bounds and count of a clipboard text say what hangs on the pointer. +#[test] +fn copies_placed_with_an_offset_move_whole_and_the_text_reports_its_bounds() { + let src = filled(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "form.pdf"); + assert_eq!(comment_count(&xfdf), 3, "note, highlight, ink (the reply is not counted)"); + let bounds = comments_bounds(&xfdf).unwrap(); + assert!(bounds[0] <= 10.0 && bounds[1] <= 10.0 && bounds[2] >= 100.0 && bounds[3] >= 500.0, "{bounds:?}"); + assert_eq!(comment_count("just text"), 0); + assert_eq!(comments_bounds(""), None); + // A reply with a rect of its own (some tools write one, at 0,0 or away from its parent) + // doesn't widen the box the paste is centred on: it has no place on the page. + let with_reply = r#" + + re + "#; + assert_eq!(comments_bounds(with_reply), Some([100.0, 100.0, 140.0, 120.0])); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), Some([100.0, -50.0])).unwrap(); + let moved = summaries(&dst); + let note = moved.iter().find(|s| s.subtype == "Text").unwrap(); + let original = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let (a, b) = (note.rect, original.rect); + assert!((a[0] - b[0] - 100.0).abs() < 0.01 && (a[1] - b[1] + 50.0).abs() < 0.01, "{a:?} from {b:?}"); + let again = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(again.contains("coords=\"110,60,190,60,110,50,190,50\""), "quads moved too: {again}"); + assert!(again.contains("110,-40;120,-20;140,-30"), "ink moved too: {again}"); + // The outlines say what hangs on the pointer: a box for the note, quads for the highlight, + // strokes for the ink, each in its colour; the reply draws nothing of its own. + let outlines = comment_outlines(&xfdf); + assert_eq!(outlines.len(), 3, "{outlines:?}"); + assert!(outlines.iter().any(|o| matches!(o, Outline::Rect { round: false, .. }))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Quads { quads, .. } if quads.len() == 1 && quads[0][0] == 10.0))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Ink { strokes, .. } if strokes == &vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]]))); + assert!(comment_outlines("no xfdf").is_empty()); + let drawn = "\ + \ + 1,1;5,5;9,1\ + 1,1;5,5;9,1\ + \ + \ + "; + let outlines = comment_outlines(drawn); + assert_eq!(outlines.len(), 6, "{outlines:?}"); + assert_eq!(outlines[0], Outline::Line { points: vec![[1.0, 2.0], [3.0, 4.0]], color: Some([1.0, 0.0, 0.0]) }); + assert!(matches!(&outlines[1], Outline::Poly { closed: false, points, .. } if points.len() == 3)); + assert!(matches!(&outlines[2], Outline::Poly { closed: true, .. })); + assert!(matches!(&outlines[3], Outline::Rect { round: true, .. })); + assert!(matches!(&outlines[5], Outline::Line { points, .. } if points == &vec![[20.0, 20.0], [40.0, 50.0]])); + // A non-finite offset is ignored rather than written into the document. + import_comments(&mut dst, &xfdf, Some(1), Some("q"), Some([f64::NAN, 0.0])).unwrap(); + assert_eq!(summaries(&dst).iter().filter(|s| s.page == 1 && s.subtype == "Text").map(|s| s.rect[0]).next(), Some(b[0])); +} + +/// Fill & Sign marks and text boxes copied: the bundle on the pointer shows the tick, cross, dot +/// and line themselves (not empty boxes) and a text box's text, and the pasted copies are drawn +/// with their mark. +#[test] +fn copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted() { + use pdfcraft_annot::FillMark; + let mut src = blank(); + let meta = Meta { date: Some("D:20261020120000Z".into()), id: "m".into() }; + let marks = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line]; + for (k, mark) in marks.into_iter().enumerate() { + let shape = Shape::Mark { rect: [100.0 + 40.0 * k as f64, 600.0, 120.0 + 40.0 * k as f64, 620.0], mark }; + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + } + let text = Shape::TextBox { rect: [100.0, 400.0, 200.0, 430.0], font_size: 14.0 }; + let new = NewAnnotation { page: 0, style: Style::default_for(&text), shape: text, contents: "Abnahme\nok".into(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "protocol.pdf"); + let outlines = comment_outlines(&xfdf); + // The tick is one stroke, the cross two, the dot a circle, the line one stroke: the strokes + // the appearance draws, not the stamps' boxes. + let check = FillMark::Check.strokes([100.0, 600.0, 120.0, 620.0]); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == check[0])), "{outlines:?}"); + assert!(!outlines.iter().any(|o| matches!(o, Outline::Rect { rect, round: false, .. } if *rect == [100.0, 600.0, 120.0, 620.0])), "{outlines:?}"); + let cross = FillMark::Cross.strokes([140.0, 600.0, 160.0, 620.0]); + assert!(cross.iter().all(|s| outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if points == s))), "{outlines:?}"); + assert!( + outlines + .iter() + .any(|o| matches!(o, Outline::Rect { rect, round: true, .. } if (rect[0] - 184.0).abs() < 1e-9 && (rect[2] - 196.0).abs() < 1e-9)), + "{outlines:?}" + ); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == vec![[220.0, 610.0], [240.0, 610.0]])), "{outlines:?}"); + assert!( + outlines.iter().any(|o| matches!(o, Outline::Text { text, size, .. } if text == "Abnahme\nok" && (*size - 14.0).abs() < 1e-9)), + "the text box shows its text at its size: {outlines:?}" + ); + // Pasted into another document, each mark is drawn with its own strokes. + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([10.0, -20.0])).unwrap(); + let page = pdfcraft_model::pages(&dst)[1].obj; + let annots = + dst.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| dst.resolve(&a).as_array().cloned().unwrap_or_default()).unwrap(); + let stamps: Vec = + annots.iter().filter_map(|a| dst.resolve(a).as_dict().cloned()).filter(|d| d.name(b"Subtype") == Some(b"Stamp".as_slice())).collect(); + assert_eq!(stamps.len(), 4); + for d in &stamps { + let ap = d.get(b"AP").map(|a| dst.resolve(a)).and_then(|a| a.as_dict().cloned()).and_then(|a| a.get(b"N").cloned()).expect("an appearance"); + let normal = dst.resolve(&ap); + let Object::Stream(s) = normal.as_ref() else { panic!("no appearance stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + let drawn = + if d.name(b"Name") == Some(b"PCDot".as_slice()) { content.contains(" c\n") || content.contains(" c ") } else { content.contains(" S\n") }; + assert!(drawn, "{:?}: {content}", d.name(b"Name")); + } +} + +/// A pasted copy is what the original was: typewriter text stays typewriter text (`/IT`) and a +/// Fill & Sign item, a cloud keeps its cloudy border, and PdfCraft's own keys (a turned comment's +/// `/PCBox` and `/PCAngle`) come along. A reference into the source document stays behind, and +/// keys that aren't PdfCraft's, or a malformed attribute, are not taken from the attribute. +#[test] +fn copies_keep_their_intent_cloud_and_pdfcraft_keys() { + let mut src = blank(); + let meta = |id: &str| Meta { date: Some("D:20261023120000Z".into()), id: id.into() }; + let typed = Shape::Typewriter { rect: [50.0, 500.0, 150.0, 520.0], font_size: 12.0 }; + let cloud = Shape::Polygon { vertices: vec![[200.0, 200.0], [300.0, 200.0], [250.0, 280.0]], cloud: true }; + let square = Shape::Rectangle { rect: [100.0, 100.0, 180.0, 160.0] }; + for (shape, id) in [(typed, "t1"), (cloud, "c1"), (square, "s1")] { + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: format!("{id} text"), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta(id)).unwrap(); + } + let annots = |doc: &Document, page: usize| -> Vec { + let pages = pdfcraft_model::pages(doc); + let list = pages[page].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).filter(|d| !matches!(d.name(b"Subtype"), Some(b"Popup" | b"Widget"))).collect() + }; + let refs: Vec = { + let pages = pdfcraft_model::pages(&src); + let list = pages[0].dict.get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + list.iter().filter_map(Object::as_ref).collect() + }; + let target = src.add(Object::Dict(Dict::new())); + let square_ref = *refs.iter().find(|r| src.get(**r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Square".as_slice()))).unwrap(); + src.update_dict(square_ref, |d| { + d.set(b"PCBox".to_vec(), arr(&[110.0, 110.0, 170.0, 150.0])); + d.set(b"PCAngle".to_vec(), Object::Real(30.0)); + d.set(b"PCElsewhere".to_vec(), Object::Ref(target)); + }) + .unwrap(); + let which: Vec<(usize, usize)> = summaries(&src).iter().map(|c| (c.page, c.index)).collect(); + let xfdf = export_xfdf_comments(&src, &which, "src.pdf"); + assert!(xfdf.contains("intent=\"FreeTextTypeWriter\"") && xfdf.contains("style=\"cloudy\""), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([5.0, -5.0])).unwrap(); + let copies = annots(&dst, 1); + assert_eq!(copies.len(), 3, "{copies:?}"); + let typed = copies.iter().find(|d| d.name(b"Subtype") == Some(b"FreeText".as_slice())).unwrap(); + assert_eq!(typed.name(b"IT"), Some(b"FreeTextTypeWriter".as_slice()), "{typed:?}"); + assert!(pdfcraft_annot::is_fill_sign(&dst, typed), "still a Fill & Sign item: {typed:?}"); + assert!(matches!(typed.get(b"PCFillSign"), Some(Object::Bool(true))), "{typed:?}"); + let cloud = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Polygon".as_slice())).unwrap(); + assert_eq!(cloud.name(b"IT"), Some(b"PolygonCloud".as_slice())); + let be = cloud.get(b"BE").and_then(Object::as_dict).expect("a cloudy border"); + assert_eq!(be.name(b"S"), Some(b"C".as_slice())); + assert_eq!(be.get(b"I").and_then(Object::as_f64), Some(1.0)); + let square = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Square".as_slice())).unwrap(); + assert_eq!(square.get(b"PCAngle").and_then(Object::as_f64), Some(30.0)); + assert!(square.get(b"PCBox").and_then(Object::as_array).is_some_and(|b| b.len() == 4)); + assert!(square.get(b"PCElsewhere").is_none(), "a reference into the source stays behind: {square:?}"); + // File ▸ Export comments writes the standard attributes only. + let exported = export_xfdf(&src, true, false, "src.pdf"); + assert!(exported.contains("intent=\"FreeTextTypeWriter\"") && !exported.contains("pdfcraft:keys"), "{exported}"); + // A hand-made attribute: only PdfCraft keys with values of their own are taken. + let crafted = format!( + "\ + \ + " + ); + let mut third = blank(); + import_comments(&mut third, &crafted, Some(0), Some("q"), None).unwrap(); + let got = annots(&third, 0); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[0].get(b"PCAngle").and_then(Object::as_f64), Some(45.0)); + assert_eq!(got[0].name(b"Subtype"), Some(b"Square".as_slice()), "not taken from the attribute"); + assert!(got[0].get(b"PCRef").is_none() && got[1].get(b"PCAngle").is_none(), "{got:?}"); +} diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index 06ad9f34ec..e2137a98f0 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -6,22 +6,188 @@ use super::*; // ── export ────────────────────────────────────────────────────────────────────────────────── -/// Every comment as an XFDF element (one line per comment, replies included). -fn xfdf_annots(doc: &Document, out: &mut String) { +/// The annotations of `which` (0-based page, position in the page's `/Annots`) as positions, +/// with every reply to them (and to those replies), so a thread travels whole. Positions, not +/// references: other tools write comments straight into the page's array, without an object of +/// their own, and those copy too. +fn annots_with_replies(doc: &Document, which: &[(usize, usize)]) -> HashSet<(usize, usize)> { let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. + let annots = |p: &pdfcraft_model::Page| -> Vec { + p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() + }; + /// An annotation's position, its reference (when it has one) and its parent's (`/IRT`). + struct Entry { + pos: (usize, usize), + obj: Option, + parent: Option, + } + let all: Vec = pages + .iter() + .enumerate() + .flat_map(|(pi, p)| { + annots(p).into_iter().enumerate().map(move |(ai, a)| { + let parent = doc.resolve(&a).as_dict().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref); + Entry { pos: (pi, ai), obj: a.as_ref(), parent } + }) + }) + .collect(); + let mut set: HashSet<(usize, usize)> = which.iter().copied().filter(|w| all.iter().any(|e| e.pos == *w)).collect(); + // Replies point at their parent through /IRT: a pass per reply depth, bounded by the count. + for _ in 0..all.len() { + let before = set.len(); + let parents: HashSet = all.iter().filter(|e| set.contains(&e.pos)).filter_map(|e| e.obj).collect(); + for e in &all { + if e.parent.is_some_and(|parent| parents.contains(&parent)) { + set.insert(e.pos); + } + } + if set.len() == before { + break; + } + } + set +} + +/// Every comment as an XFDF element (one line per comment, replies included), or only those at +/// the positions in `only`. +/// The namespace of PdfCraft's own attributes in XFDF (`pdfcraft:keys`): other readers skip them. +pub(crate) const PDFCRAFT_NS: &str = "https://github.com/storytold/pdfcraft#xfdf"; +/// At most this much text in a `pdfcraft:keys` attribute is read. +const MAX_OWN_KEYS: usize = 64 * 1024; + +/// A value that means the same in another document: no reference or stream into this one. +fn portable(o: &Object, depth: usize) -> bool { + match o { + Object::Ref(_) | Object::Stream(_) => false, + Object::Array(a) => depth < 4 && a.iter().all(|o| portable(o, depth + 1)), + Object::Dict(d) => depth < 4 && d.iter().all(|(_, o)| portable(o, depth + 1)), + _ => true, + } +} + +/// The annotation's own PdfCraft keys (`/PC…`: a Fill & Sign item, a turned comment's box and +/// angle, …) as one PDF dictionary, for a copy that is what the original was. Values that point +/// into this document stay behind. +fn own_keys(d: &Dict) -> Option { + let mut keys = Dict::new(); + // Each value must also write as text: XML can't hold raw bytes. + let writes_as_text = |v: &Object| { + let mut out = Vec::new(); + pdfcraft_cos::serialize(v, &mut out); + std::str::from_utf8(&out).is_ok() + }; + for (k, v) in d.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0) && writes_as_text(v)) { + keys.set(k.clone(), v.clone()); + } + if keys.is_empty() { + return None; + } + let mut out = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(keys), &mut out); + String::from_utf8(out).ok() +} + +/// The `pdfcraft:keys` of an XFDF comment back as dictionary entries: PdfCraft's keys only, with +/// values that mean the same here. Anything malformed is skipped. +fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { + let Some(text) = node.attribute((PDFCRAFT_NS, "keys")).filter(|t| t.len() <= MAX_OWN_KEYS) else { return }; + let Ok(Object::Dict(keys)) = pdfcraft_cos::Lexer::new(text.as_bytes(), 0).object() else { return }; + for (k, v) in keys.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0)) { + if !d.contains(k) { + d.set(k.clone(), v.clone()); + } + } +} + +/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one +/// (other tools leave /NM out), a name new to the document for this export, so a reply copied +/// with it still finds it. +fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap { let mut names: HashMap = HashMap::new(); - for p in &pages { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) - { - names.insert(r, nm); + let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new(); + // Every name in use, those of annotations written straight into the page array too, so a + // given name is new. + let mut taken: HashSet = HashSet::new(); + for (pi, p) in pages.iter().enumerate() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM")); + if let Some(nm) = &nm { + taken.insert(nm.clone()); + } + let Some(r) = a.as_ref() else { continue }; + match nm { + Some(nm) => { + names.insert(r, nm); + } + None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)), + None => {} } } } + for (pi, ai, r) in unnamed { + let mut name = format!("pdfcraft-{pi}-{ai}"); + while taken.contains(&name) { + name.push('x'); + } + taken.insert(name.clone()); + names.insert(r, name); + } + names +} + +/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices +/// and pop-up (on page `pi`). +fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { + if let Some(c) = text_of(doc, d, b"Contents") { + let _ = write!(out, "{}", esc(&c)); + } + if let Some(da) = text_of(doc, d, b"DA") { + let _ = write!(out, "{}", esc(&da)); + } + let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + if !ink.is_empty() { + out.push_str(""); + for g in ink { + let v = nums_of(doc, Some(&g)); + let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + let _ = write!(out, "{}", pts.join(";")); + } + out.push_str(""); + } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } + if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + let r = nums_of(doc, pd.get(b"Rect")); + if r.len() == 4 { + let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); + let _ = write!( + out, + "", + n(r[0]), + n(r[1]), + n(r[2]), + n(r[3]), + if open { "yes" } else { "no" } + ); + } + } +} + +/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste. +fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) { + let pages = pdfcraft_model::pages(doc); + // NM of every annotation, for inreplyto. A selected annotation without one (other tools + // leave /NM out) gets a name for this export, so a reply copied with it still finds it. + let names = annotation_names(doc, &pages, only); for (pi, p) in pages.iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + if only.is_some_and(|only| !only.contains(&(pi, ai))) { + continue; + } let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; let Some(sub) = d.name(b"Subtype") else { continue }; let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; @@ -37,9 +203,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) { n(rect[1].max(rect[3])) ); } + // The name: /NM, or the one given above for this export. + if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) { + let _ = write!(attrs, " name=\"{}\"", esc(&nm)); + } for (key, attr) in [ - (&b"NM"[..], "name"), - (b"T", "title"), + (&b"T"[..], "title"), (b"Subj", "subject"), (b"M", "date"), (b"CreationDate", "creationdate"), @@ -81,7 +250,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); } - // A callout: its leader path, text-box inset, intent and (single-name) line ending. + // A callout: its leader path, text-box inset and (single-name) line ending. let cl = nums_of(doc, d.get(b"CL")); if cl.len() == 4 || cl.len() == 6 { let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); @@ -89,54 +258,31 @@ fn xfdf_annots(doc: &Document, out: &mut String) { if rd.len() == 4 { let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); } - if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { let _ = write!(attrs, " head=\"{}\"", esc(&le)); } } + // What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow. + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + // A cloudy border (/BE /S /C) and how far its arcs bulge. + if let Some(be) = + d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice())) + { + attrs.push_str(" style=\"cloudy\""); + if let Some(i) = be.get(b"I").and_then(Object::as_f64) { + let _ = write!(attrs, " intensity=\"{}\"", n(i)); + } + } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); } + if own && let Some(keys) = own_keys(&d) { + let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys)); + } let _ = write!(out, "<{tag}{attrs}>"); - if let Some(c) = text_of(doc, &d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); - } - if let Some(da) = text_of(doc, &d, b"DA") { - let _ = write!(out, "{}", esc(&da)); - } - let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - if !ink.is_empty() { - out.push_str(""); - for g in ink { - let v = nums_of(doc, Some(&g)); - let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - let _ = write!(out, "{}", pts.join(";")); - } - out.push_str(""); - } - // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). - let vertices = nums_of(doc, d.get(b"Vertices")); - let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - if !pts.is_empty() { - let _ = write!(out, "{}", pts.join(";")); - } - if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { - let r = nums_of(doc, pd.get(b"Rect")); - if r.len() == 4 { - let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); - let _ = write!( - out, - "", - n(r[0]), - n(r[1]), - n(r[2]), - n(r[3]), - if open { "yes" } else { "no" } - ); - } - } + xfdf_annot_children(doc, out, &d, pi); let _ = writeln!(out, ""); } } @@ -191,12 +337,235 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } } +/// XFDF with these comments alone (0-based page and position in the page's `/Annots`), each +/// with its replies: what Copy puts on the clipboard, and Export selected comments writes. +pub fn export_xfdf_comments(doc: &Document, which: &[(usize, usize)], file: &str) -> String { + let only = annots_with_replies(doc, which); + let mut out = format!( + "\n\n" + ); + out.push_str("\n"); + xfdf_annots(doc, &mut out, Some(&only), true); + out.push_str("\n"); + let _ = writeln!(out, "", esc(file)); + out.push_str("\n"); + out +} + +/// Whether `text` is XFDF with comments in it (what a paste of copied comments carries). +pub fn is_xfdf_comments(text: &str) -> bool { + let head: String = text.chars().take(400).collect(); + head.contains(" }, + /// A line or arrow, and a callout's leader. + Line { points: Vec<[f64; 2]>, color: Option<[f64; 3]> }, + /// A polygon (closed) or connected lines. + Poly { points: Vec<[f64; 2]>, closed: bool, color: Option<[f64; 3]> }, + /// Freehand strokes. + Ink { strokes: Vec>, color: Option<[f64; 3]> }, + /// Text markup: each quad is x1,y1 (upper left), x2,y2 (upper right), x3,y3 (lower left), x4,y4. + Quads { quads: Vec<[f64; 8]>, color: Option<[f64; 3]> }, + /// A text box's text, from the top-left of its box (`rect`, which it wraps in), at its font + /// `size` in points and in its text colour. + Text { rect: [f64; 4], text: String, size: f64, color: Option<[f64; 3]> }, +} + +/// Every comment element of an XFDF text, in order (none when it is no XFDF). +fn with_comment_nodes(text: &str, mut f: impl FnMut(roxmltree::Node<'_, '_>)) { + if check_form_xml_depth(text).is_err() { + return; + } + let Ok(xml) = parse_form_xml(text) else { return }; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return; + } + let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) else { return }; + for node in annots.children().filter(|c| c.is_element()) { + if SUBTYPES.iter().any(|(_, t)| *t == node.tag_name().name().to_ascii_lowercase()) { + f(node); + } + } +} + +/// The comment elements of an XFDF text: tag, `inreplyto`, rect. +fn comment_nodes(text: &str) -> Vec<(String, Option, Vec)> { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + out.push(( + node.tag_name().name().to_owned(), + node.attribute("inreplyto").map(str::to_owned), + node.attribute("rect").map(csv).unwrap_or_default(), + )); + }); + out +} + +/// Pairs `x,y;x,y;…` (XFDF vertices and ink gestures). +fn pairs(text: &str) -> Vec<[f64; 2]> { + text.split(';').filter_map(|p| if let [x, y] = csv(p).as_slice() { Some([*x, *y]) } else { None }).collect() +} + +/// The outlines of the comments in an XFDF text (replies, which have no place of their own, +/// left out): what a paste shows hanging on the pointer. +pub fn comment_outlines(text: &str) -> Vec { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + if node.attribute("inreplyto").is_some() { + return; + } + let tag = node.tag_name().name().to_ascii_lowercase(); + let color = node.attribute("color").and_then(parse_hex); + let rect = node + .attribute("rect") + .map(csv) + .filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite())) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); + match tag.as_str() { + "line" => { + if let (Some(a), Some(b)) = (node.attribute("start").map(csv), node.attribute("end").map(csv)) + && let ([ax, ay], [bx, by]) = (a.as_slice(), b.as_slice()) + { + out.push(Outline::Line { points: vec![[*ax, *ay], [*bx, *by]], color }); + } + } + "polygon" | "polyline" => { + let points = node.children().find(|c| c.has_tag_name("vertices")).and_then(|v| v.text()).map(pairs).unwrap_or_default(); + if points.len() >= 2 { + out.push(Outline::Poly { points, closed: tag == "polygon", color }); + } + } + "ink" => { + let strokes: Vec> = node + .children() + .find(|c| c.has_tag_name("inklist")) + .map(|list| { + list.children().filter(|g| g.has_tag_name("gesture")).filter_map(|g| g.text()).map(pairs).filter(|s| !s.is_empty()).collect() + }) + .unwrap_or_default(); + if !strokes.is_empty() { + out.push(Outline::Ink { strokes, color }); + } + } + "highlight" | "underline" | "strikeout" | "squiggly" | "redact" => { + let coords = node.attribute("coords").map(csv).unwrap_or_default(); + let quads: Vec<[f64; 8]> = coords.as_chunks::<8>().0.to_vec(); + if quads.is_empty() { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: false, color }); + } + } else { + out.push(Outline::Quads { quads, color }); + } + } + // A Fill & Sign mark: its tick, cross, dot or line, as its appearance draws it. + "stamp" + if let Some(rect) = rect + && let Some(mark) = node.attribute("icon").and_then(|i| pdfcraft_annot::FillMark::from_name(i.as_bytes())) => + { + if mark == pdfcraft_annot::FillMark::Dot { + let r = (rect[2] - rect[0]).min(rect[3] - rect[1]) * 0.3; + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + out.push(Outline::Rect { rect: [cx - r, cy - r, cx + r, cy + r], round: true, color }); + } + for points in mark.strokes(rect) { + out.push(Outline::Line { points, color }); + } + } + _ => { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: tag == "circle", color }); + } + // A callout's leader, from the box to what it points at. + let callout = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6); + if tag == "freetext" + && let Some(cl) = &callout + { + out.push(Outline::Line { points: cl.as_chunks::<2>().0.to_vec(), color }); + } + // A text box's text, in its own colour and size, inside the box (a callout's box + // is its rect inset by the fringe). + if tag == "freetext" + && let Some(rect) = rect + && let Some(text) = node.children().find(|c| c.has_tag_name("contents")).and_then(|c| c.text()).filter(|t| !t.trim().is_empty()) + { + let mut da = Dict::new(); + if let Some(s) = node.children().find(|c| c.has_tag_name("defaultappearance")).and_then(|c| c.text()) { + da.set(b"DA".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + } + let (text_color, size) = pdfcraft_annot::appearance::parse_da(&da); + let fringe = node.attribute("fringe").map(csv).filter(|f| callout.is_some() && f.len() == 4 && f.iter().all(|v| v.is_finite())); + let rect = match fringe.as_deref() { + Some([l, b, r, t]) => [rect[0] + l, rect[1] + b, rect[2] - r, rect[3] - t], + _ => rect, + }; + out.push(Outline::Text { rect, text: text.replace("\r\n", "\n").replace('\r', "\n"), size, color: Some(text_color) }); + } + } + } + }); + out +} + +/// How many comments (replies not counted) an XFDF text holds. +pub fn comment_count(text: &str) -> usize { + comment_nodes(text).iter().filter(|(_, irt, _)| irt.is_none()).count() +} + +/// The box around every comment in an XFDF text, in the pages' user space: [x0, y0, x1, y1], +/// or `None` without a comment with a rect. What a paste shows hanging on the pointer and centres +/// on the point: replies, which have no place of their own (some tools still give them a rect, at +/// 0,0 or away from their parent), are left out, as [`comment_outlines`] leaves them out. +pub fn comments_bounds(text: &str) -> Option<[f64; 4]> { + let mut bounds: Option<[f64; 4]> = None; + for (_, in_reply_to, rect) in comment_nodes(text) { + if in_reply_to.is_some() { + continue; + } + if let [x0, y0, x1, y1] = rect.as_slice() + && rect.iter().all(|v| v.is_finite()) + { + let r = [x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]; + bounds = Some(match bounds { + None => r, + Some(b) => [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])], + }); + } + } + bounds +} + +/// Move an annotation's geometry by (dx, dy) in user space: its rect, quads, vertices, ink, +/// line ends and callout leader (what a copy placed elsewhere needs). +fn translate(d: &mut Dict, dx: f64, dy: f64) { + let shift = |o: &Object| -> Option { + let v: Vec = o.as_array()?.iter().map(|x| x.as_f64()).collect::>>()?; + Some(arr(&v.iter().enumerate().map(|(i, x)| if i % 2 == 0 { x + dx } else { x + dy }).collect::>())) + }; + for key in [&b"Rect"[..], b"QuadPoints", b"Vertices", b"L", b"CL"] { + if let Some(moved) = d.get(key).and_then(shift) { + d.set(key.to_vec(), moved); + } + } + if let Some(Object::Array(strokes)) = d.get(b"InkList").cloned() { + let moved: Vec = strokes.iter().map(|s| shift(s).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } +} + /// XFDF with the document's comments and/or field values. pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { let mut out = String::from("\n\n"); if comments { out.push_str("\n"); - xfdf_annots(doc, &mut out); + xfdf_annots(doc, &mut out, None, false); out.push_str("\n"); } if fields { @@ -311,6 +680,16 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } + if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { + d.set(b"IT".to_vec(), Object::name(it)); + } + if node.attribute("style") == Some("cloudy") { + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + let intensity = node.attribute("intensity").and_then(|i| i.parse::().ok()).filter(|i| i.is_finite()).unwrap_or(1.0); + be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0))); + d.set(b"BE".to_vec(), Object::Dict(be)); + } if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { d.set(b"CL".to_vec(), arr(&cl)); if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { @@ -352,6 +731,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt _ => {} } } + read_own_keys(node, &mut d); Some(d) } @@ -472,7 +852,32 @@ pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: Ok(()) } +/// Import XFDF comments as copies: every comment lands on `page` (0-based; its own page when +/// `None`) and, with `fresh` (a prefix unique to this import), under a new name, so nothing in +/// the document is replaced and pasting the same comments twice gives two copies. Replies keep +/// their thread among the pasted comments; a reply to a comment that was not copied stands +/// alone. Field values in the file are ignored. +pub fn import_comments( + doc: &mut Document, + text: &str, + page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { + import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite()))) +} + pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { + import_xfdf_with(doc, text, None, None, None) +} + +fn import_xfdf_with( + doc: &mut Document, + text: &str, + target_page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; let root = xml.root_element(); @@ -481,28 +886,65 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + if let Some(p) = target_page + && p >= pages.len() + { + return Err(DataError::Malformed(format!("page {} is not in the document", p + 1))); + } // Comments: parents first, then replies (whose /IRT needs the parent's reference). let mut by_name: HashMap = HashMap::new(); let mut later: HashMap = HashMap::new(); + // Fresh names: every copy gets one from its own position, and `name` → the first copy's, + // so replies find their copied parent (a name two source comments share, as other tools + // write, still gives two copies). + let mut renamed: HashMap = HashMap::new(); + if let (Some(prefix), Some(annots)) = (fresh, root.children().find(|c| c.has_tag_name("annots"))) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { + if let Some(nm) = node.attribute("name") { + renamed.entry(nm.to_string()).or_insert_with(|| format!("{prefix}-{}", n + 1)); + } + } + } if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { - for node in annots.children().filter(|c| c.is_element()) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { let tag = node.tag_name().name().to_ascii_lowercase(); let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; - let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; - let Some(d) = annot_from_xml(node, subtype, page) else { continue }; + let own_page = node.attribute("page").and_then(|p| p.parse::().ok()); + let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue }; + let Some(mut d) = annot_from_xml(node, subtype, page) else { continue }; + let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1)); + if let Some(name) = &assigned { + // A copy under a name of its own (one even where the original had none). + d.set(b"NM".to_vec(), PdfString::text(name)); + } + if let Some([dx, dy]) = offset { + translate(&mut d, dx, dy); + } let r = place(doc, page, d)?; // A later same-name record replaces this comment, including its reply relation. later.remove(&r); - if let Some(nm) = node.attribute("name") { - by_name.insert(nm.to_string(), r); + if let Some(name) = assigned.clone().or_else(|| node.attribute("name").map(str::to_owned)) { + by_name.insert(name, r); } if let Some(irt) = node.attribute("inreplyto") { - later.insert(r, irt.to_string()); + match (fresh, renamed.get(irt)) { + // The parent is among the copies: the reply follows it. + (Some(_), Some(parent)) => later.insert(r, parent.clone()), + // A copy's parent was not copied: the reply stands alone rather than + // attaching to a same-named comment the document happens to have. + (Some(_), None) => None, + (None, _) => later.insert(r, irt.to_string()), + }; } // A pop-up window. if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) - && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) + && let Some(mut rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) { + if let Some([dx, dy]) = offset { + for (i, v) in rect.iter_mut().enumerate() { + *v += if i % 2 == 0 { dx } else { dy }; + } + } add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; } report.comments += 1; @@ -513,6 +955,10 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result Date: Thu, 5 Nov 2026 17:08:08 +0100 Subject: [PATCH 24/59] Fill & Sign: a Border control for typed text, and a border colour of its own (#4146) * Fill & Sign: a Border control for typed text, and a border colour of its own Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the same places as the border (the open box, typed(), the page input, the toolbar and the preferences), so every step of the branch met it again. Text colour and border now travel together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's argument limit; a border without its own colour takes the text's. Make Current Properties Default on typed text also keeps its colour for new text now. The branch's commits, in order: - Typewriter text: a border change grows its box on every side, so the text keeps its place - Fill & Sign: a Border control for typed text and dates - Parity: the typewriter border's evidence - i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml - Text boxes, typewriter text and callouts: a border colour of their own - Fill & Sign: the toolbar follows the selected text; a Border colour button - fill_sign_add and comment_edit take `border_color` - Parity: the border colour and the toolbar following the selection - i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml - Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored - Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border - Review: Make Properties Default gives a text box's or callout's tool its border colour - i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml - Review: a test that dragging the Font size control resizes the selected text to where the drag ends - Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox Co-Authored-By: Claude Opus 5.5 * Open the Font size popup first in the drag test: main moved the size into a popup --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- crates/annot/src/appearance.rs | 31 ++- crates/annot/src/create.rs | 7 +- crates/annot/src/info.rs | 3 + crates/annot/src/lib.rs | 5 +- crates/annot/src/modify.rs | 50 ++++- crates/annot/src/tests.rs | 79 ++++++++ crates/annot/src/types.rs | 5 +- crates/automation/src/comments.rs | 27 ++- crates/automation/src/tools.rs | 7 +- crates/automation/tests/it/automation.rs | 42 ++++ crates/engine/src/edit.rs | 7 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/prefs.rs | 30 +++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/page_input.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 46 ++++- crates/ui-egui/src/editing.rs | 4 +- crates/ui-egui/src/hotbar.rs | 75 +++++++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/panels/comments_panel.rs | 2 +- crates/ui-egui/src/tools/comments.rs | 27 ++- crates/ui-egui/src/tools/comments/menu.rs | 31 ++- crates/ui-egui/src/tools/fill_sign.rs | 89 +++++++-- crates/ui-egui/tests/it/comments.rs | 31 +++ crates/ui-egui/tests/it/control.rs | 1 + crates/ui-egui/tests/it/fill_sign.rs | 208 +++++++++++++++++++- crates/ui-egui/tests/it/ui.rs | 18 ++ crates/ui-egui/tests/it/viewer.rs | 10 +- parity/acrobat-features.toml | 8 +- 32 files changed, 811 insertions(+), 51 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index c2f7f5e53d..9cdb548360 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -232,6 +232,31 @@ fn rg_stroke(c: Rgb) -> String { format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2])) } +/// A FreeText's border colour where it has its own: the stroking colour in `/DA` (`RG`, or `G` +/// for grey). `None`: the border takes the text colour. +pub fn da_border(d: &Dict) -> Option { + let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned())?; + let toks: Vec<&str> = da.split_whitespace().collect(); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|x| x.is_finite()); + let mut col = None; + for (i, t) in toks.iter().enumerate() { + match *t { + "RG" if i >= 3 => { + if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) { + col = Some([r, g, b].map(|x| x.clamp(0.0, 1.0))); + } + } + "G" if i >= 1 => { + if let Some(g) = num(i - 1) { + col = Some([g.clamp(0.0, 1.0); 3]); + } + } + _ => {} + } + } + col +} + /// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); @@ -921,6 +946,8 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let mut res = std::mem::take(&mut drawing.resources); let _tail = std::mem::take(&mut drawing.tail); let (text_color, size) = parse_da(d); + // The box's border and a callout's leader line: their own colour, else the text's. + let line_color = da_border(d).unwrap_or(text_color); let bg = stroke; let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; // A callout: the leader line (arrowhead at its first point), and the text box inside @@ -959,7 +986,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { return None; } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); } @@ -975,7 +1002,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let h = bw / 2.0; c.push_str(&format!( "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), + rg_stroke(line_color), n(bw), dash(d), n(rect[0] + h), diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 298dc67f41..f12b2bba3b 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{n, rich_text, set_part_keys}; +use super::modify::{free_text_da, n, rich_text, set_part_keys}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -385,8 +385,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); } let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size)); d.set(b"Q".to_vec(), Object::Int(0)); if let Some(f) = style.fill { d.set(b"C".to_vec(), rgb(f)); @@ -426,7 +425,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } fn validate_style(style: &Style) -> Result<(), AnnotError> { - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() { return Err(AnnotError::Invalid("invalid style".into())); } Ok(()) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index ce001bcf29..cf1b607664 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -217,6 +217,8 @@ pub struct Props { pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). + pub border_color: Option, /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its /// line's or border's). pub fill_opacity: Option, @@ -253,6 +255,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fillable: fillable(&subtype), fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6d555dbb9c..270be0115d 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,8 +41,9 @@ pub use lookup::{ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, + set_border_color, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, + set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 469815af7f..8dbfd4c5b9 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -528,6 +528,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } +/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border` +/// (another app's), else 1. fn border_width_of(d: &Dict) -> f64 { appearance::border_width(d) } @@ -582,13 +584,24 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + // Typewriter text has no box of its own: a border goes around its text, so the box grows + // (or shrinks) by the change on every side and the text keeps its place and its lines. + let typewriter = free_text && d.name(b"IT") == Some(b"FreeTextTypeWriter") && !d.contains(b"CL"); + let regrow = width.filter(|_| typewriter).and_then(|w| { + let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; + let rect: Vec = d.get(b"Rect")?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + let rect = grow(normalize(four(&rect)?), by); + (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some(rect) + }); let before = d; doc.update_dict(r, |d| { + if let Some(rect) = regrow { + d.set(b"Rect".to_vec(), num_array(&rect)); + } if let Some(c) = color { if free_text { let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size)); rich_text(d); } else { d.set(b"C".to_vec(), rgb(c)); @@ -627,6 +640,37 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha redraw_or_restore(doc, r, before) } +/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking +/// colour, `RG`), Helvetica at `size`. +pub(crate) fn free_text_da(text: Rgb, border: Option, size: f64) -> PdfString { + let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0)); + let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default(); + PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()) +} + +/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's +/// again), and redraw it. +pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into())); + } + if color.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (text, size) = appearance::parse_da(d); + d.set(b"DA".to_vec(), free_text_da(text, color, size)); + rich_text(d); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the /// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new /// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. @@ -644,7 +688,7 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m let before = d; doc.update_dict(r, |d| { let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size)); rich_text(d); touch(d, meta); })?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 91b36550cf..f451ad0f8e 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -668,6 +668,85 @@ fn fill_and_sign_items_are_drawn() { assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } +/// A border chosen for typewriter text goes around it in the text's colour. Changing it grows or +/// shrinks the box on every side, so the text stays where it was typed; a text box keeps its box. +#[test] +fn a_typewriter_border_goes_around_its_text_which_stays_put() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + // The text starts 2 pt inside the border: 100 + 1 + 2 across, 716 - 1 - 2 - 0.9 em down. + let at = "1 0 0 1 103 703.1 Tm"; + let d = &list(&doc, 1)[t]; + let ap = ap_content(&doc, d); + assert!(ap.contains("1 w\n") && ap.contains(" re S"), "a 1 pt border: {ap}"); + assert!(ap.contains(at), "{ap}"); + for (width, expected) in [(2.0, [99.0, 699.0, 201.0, 717.0]), (0.0, [101.0, 701.0, 199.0, 715.0])] { + set_style(&mut doc, 1, t, StyleChange::new(None, None, Some(width), None), &meta("")).unwrap(); + let d = &list(&doc, 1)[t]; + assert_eq!(rect(d), expected, "{width} pt border"); + let ap = ap_content(&doc, d); + assert!(ap.contains(at), "{width} pt: the text keeps its place: {ap}"); + assert_eq!(ap.contains(" re S"), width > 0.0, "{width} pt: {ap}"); + } + let shape = Shape::TextBox { rect: [100.0, 600.0, 200.0, 650.0], font_size: 11.0 }; + let b = add_annotation(&mut doc, &new(1, shape), &meta("b")).unwrap(); + set_style(&mut doc, 1, b, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[b]), [100.0, 600.0, 200.0, 650.0], "a text box keeps the box it was drawn with"); + // Another app's typed text with its width in /Border (no /BS): 4 pt, so 2 pt shrinks the box + // by 2 on every side, as it is drawn. + let shape = Shape::Typewriter { rect: [100.0, 500.0, 200.0, 530.0], font_size: 11.0 }; + let o = add_annotation( + &mut doc, + &NewAnnotation { page: 1, style: Style::default_for(&shape), shape: shape.clone(), contents: "Bo".into(), author: "Ada".into() }, + &meta("o"), + ) + .unwrap(); + let (_, r) = annot_ref(&mut doc, 1, o).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), num_array(&[0.0, 0.0, 4.0])); + }) + .unwrap(); + set_style(&mut doc, 1, o, StyleChange::new(None, None, Some(2.0), None), &meta("")).unwrap(); + assert_eq!(rect(&list(&doc, 1)[o]), [102.0, 502.0, 198.0, 528.0], "the imported 4 pt border is read from /Border"); + // A border colour that isn't a colour is refused before anything is written. + let mut bad = NewAnnotation { page: 1, style: Style::default_for(&shape), shape, contents: "x".into(), author: "Ada".into() }; + bad.style.border_color = Some([f64::NAN, 0.0, 0.0]); + let before = list(&doc, 1).len(); + assert!(matches!(add_annotation(&mut doc, &bad, &meta("n")), Err(AnnotError::Invalid(_)))); + assert_eq!(list(&doc, 1).len(), before); +} + +/// Text keeps its colour and the border gets its own: the stroking colour in `/DA`, which a new +/// text colour or font size keeps, and which `None` gives back to the text's. +#[test] +fn a_text_border_can_have_its_own_colour() { + let mut doc = fixture(); + let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }; + let style = Style { color: [0.0, 0.5, 0.0], width: 1.0, border_color: Some([1.0, 0.0, 0.0]), ..Style::default_for(&shape) }; + let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap(); + let da = |doc: &Document| text(&list(doc, 1)[t], b"DA"); + assert_eq!(da(&doc), "0 0.5 0 rg 1 0 0 RG /Helv 11 Tf"); + let ap = ap_content(&doc, &list(&doc, 1)[t]); + assert!(ap.contains("1 0 0 RG\n1 w"), "the border in red: {ap}"); + assert!(ap.contains("0 0.5 0 rg"), "the text in green: {ap}"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, Some([1.0, 0.0, 0.0])); + set_style(&mut doc, 1, t, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 11 Tf", "a new text colour keeps the border's"); + set_font_size(&mut doc, 1, t, 14.0, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 14 Tf", "so does a new size"); + set_border_color(&mut doc, 1, t, Some([0.5, 0.5, 0.5]), &meta("")).unwrap(); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0.5 0.5 0.5 RG\n1 w")); + set_border_color(&mut doc, 1, t, None, &meta("")).unwrap(); + assert_eq!(da(&doc), "0 0 1 rg /Helv 14 Tf"); + assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0 0 1 RG\n1 w"), "the text's colour again"); + assert_eq!(props(&doc, 1, t).unwrap().border_color, None); + let m = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("m")).unwrap(); + assert!(set_border_color(&mut doc, 0, m, Some([1.0, 0.0, 0.0]), &meta("")).is_err(), "a rectangle's colour is its border"); +} + #[test] fn author_subject_and_note_icon_change() { let mut doc = fixture(); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 935eff8759..abc5c17601 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -588,6 +588,9 @@ pub struct Style { pub width: f64, /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. pub fill: Option, + /// A text box's, typewriter text's or callout's border colour where it differs from the + /// text's (`None`: the text colour), the stroking colour in its `/DA`. + pub border_color: Option, /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the /// line's or border's); `None`: as `opacity`. pub fill_opacity: Option, @@ -624,7 +627,7 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None } + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None, border_color: None } } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 6adc95b2ab..b8b815ac37 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -553,6 +553,18 @@ impl Automation { if !(4.0..=300.0).contains(&size) { return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into())); } + let requested_border = a.opt_num("border")?; + if requested_border.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("border is only for type text or date".into())); + } + let border = requested_border.unwrap_or(0.0); + if !(0.0..=12.0).contains(&border) { + return Err(ToolError::InvalidArgs("border must be 0–12 pt".into())); + } + let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?; + if border_color.is_some() && border <= 0.0 { + return Err(ToolError::InvalidArgs("border_color needs a border".into())); + } if let Some(path) = a.opt_str("path")? { if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() { return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into())); @@ -581,8 +593,9 @@ impl Automation { let w = (longest + 8.0).clamp(20.0, 600.0); let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); let h = rows as f64 * size * 1.2 + 6.0; - let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); - Shape::Typewriter { rect, font_size: size } + let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); + // A border goes around the box, so the text sits where it would without one. + Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size } }; let (shape, contents) = match kind { "text" => { @@ -628,6 +641,10 @@ impl Automation { if let Some(color) = requested_color { style.color = color; } + if matches!(shape, Shape::Typewriter { .. }) { + style.width = border; + style.border_color = border_color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -694,6 +711,10 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + if let Some(c) = a.opt_str("border_color")? { + let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) }; + edits.push(Edit::BorderColorAnnotation { page, index, color }); + } // After `opacity`, which sets the whole comment's. let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { @@ -735,7 +756,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b3b149fbc2..d15c1d915f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -994,7 +994,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -1003,6 +1003,7 @@ pub fn tools() -> Vec { "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, + "border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1407,7 +1408,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1419,6 +1420,8 @@ pub fn tools() -> Vec { "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, "color": color(), + "border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." }, + "border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f0426fa3fc..a4ee239a62 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4074,6 +4074,48 @@ fn fill_sign_text_and_date_colours_are_saved_and_validated() { } } +#[test] +fn fill_sign_text_border_is_saved_and_validated() { + let dir = workdir("fill-sign-text-border"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Plain" })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "Plain", "border": 1.5 })); + let comments = ok(&mut a, "comment_list", json!({ "doc": doc })); + let width = |k: usize| { + let rect = comments["comments"][k]["rect"].as_array().unwrap(); + rect[2].as_f64().unwrap() - rect[0].as_f64().unwrap() + }; + assert!((width(1) - width(0) - 3.0).abs() < 1e-6, "1.5 pt on each side: {comments}"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "framed.pdf" })); + let saved = std::fs::read(dir.join("framed.pdf")).unwrap(); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotation = pdf.resolve(annotations.as_array().unwrap().get(1).unwrap()); + let bs = annotation.as_dict().unwrap().get(b"BS").unwrap().as_dict().unwrap().get(b"W").unwrap().as_f64().unwrap(); + assert_eq!(bs, 1.5, "saved annotation has the requested border"); + // Its own colour, then the text's again through comment_edit. + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 160], "text": "Red", "border": 1, "border_color": "#ff0000" }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let red = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "Red").unwrap()["index"].clone(); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": red, "border_color": "text" })); + assert!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 200], "text": "No", "border_color": "red" })).is_err(), + "no border" + ); + for (kind, border) in [("text", -1.0), ("text", 13.0), ("check", 1.0)] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 120], "text": "No", "border": border })), + Err(ToolError::InvalidArgs(_)) + )); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index e398604766..ab3393a0f5 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -278,6 +278,13 @@ pub enum Edit { index: usize, size: f64, }, + /// Give a text box, typewriter text or callout a border colour of its own (`None`: the + /// text's again). + BorderColorAnnotation { + page: usize, + index: usize, + color: Option, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 0c7046cdbb..a244d70c93 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -236,6 +236,7 @@ impl Edit { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 27eee7e428..3d24a22fa2 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -67,6 +67,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -587,6 +588,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 643f1d66a3..c679406154 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -71,6 +71,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::FillAnnotation { .. } | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } + | Edit::BorderColorAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9dd3efac6e..7c7710b5b8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -110,6 +110,9 @@ impl PdfCraftApp { fill_text_size: fill_sign::TEXT_SIZE, fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], + fill_text_border: 0.0, + fill_text_border_color: None, + fill_text_default: fill_sign::TextBorder::NONE, dialog: None, updates: updates::Updates::default(), printer_source: None, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5f9297c2ab..ee33caeb5c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -103,6 +103,8 @@ impl PdfCraftApp { "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, "fill_text_size": self.fill_text_size, + "fill_text_border": self.fill_text_default.width, + "fill_text_border_color": self.fill_text_default.color, "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(), @@ -120,6 +122,7 @@ impl PdfCraftApp { "text_opacity": s.text_opacity, "width": s.width, "fill": s.fill, + "border_color": s.border_color, })) .collect::>(), // Drawn signatures keep their original form (older settings read the same). @@ -310,6 +313,22 @@ impl PdfCraftApp { if let Some(show) = v["show_page_edge_shadows"].as_bool() { self.show_page_edge_shadows = show; } + if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) { + self.fill_text_default.width = width; + } + // `null` is a saved "the text's colour"; a missing or malformed one keeps the setting. + match v.get("fill_text_border_color") { + Some(serde_json::Value::Null) => self.fill_text_default.color = None, + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.fill_text_default.color = Some(c.map(|x| x.clamp(0.0, 1.0))); + } + } + None => {} + } + // Fill & Sign text starts from the saved border. + self.fill_text_border = self.fill_text_default.width; + self.fill_text_border_color = self.fill_text_default.color; if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; @@ -375,6 +394,17 @@ impl PdfCraftApp { s.fill = fill; self.comment_prefs.set_style(tool, s); } + // `"border_color": null` is a saved "Same as text", which clears a tool's own border + // colour; a missing or malformed one keeps it. + match e.get("border_color") { + Some(serde_json::Value::Null) => self.comment_prefs.set_border_color(tool, None), + Some(c) => { + if let Some(c) = rgb_triple(c) { + self.comment_prefs.set_border_color(tool, Some(c)); + } + } + None => {} + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2207adbf59..9681126048 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1214,6 +1214,7 @@ struct PageEnv<'a> { y_shift: f32, fill_text_size: f64, fill_text_color: [f64; 3], + fill_text_border: crate::fill_sign::TextBorder, today: (i64, u32, u32), can_paste_image: bool, } @@ -3160,6 +3161,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, + fill_text_border: crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, today, can_paste_image, }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index d366967b9f..e1c9719668 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -266,6 +266,7 @@ fn tool_input( units, fill_text_size, fill_text_color, + fill_text_border, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -295,6 +296,7 @@ fn tool_input( tool: ft, text_size: fill_text_size, text_color: fill_text_color, + text_border: fill_text_border, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 285423cbca..e3762ce0c0 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -102,6 +102,22 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; + // Typed text has no comment tool: Fill & Sign gives new text its size, colour and border, + // kept across launches. + if crate::comments::typed_text(a) { + let kind = crate::i18n::in_sentence(tl!(crate::comments_panel::kind_label(a))).to_string(); + if let Some(size) = p.font_size.and_then(crate::fill_sign::valid_text_size) { + self.fill_text_size = size; + } + if let Some(color) = p.color.and_then(crate::fill_sign::valid_text_color) { + self.fill_text_color = color; + } + let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); + self.fill_text_default = crate::fill_sign::TextBorder { width, color: p.border_color }; + (self.fill_text_border, self.fill_text_border_color) = (width, p.border_color); + self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); + return; + } let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -111,6 +127,8 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // A text box's or callout's own border colour (cleared for tools that have none). + self.comment_prefs.set_border_color(tool, p.border_color); // The fill's and text's own opacities where they show (else the opacity is the whole // comment's). With neither a border nor a fill the text is the whole comment: its drawn // opacity, which Comment Properties shows, is the whole comment's default. @@ -249,6 +267,9 @@ fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &Co { out.push(Edit::FontSizeAnnotation { page, index, size }); } + if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { + out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); + } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); @@ -273,11 +294,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .collect() }) .unwrap_or_default(); - let default_tool = app.comment_props.as_ref().and_then(|d| { - let (_, id) = app.active_ids()?; - let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; - crate::comments::tool_for_default(a, d.edited.endings.as_deref()) + let has_default = app.comment_props.as_ref().is_some_and(|d| { + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + doc.and_then(|doc| doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())) + .is_some_and(|a| crate::comments::tool_for_default(a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a)) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); @@ -320,6 +340,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo e.color = Some(c); } ui.end_row(); + // Text boxes, typewriter text and callouts: the border's own colour. + if e.font_size.is_some() { + ui.label(tl!("Border colour")); + let text = e.color.unwrap_or([0.0; 3]); + ui.horizontal(|ui| { + if let Some(c) = crate::comments::border_colour_button(ui, e.border_color, text) { + e.border_color = c; + } + }); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { @@ -428,8 +459,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.horizontal(|ui| { ui.add(widgets::checkbox(&mut d.edited.locked, tl!("Locked"))); ui.add_space(16.0); - // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool. - let defaultable = d.edited.restylable && default_tool.is_some(); + // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool (or + // typed text, for Fill & Sign). + let defaultable = d.edited.restylable && has_default; ui.add_enabled(defaultable, widgets::checkbox(&mut d.make_default, tl!("Make Properties Default"))); }); let (mut apply, mut cancel) = (false, false); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6a788e765d..e5e2d49b0d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -810,7 +810,8 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); + let look = crate::fill_sign::TypedLook { color: tb.color, border: tb.border }; + let edit = crate::fill_sign::typed_with(tb.page, tb.at, &text, tb.size, rotation, &author, look); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -929,6 +930,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::FillAnnotation { page, .. } | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } + | Edit::BorderColorAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index f27b1e035b..fafae309ff 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -323,6 +323,62 @@ fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { .then_some((page, annotation)) } +/// Fill & Sign's Border controls for typed text: its width and colour, for the selected text +/// (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// font size's does. +fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { + // The selected text's own border (Comment Properties may have changed it + // since): the controls below show it and change only it. Without one they + // set what new text gets until PdfCraft quits; Make Current Properties + // Default on typed text keeps it. + let selected = picked.and_then(|(page, annotation)| { + let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; + Some((page, annotation, crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color })) + }); + let was = selected.map_or(crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, |(_, _, own)| own); + let mut border = was; + // A border around the text, in its colour; the text keeps its place. + let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; + let label = vertical.then(|| ui.label(tl!("Border:"))); + let combo = craft_ui::ComboBox::from_id_salt("fill-text-border") + .selected_text(border_label(was.width)) + .width(72.0) + .show_ui(ui, |ui| { + for w in crate::fill_sign::TEXT_BORDERS { + ui.selectable_value(&mut border.width, w, border_label(w)); + } + }) + .response; + match label { + Some(label) => { + combo.labelled_by(label.id); + } + None => { + combo.on_hover_text(tl!("Border:")); + } + } + // The border's own colour (the text's unless picked). + if was.width > 0.0 + && let Some(color) = comments::border_colour_button(ui, was.color, [0.0; 3]) + { + border.color = color; + } + match selected { + Some((page, index, _)) => { + if border.width != was.width { + app.apply_edit(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + } + if border.color != was.color { + app.apply_edit(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + } + None => { + app.fill_text_border = border.width; + app.fill_text_border_color = border.color; + } + } +} + /// Extra controls rendered below/beside the buttons when a comment or fill tool is active. fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { let current_fill = match app.quick_tool { @@ -365,9 +421,28 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve { app.apply_edit(Edit::FontSizeAnnotation { page, index, size }); } + // After the text's own controls, so they keep their place in a bar that scrolls. + fill_text_border_controls(ui, app, selected_fill_text(app), vertical); } if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); + // Text boxes and callouts: the border's own colour, for new ones and the selected one + // this tool made (not a locked one). + if tool.has_border_color() { + let style = app.comment_prefs.style(tool); + if let Some(color) = comments::border_colour_button(ui, style.border_color, style.color) { + app.comment_prefs.set_border_color(tool, color); + if let Some((index, id)) = app.active_ids() + && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) + && app.session.get(id).is_some_and(|doc| { + doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)) + }) + { + app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); + } + } + } // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) && let Some((_, id)) = app.active_ids() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6465f1df..0cb7237376 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -337,6 +337,14 @@ pub struct PdfCraftApp { pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], + /// Border around new Fill & Sign text and dates, in points (0: none), until PdfCraft quits: + /// each launch starts from [`Self::fill_text_default`]. + pub fill_text_border: f64, + /// That border's own colour (`None`: the text's). + pub fill_text_border_color: Option, + /// The border new Fill & Sign text starts each launch with, kept across launches: what Make + /// Current Properties Default last took from typed text (none at first). + pub fill_text_default: fill_sign::TextBorder, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 0ac648d59c..430bdbec85 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -616,7 +616,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.props_request = Some((a.page, a.index)); ui.close(); } - if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(crate::comments::defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((a.page, a.index)); ui.close(); } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 7e6d88b520..451e5e4000 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -39,7 +39,7 @@ pub use geometry::{drawn_text_box_rect, text_box_rect}; use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; -pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; +pub(crate) use menu::{MenuRights, attachment_items, border_colour_button, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; pub(crate) use paint::paint_page; @@ -247,6 +247,12 @@ impl CommentTool { if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 } } + /// Whether the border-colour control applies to this tool: text boxes and callouts, whose + /// border takes the text colour unless it has its own. + pub fn has_border_color(self) -> bool { + matches!(self, Self::TextBox | Self::Callout) + } + /// Whether the fill-colour control applies to this tool. pub fn has_fill(self) -> bool { matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) @@ -369,6 +375,14 @@ impl CommentPrefs { } } + /// The border's own colour for new text boxes and callouts (`None`: the text colour). + pub fn set_border_color(&mut self, tool: CommentTool, color: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_color = + if tool.has_border_color() { color.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None }; + } + } + pub fn set_style(&mut self, tool: CommentTool, style: Style) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { *s = style; @@ -400,6 +414,17 @@ fn arrow_ending(ending: LineEnding) -> bool { matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) } +/// Typed text (Fill & Sign's, or a typewriter's): no comment tool makes it, so Make Current +/// Properties Default sets what Fill & Sign gives new text. +pub(crate) fn typed_text(a: &Annotation) -> bool { + a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") +} + +/// Whether Make Current Properties Default has defaults to set from `a`. +pub(crate) fn defaultable(a: &Annotation) -> bool { + tool_for(a).is_some() || typed_text(a) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index ecbe36d6ae..a2c38b2628 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -3,7 +3,7 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, - icons, theme, tool_for, vec2, + defaultable, icons, theme, vec2, }; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's @@ -153,7 +153,7 @@ pub(crate) fn context_menu( action = Some(CanvasAction::Properties(page, index)); ui.close(); } - if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((page, index)); ui.close(); } @@ -400,6 +400,33 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m selected_color } +/// A border-colour button: a hollow square in the border's colour (`current`, else the text's, +/// `text`), with a menu of "Same as text" and the swatches. Returns the choice (`Some(None)`: +/// the text's colour again). +pub(crate) fn border_colour_button(ui: &mut egui::Ui, current: Option, text: Rgb) -> Option> { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + let sq = Rect::from_center_size(r.center(), vec2(15.0, 15.0)); + ui.painter().rect_stroke(sq, 1.0, Stroke::new(3.0, color32(current.unwrap_or(text))), egui::StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Border colour"))); + let resp = resp.on_hover_text(tl!("Border colour")); + let mut picked = None; + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { + ui.label(egui::RichText::new(tl!("Border colour")).font(theme::semibold(12.0))); + if ui.add(egui::Button::new(tl!("Same as text")).selected(current.is_none())).clicked() { + picked = Some(None); + ui.close(); + } + if let Some(c) = swatch_grid(ui, current) { + picked = Some(Some(c)); + ui.close(); + } + }); + picked +} + /// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline /// for no fill. fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index af64f63adf..7e98eda05b 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -215,9 +215,23 @@ pub struct TypeBox { pub text: String, pub size: f64, pub color: [f64; 3], + /// The border around the text. + pub border: TextBorder, pub focus: bool, } +/// The border around typed text: its width in points (0: none) and its own colour (`None`: the +/// text's). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TextBorder { + pub width: f64, + pub color: Option, +} + +impl TextBorder { + pub const NONE: Self = Self { width: 0.0, color: None }; +} + /// The default size Fill & Sign uses for typed text. pub const TEXT_SIZE: f64 = 10.0; pub const MIN_TEXT_SIZE: f64 = 4.0; @@ -231,6 +245,14 @@ pub fn valid_text_color(color: [f64; 3]) -> Option<[f64; 3]> { color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)).then_some(color) } +/// The borders the toolbar offers around typed text, in points (0: none). +pub const TEXT_BORDERS: [f64; 6] = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]; +pub const MAX_TEXT_BORDER: f64 = 12.0; + +pub fn valid_text_border(width: f64) -> Option { + (width.is_finite() && (0.0..=MAX_TEXT_BORDER).contains(&width)).then_some(width) +} + fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { let (vx, vy) = xf.screen_to_view(p); let u = info.pages[page].view_to_user(vx, vy); @@ -243,17 +265,30 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { } /// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by -/// `rotation` (its `/Rotate`). +/// `rotation` (its `/Rotate`), in black and without a border. pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { - typed_with_color(page, at, text, size, rotation, author, [0.0; 3]) + typed_with(page, at, text, size, rotation, author, TypedLook::default()) } -/// Typed text/date colour applies to its annotation appearance and survives saving. -pub fn typed_with_color(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, color: [f64; 3]) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size, rotation); - let shape = Shape::Typewriter { rect, font_size: size }; - let mut style = Style::default_for(&shape); - style.color = valid_text_color(color).unwrap_or([0.0; 3]); +/// How typed text and dates look: their colour, and the border around them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TypedLook { + pub color: [f64; 3], + pub border: TextBorder, +} + +/// [`typed`] in `look`: the colour applies to the annotation's appearance and survives saving; a +/// border goes around the box sized to the text, so the text sits where it would without one. +pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { + let width = valid_text_border(look.border.width).unwrap_or(0.0); + let [x0, y0, x1, y1] = crate::comments::text_box_rect(at, text, size, rotation); + let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; + let style = Style { + color: valid_text_color(look.color).unwrap_or([0.0; 3]), + width, + border_color: look.border.color.filter(|_| width > 0.0), + ..Style::default_for(&shape) + }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) } @@ -453,6 +488,7 @@ pub(crate) struct PageInput<'a> { pub(crate) tool: FillTool, pub(crate) text_size: f64, pub(crate) text_color: [f64; 3], + pub(crate) text_border: TextBorder, pub(crate) signature: Option<&'a SavedSig>, pub(crate) initials: Option<&'a SavedSig>, pub(crate) preview: &'a mut Option<(SavedSig, egui::TextureHandle)>, @@ -463,7 +499,7 @@ pub(crate) struct PageInput<'a> { /// Preview and clicks with a Fill & Sign tool on one page. pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; + let PageInput { tool, text_size, text_color, text_border, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); @@ -495,21 +531,37 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo // deselects, as a comment tool's note or text box does: Escape then closes the box and // leaves the tool in two presses. view.comments.clear_selection(); - let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); + let open = view.fill_text.replace(TypeBox { + page, + at: top, + text: String::new(), + size: text_size, + color: text_color, + border: text_border, + focus: true, + }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { - FillAction::Edit(Box::new(typed_with_color( + FillAction::Edit(Box::new(typed_with( tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, - tb.color, + TypedLook { color: tb.color, border: tb.border }, ))) }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed_with_color(page, top, text, text_size, rotation, author, text_color))), + Ok(text) => FillAction::Edit(Box::new(typed_with( + page, + top, + text, + text_size, + rotation, + author, + TypedLook { color: text_color, border: text_border }, + ))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -586,6 +638,12 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .id_salt("fill-text-edit"), ) .accessible_name(tl!("Text to place")); + // The border the text will get, around the box being typed into. + if t.border.width > 0.0 { + let w = (t.border.width as f32 * zoom).max(1.0); + let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } if t.focus { r.request_focus(); t.focus = false; @@ -604,7 +662,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + let look = TypedLook { color: tb.color, border: tb.border }; + return Some(typed_with(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, look)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index cff01c0014..0396714c18 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2382,6 +2382,36 @@ fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); } +/// Make Properties Default on a text box with a border colour of its own gives the Text box tool +/// that colour. +#[test] +fn a_text_box_made_default_gives_the_tool_its_border_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + let red = Some([0.89, 0.13, 0.13]); + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: red })); + assert_ne!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -3130,6 +3160,7 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { fill: Some([1.0, 0.0, 0.0]), fill_opacity: None, text_opacity: None, + border_color: None, }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 04f67ce216..1b9cb85a39 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1801,6 +1801,7 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { text: "Unsaved signature note".into(), size: 10.0, color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, focus: false, }); h.state_mut().active = None; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index b5ad9f0522..55b6bdafd9 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -292,6 +292,50 @@ fn fill_text_size_resizes_the_selected_text() { assert!(after > before * 2.0, "36 pt text has a taller box than 10 pt text: {before} -> {after}"); } +/// Dragging the Font size control with a placed text selected resizes it once, when the drag +/// ends, to the size it was dragged to: the frame of the release must not put the old size back. +#[test] +fn dragging_the_font_size_resizes_the_selected_text_to_where_the_drag_ends() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Drag me".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (id, annotation) = { + let s = h.state(); + let id = s.views[0].id; + let a = s.session.get(id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Drag me")).map(|a| (a.page, a.index)); + (id, a.unwrap()) + }; + h.state_mut().views[0].comments.selected = Some(annotation); + h.run_steps(2); + // The size is set in the Font size button's popup. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); + let from = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .rect() + .center(); + let before = h.state().fill_text_size; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for dx in [10.0, 20.0, 30.0, 40.0] { + h.hover_at(from + egui::vec2(dx, 0.0)); + h.run_steps(1); + } + h.drop_at(from + egui::vec2(40.0, 0.0)); + h.run_steps(4); + let dragged = h.state().fill_text_size; + assert!(dragged > before, "the drag made the size larger: {before} -> {dragged}"); + let placed = h.state().session.get(id).unwrap().comment_props(annotation.0, annotation.1).and_then(|p| p.font_size); + assert_eq!(placed, Some(dragged), "the selected text has the size the drag ended at"); +} + /// The font sizes of the texts `text` drawn in the last frame. fn drawn_sizes(h: &Harness<'static, PdfCraftApp>, text: &str) -> Vec { fn walk(shape: &egui::Shape, text: &str, out: &mut Vec) { @@ -337,6 +381,150 @@ fn the_type_box_text_matches_the_page_zoom_in_a_turned_view() { } } +/// With a placed text selected, the toolbar shows its own border, also after Comment Properties +/// changed it, and changes only that text: the Border colour button gives its border its own +/// colour ("Same as text" takes it back), and new text keeps the toolbar's own border. +#[test] +fn the_toolbar_shows_and_changes_the_selected_texts_border() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Framed".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Framed")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + let shows = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some(value)) + }; + h.state_mut().views[0].comments.selected = Some((page, index)); + // As Comment Properties ▸ Thickness does. + h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(2.0), endings: None }); + h.run_steps(3); + assert!(shows(&h, "2 pt"), "the toolbar shows the selected text's border"); + assert_eq!(h.state().fill_text_border, 0.0, "new text keeps none"); + + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Red").click(); + h.run_steps(4); + let red = Some([0.89, 0.13, 0.13]); + assert_eq!(props(&h).border_color, red, "the selected text's border is red"); + assert_eq!(h.state().fill_text_border_color, None, "new text's border keeps the text's colour"); + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Same as text").click(); + h.run_steps(4); + assert_eq!(props(&h).border_color, None); + // Nothing selected, the toolbar shows new text's border again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + assert!(shows(&h, "None")); +} + +/// The Border control puts a frame around new Fill & Sign text, and around the text that is +/// selected; the box grows outwards, so the text stays where it was typed. The choice persists. +#[test] +fn fill_text_border_frames_new_and_selected_text() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + h.get_by_label("Border:"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + click(h, 50.0, y); + h.event(egui::Event::Text("Same".into())); + h.run_steps(1); + commit_typed_text(h); + }; + // The two texts, oldest first: (page, index, rect). + let texts = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let mut v: Vec<_> = + doc.info.annotations.iter().filter(|a| a.contents.as_deref() == Some("Same")).map(|a| (a.page, a.index, a.rect)).collect(); + v.sort_by_key(|t| t.1); + v + }; + let width = |r: [f32; 4]| r[2] - r[0]; + type_at(&mut h, 150.0); + // Nothing selected, so the toolbar sets new text's border only (it shows a selected text's). + h.state_mut().views[0].comments.selected = None; + h.state_mut().fill_text_border = 1.5; + type_at(&mut h, 250.0); + let [(page, index, plain), (_, _, framed)] = texts(&h)[..] else { panic!("two texts") }; + assert!((width(framed) - width(plain) - 3.0).abs() < 0.01, "1.5 pt on each side: {plain:?} {framed:?}"); + + // The plain text selected, then framed with 2 pt from the toolbar. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("1.5 pt")) + .expect("the Border control, showing the border for new text") + .click(); + h.run_steps(2); + h.get_by_label("2 pt").click(); + h.run_steps(4); + assert_eq!(h.state().fill_text_border, 1.5, "new text keeps its own"); + let (_, _, now) = texts(&h)[0]; + assert!((width(now) - width(plain) - 4.0).abs() < 0.01, "2 pt on each side: {plain:?} {now:?}"); + assert!((plain[0] - now[0] - 2.0).abs() < 0.01 && (now[3] - plain[3] - 2.0).abs() < 0.01, "grown outwards, the text in place: {plain:?} {now:?}"); + + // The toolbar's border lasts until PdfCraft quits; a saved default is read back. + let settings = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&settings); + assert_eq!(restored.fill_text_border, 0.0); + restored.restore(r#"{"fill_text_border":2}"#); + assert_eq!(restored.fill_text_border, 2.0); + restored.restore(r#"{"fill_text_border":100}"#); + assert_eq!(restored.fill_text_border, 2.0, "invalid settings do not change the border"); +} + +/// Make Properties Default on typed text sets what Fill & Sign gives new text, its size and +/// border, and keeps it across launches. +#[test] +fn typed_text_made_default_is_what_new_text_gets() { + use pdfcraft_engine::Edit; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { + click(h, 50.0, y); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + commit_typed_text(h); + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).cloned().unwrap(); + (a.page, a.index) + }; + let (page, index) = type_at(&mut h, 150.0, "Model"); + let red = Some([0.89, 0.13, 0.13]); + for edit in [ + Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, + Edit::BorderColorAnnotation { page, index, color: red }, + Edit::FontSizeAnnotation { page, index, size: 14.0 }, + ] { + assert!(h.state_mut().apply_edit(edit)); + } + h.state_mut().open_comment_props(page, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color), (14.0, 1.5, red)); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!((restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color), (14.0, 1.5, red), "kept across launches"); + h.state_mut().views[0].comments.selected = None; + let (page, index) = type_at(&mut h, 350.0, "Next"); + let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!((p.font_size, p.width, p.border_color), (Some(14.0), Some(1.5), red)); +} + #[test] fn fill_text_colour_survives_preview_commit_settings_and_save() { let mut h = harness(); @@ -1211,7 +1399,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); let id = app.views[0].id; app.session.apply(id, mark).unwrap(); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); let kept = dir.join("kept.pdf"); app.save_override = Some(kept.to_string_lossy().into_owned()); assert!(app.save_active(SaveTarget::InPlace)); @@ -1222,7 +1418,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}"); assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too"); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 160.0], + text: "More".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); app.flatten_fill_sign_on_save = true; let flat = dir.join("flat.pdf"); app.save_override = Some(flat.to_string_lossy().into_owned()); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12989e9e73..a7f5b4cf7f 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -889,6 +889,24 @@ fn a_saved_no_fill_clears_a_comment_tool_default_fill() { assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None); } +/// A tool's saved "Same as text" border colour (`"border_color": null`) clears an own border +/// colour it had; a missing or malformed one keeps it. +#[test] +fn a_saved_same_as_text_clears_a_comment_tool_border_colour() { + use pdfcraft_ui_egui::comments::CommentTool; + let red = Some([0.89, 0.13, 0.13]); + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_border_color(CommentTool::TextBox, red); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":"red"}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, red, "malformed keeps it"); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":null}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, None, "null clears it"); + let mut b = PdfCraftApp::new(); + b.comment_prefs.set_border_color(CommentTool::TextBox, red); + b.restore(&a.persist()); + assert_eq!(b.comment_prefs.style(CommentTool::TextBox).border_color, None, "a round trip keeps it cleared"); +} + /// Persisted per-tool comment styles go through their clamping setters: hostile or malformed /// values are clamped or ignored, never trusted (#340). #[test] diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index d383582915..9a06a0bc12 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1046,7 +1046,15 @@ fn the_window_title_marks_a_draft_that_is_not_applied_yet() { h.run_steps(2); assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); let i = h.state().active.unwrap(); - h.state_mut().views[i].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + h.state_mut().views[i].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); h.run_steps(2); let s = h.state(); assert!(!s.session.get(s.views[i].id).unwrap().dirty, "nothing applied to the document yet"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e95e7252d9..cf6a3db0a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3758,8 +3758,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] -notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates." +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] +notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." [[feature]] id = "comment.text-box" @@ -4010,8 +4010,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340). Typed text, which no comment tool makes, gives Fill & Sign's new text its size and border" [[feature]] id = "comment.keep-tool-selected" From 496e37d72be8d5f041520a59312830ad8a98d029 Mon Sep 17 00:00:00 2001 From: ohagmann <268387159+ohagmann@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:32:01 +0100 Subject: [PATCH 25/59] M5: text boxes and typed text take an oval or a circle for their border (#4543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fill & Sign: a Border control for typed text, and a border colour of its own Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the same places as the border (the open box, typed(), the page input, the toolbar and the preferences), so every step of the branch met it again. Text colour and border now travel together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's argument limit; a border without its own colour takes the text's. Make Current Properties Default on typed text also keeps its colour for new text now. The branch's commits, in order: - Typewriter text: a border change grows its box on every side, so the text keeps its place - Fill & Sign: a Border control for typed text and dates - Parity: the typewriter border's evidence - i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml - Text boxes, typewriter text and callouts: a border colour of their own - Fill & Sign: the toolbar follows the selected text; a Border colour button - fill_sign_add and comment_edit take `border_color` - Parity: the border colour and the toolbar following the selection - i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml - Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored - Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border - Review: Make Properties Default gives a text box's or callout's tool its border colour - i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml - Review: a test that dragging the Font size control resizes the selected text to where the drag ends - Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox Co-Authored-By: Claude Opus 5.5 * M5: text boxes and typed text take an oval or a circle for their border Rebased onto #4146's new head as one commit (see there: main's Fill & Sign text colour runs through the same places). The border shape goes into TypedLook's border with the width and colour; a circle around typed text still takes the snug box. The branch's commits, in order: - Text boxes and typed text take an oval or a circle for their border - A circle goes snug around its text - Review: fill_sign_add takes an oval or a circle only with a border - Review: a circle made on a quarter-turned page is sized from the text as shown - Review: Border ▸ None takes an oval or a circle away with the line - Review: the Border colour button shows the selected text box's own colour - i18n: Border shape in the Persian catalog, recorded in ATTRIBUTION.toml - Review: Border ▸ None on a circled text is one undo step - Rebase: the border shape in main's annot, engine and comments modules; craft_ui's ComboBox; Fill & Sign's border controls in a function of their own (main's complexity limit) - Review: Thickness 0 in Comment Properties takes an oval or a circle away, as Border ▸ None does Co-Authored-By: Claude Opus 5.5 * Open the Font size popup first in the drag test: main moved the size into a popup * Border shape per selected comment, and split the oval/circle border out of draw_free_text (clippy cognitive complexity) * Re-run CI (the push of the main merge did not start it) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 100 ++++++-- crates/annot/src/create.rs | 7 +- crates/annot/src/info.rs | 5 +- crates/annot/src/lib.rs | 8 +- crates/annot/src/modify.rs | 181 +++++++++++++-- crates/annot/src/tests.rs | 214 ++++++++++++++++++ crates/annot/src/types.rs | 101 ++++++++- crates/automation/src/comments.rs | 41 +++- crates/automation/src/tools.rs | 3 + crates/automation/tests/it/automation.rs | 49 ++++ crates/engine/src/edit.rs | 16 +- crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 4 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/prefs.rs | 9 + crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/dialogs/comment_props.rs | 41 +++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/hotbar.rs | 70 ++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tools/comments.rs | 9 +- crates/ui-egui/src/tools/comments/geometry.rs | 11 +- crates/ui-egui/src/tools/fill_sign.rs | 41 +++- crates/ui-egui/tests/it/comments.rs | 116 ++++++++++ crates/ui-egui/tests/it/fill_sign.rs | 132 ++++++++++- docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 12 + 30 files changed, 1101 insertions(+), 86 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3ce7c13542..f104ee0d80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -303,6 +303,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. - **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 9cdb548360..7148e9fc2e 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -366,10 +366,18 @@ fn stamp_turn(d: &Dict) -> i64 { /// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by /// (`/PCTextRotate`, written when it is added to a turned page). -fn text_turn(d: &Dict) -> i64 { +pub(crate) fn text_turn(d: &Dict) -> i64 { d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// A FreeText's `/RD`: how far its text box lies inside `/Rect` from the left, bottom, right and +/// top. A distance a hair below 0 (rounding) reads as 0; `None` when one is negative or they can't +/// be read. +pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> { + let rd: [f64; 4] = nums(d, b"RD")?.try_into().ok()?; + rd.iter().all(|x| x.is_finite() && *x > -0.001).then(|| rd.map(|x| x.max(0.0))) +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a @@ -937,6 +945,48 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - Some(form(rect, c.as_bytes(), res)) } +/// The oval or circle (`shape`) around a text box whose whole box is `full`: its background +/// and border go into `c`; returns the text box inside it. +#[allow(clippy::too_many_arguments)] +fn shaped_border( + d: &Dict, + shape: crate::BorderShape, + full: [f64; 4], + turn: i64, + bw: f64, + bg: Option, + line_color: Rgb, + c: &mut String, +) -> Option<[f64; 4]> { + // Laid out as displayed: a quarter turn swaps the sides' differences. + let rect = match inset(d) { + Some(rd) => { + let (dx, dy) = if matches!(turn, 90 | 270) { (rd[1], rd[0]) } else { (rd[0], rd[1]) }; + [full[0] + dx, full[1] + dy, full[2] - dx, full[3] - dy] + } + None => shape.inside(full, bw, full), + }; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let h = bw / 2.0; + let [x0, y0, x1, y1] = [full[0] + h, full[1] + h, full[2] - h, full[3] - h]; + let path = match shape { + crate::BorderShape::Circle => { + let (cx, cy, r) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).min(y1 - y0) / 2.0); + ellipse(cx - r, cy - r, cx + r, cy + r) + } + _ => ellipse(x0, y0, x1, y1), + }; + if let Some(bg) = bg { + c.push_str(&format!("{}{path}f\n", rg(bg))); + } + if bw > 0.0 { + c.push_str(&format!("{}{} w\n{}{path}S\n[] 0 d\n", rg_stroke(line_color), n(bw), dash(d))); + } + Some(rect) +} + fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { let d = context.dict; let parts = context.parts; @@ -995,26 +1045,36 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin return None; } } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(line_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); + // An oval or a circle (`/PCBorderShape`) around the text box, which is `/Rect` inset by + // `/RD` and keeps its place: background and border take the shape, the text is laid out + // in the text box as in a rectangle. + let shape = crate::BorderShape::of(d); + if shape != crate::BorderShape::Rectangle { + rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?; + } else { + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(line_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } } let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); let pad = 2.0 + bw; let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); + // In an oval or a circle the lines are centred in the text box, up and down too. + let round = shape != crate::BorderShape::Rectangle; + let q = if round { 1 } else { d.int(b"Q").unwrap_or(0) }; // The text at its own opacity, where it has one. if parts.text != parts.fill { c.push_str("/GS1 gs\n"); @@ -1029,8 +1089,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin rg(text_color) )); let mut out = c.into_bytes(); + let lines = wrap(&text, size, width); let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { + if round { + y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0); + } + for line in lines { if y < rect[1] - size { break; } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index f12b2bba3b..6376655ce9 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{free_text_da, n, rich_text, set_part_keys}; +use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -392,12 +392,15 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); rich_text(&mut d); - // The text is laid out for the page as displayed, like a stamp's label. if !matches!(new.shape, Shape::Callout { .. }) { + // The text is laid out for the page as displayed, like a stamp's label. let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if matches!(turn, 90 | 180 | 270) { d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); } + // An oval or a circle goes around the text box, its text wrapped as displayed: + // after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page. + shape_text_box(&mut d, rect, style.border_shape, style.width); } } _ => {} diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index cf1b607664..ba181d7504 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -2,7 +2,7 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; -use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; +use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── @@ -219,6 +219,8 @@ pub struct Props { pub font_size: Option, /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). pub border_color: Option, + /// Text boxes and typewriter text: the border's shape (callouts have none to choose). + pub border_shape: Option, /// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its /// line's or border's). pub fill_opacity: Option, @@ -256,6 +258,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { fill: fill_of(d), font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), + border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text), subtype, diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 270be0115d..52c0aad74f 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -42,13 +42,13 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, - set_border_color, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, - set_style, with_replies_and_popups, + set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, + set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ - AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, + AttachIcon, BorderShape, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, + Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 8dbfd4c5b9..c7d87be090 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -5,8 +5,8 @@ use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ - FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, - StyleChange, TextReplacement, + BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, + Shape, Style, StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; @@ -159,10 +159,13 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int } /// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the -/// new text: the wrap width and top edge stay, the height fits the wrapped lines. +/// new text: the wrap width and top edge stay, the height fits the wrapped lines. A circle snug +/// around its text stays snug around the new text, centred where it was. pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; - let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + let d = annot_dict(doc, r); + let free_text = d.name(b"Subtype") == Some(b"FreeText"); + let snug = free_text && is_snug(&d); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); // The rich-text version follows the new plain text (text boxes), or would contradict it. @@ -177,8 +180,16 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m // The box grows and shrinks with the text instead of clipping it. A locked box keeps its // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even // where the standard fonts can't draw some of it (marked and reported, see - // `draw_appearance`). - match fitted_box(doc, r, text) { + // `draw_appearance`). A circle snug around its text stays snug around the new text; one + // made larger by hand keeps its size, only growing for more lines. + let fit = if snug { + snug_refit(doc, r) + } else { + let d = annot_dict(doc, r); + let circle = text_box_of(&d).filter(|_| BorderShape::of(&d) == BorderShape::Circle); + fitted_box(doc, r, text).map(|fit| circle.map_or(fit, |old| [fit[0], fit[1].min(old[1]), fit[2], fit[3]])) + }; + match fit { Some(rect) => apply_text_box(doc, r, rect, meta, true)?, None => draw_appearance(doc, r)?, } @@ -197,11 +208,12 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; let rect = [rect[0], rect[1], rect[2], rect[3]]; // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. + // So does a text box inside an oval or a circle. let tb = if d.contains(b"CL") { let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] } else { - rect + text_box_of(&d).unwrap_or(rect) }; let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); let w = tb[2] - tb[0]; @@ -388,9 +400,134 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); } + // An oval's or a circle's frame is resized: its text box follows inside. + let shape = if subtype == "FreeText" { BorderShape::of(&d) } else { BorderShape::Rectangle }; + let rect = match (shape, text_box_of(&d)) { + (BorderShape::Rectangle, _) | (_, None) => rect, + // A circle's text box keeps its proportions and reaches the circle with its longer sides; + // the circle then goes around it again, never closer than its text allows. + (BorderShape::Circle, Some(was)) => { + let (o, w) = (normalize(rect), normalize(was)); + let (cx, cy, r) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]).min(o[3] - o[1]) / 2.0); + let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0); + let k = r / wx.max(wy).max(1e-6); + let (ix, iy) = ((wx * k).max(0.5), (wy * k).max(0.5)); + [cx - ix, cy - iy, cx + ix, cy + iy] + } + (shape, Some(was)) => shape.inside(rect, border_width_of(&d), was), + }; apply_text_box(doc, r, rect, meta, false) } +/// A FreeText's text box: `/Rect` inset by `/RD` for a callout or a text box inside an oval or a +/// circle, else `/Rect`. `None` when they can't be read. +pub(crate) fn text_box_of(d: &Dict) -> Option<[f64; 4]> { + let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; + let rect = normalize(four(&nums(b"Rect")?)?); + if !d.contains(b"CL") && BorderShape::of(d) == BorderShape::Rectangle { + return Some(rect); + } + let rd = appearance::inset(d)?; + let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]; + (tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb) +} + +/// Give text box or typewriter text `d`, whose text box is `tb`, the border `shape` (`width` +/// wide): `/Rect` around it, `/RD` and `/PCBorderShape`, or for a rectangle `/Rect` the text +/// box itself again. +pub(crate) fn shape_text_box(d: &mut Dict, tb: [f64; 4], shape: BorderShape, width: f64) { + // A circle goes around the text as the box lays it out. + let text = (shape == BorderShape::Circle).then(|| text_extent(d, displayed_sides(d, tb).0 - 2.0 * (2.0 + width.max(0.0)))); + let outer = shape.around(tb, width, text); + d.set(b"Rect".to_vec(), num_array(&outer)); + if shape == BorderShape::Rectangle { + d.remove(b"PCBorderShape"); + d.remove(b"RD"); + } else { + d.set(b"PCBorderShape".to_vec(), Object::name(shape.name())); + // A circle touching the text box's sides would put them a rounding error outside it. + let tb = normalize(tb); + let rd = [tb[0] - outer[0], tb[1] - outer[1], outer[2] - tb[2], outer[3] - tb[3]].map(|x| x.max(0.0)); + d.set(b"RD".to_vec(), num_array(&rd)); + } +} + +/// The width of the widest line and the height of the lines of FreeText `d`'s text, as its +/// appearance lays the text out `wrap` wide. +fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] { + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let (_, size) = appearance::parse_da(d); + let lines = appearance::wrap(&text, size, wrap.max(1.0)); + let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + [widest, lines.len().max(1) as f64 * size * 1.2] +} + +/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by +/// a quarter, where its text is laid out upright. +fn displayed_sides(d: &Dict, tb: [f64; 4]) -> (f64, f64) { + let tb = normalize(tb); + let (w, h) = (tb[2] - tb[0], tb[3] - tb[1]); + if matches!(appearance::text_turn(d), 90 | 270) { (h, w) } else { (w, h) } +} + +/// FreeText `d`'s text box `tb`, with a border `width` wide, made as large as its text wrapped +/// `wrap` wide needs (its widest line and its lines, inside the padding a box sized to its text +/// has) and centred where it was: a circle goes around this, not around a box wider than its text. +fn snug_text_box(d: &Dict, tb: [f64; 4], width: f64, wrap: f64) -> [f64; 4] { + let tb = normalize(tb); + let pad = 2.0 + width.max(0.0); + let [w, h] = text_extent(d, wrap); + let (hx, hy) = (w / 2.0 + pad + 2.0, h / 2.0 + pad + 1.0); + let (hx, hy) = if matches!(appearance::text_turn(d), 90 | 270) { (hy, hx) } else { (hx, hy) }; + let (cx, cy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0); + [cx - hx, cy - hy, cx + hx, cy + hy] +} + +/// Whether FreeText `d` is a circle whose text box is snug around its text ([`snug_text_box`]): +/// it then follows its text as that changes, where one made larger by hand keeps its size. +fn is_snug(d: &Dict) -> bool { + let Some(tb) = text_box_of(d).filter(|_| BorderShape::of(d) == BorderShape::Circle) else { return false }; + let width = border_width_of(d); + let s = snug_text_box(d, tb, width, displayed_sides(d, tb).0 - 2.0 * (2.0 + width)); + let tb = normalize(tb); + ((s[2] - s[0]) - (tb[2] - tb[0])).abs() < 0.5 && ((s[3] - s[1]) - (tb[3] - tb[1])).abs() < 0.5 +} + +/// The text box of a circle snug around its text for the text and size it has now: its lines up to +/// 300 pt wide, centred where it was. +fn snug_refit(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { + let d = annot_dict(doc, r); + let width = border_width_of(&d); + Some(snug_text_box(&d, text_box_of(&d)?, width, 300.0 - 2.0 * (2.0 + width))) +} + +/// Give a text box or typewriter text an oval, a circle or a rectangle for its border, and redraw +/// it. Its text box keeps its place; the oval or circle goes around it. For a circle the text box +/// first shrinks to its text, its lines wrapped as they are. +pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: BorderShape, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") || d.contains(b"CL") { + return Err(AnnotError::Invalid("only text boxes and typewriter text have a border shape".into())); + } + let tb = text_box_of(&d).ok_or_else(|| AnnotError::Invalid("the text box can't be read".into()))?; + let tb = match shape { + BorderShape::Circle => { + let width = border_width_of(&d); + snug_text_box(&d, tb, width, displayed_sides(&d, tb).0 - 2.0 * (2.0 + width)) + } + _ => tb, + }; + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + shape_text_box(d, tb, shape, border_width_of(d)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// At most this many points reshape a comment (a drawn polygon has a handful). const MAX_POINTS: usize = 100_000; @@ -503,6 +640,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } None => (rect, None), }; + let (shape, width) = (BorderShape::of(&d), border_width_of(&d)); + let free_text = d.name(b"Subtype") == Some(b"FreeText"); doc.update_dict(r, |d| { d.set(b"Rect".to_vec(), num_array(&outer)); match &cl { @@ -510,6 +649,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne d.set(b"CL".to_vec(), num_array(l)); d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); } + // An oval or a circle goes around the text box again. + None if free_text => shape_text_box(d, rect, shape, width), None => { d.remove(b"RD"); } @@ -586,17 +727,18 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha let free_text = subtype == "FreeText"; // Typewriter text has no box of its own: a border goes around its text, so the box grows // (or shrinks) by the change on every side and the text keeps its place and its lines. - let typewriter = free_text && d.name(b"IT") == Some(b"FreeTextTypeWriter") && !d.contains(b"CL"); + // So does a text box inside an oval or a circle, which goes around it again. + let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle }; + let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL"); let regrow = width.filter(|_| typewriter).and_then(|w| { let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; - let rect: Vec = d.get(b"Rect")?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); - let rect = grow(normalize(four(&rect)?), by); - (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some(rect) + let rect = grow(text_box_of(&d)?, by); + (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0))) }); let before = d; doc.update_dict(r, |d| { - if let Some(rect) = regrow { - d.set(b"Rect".to_vec(), num_array(&rect)); + if let Some((rect, w)) = regrow { + shape_text_box(d, rect, shape, w); } if let Some(c) = color { if free_text { @@ -685,6 +827,7 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); } refuse_foreign_text(doc, &d)?; + let snug = is_snug(&d); let before = d; doc.update_dict(r, |d| { let (col, _) = appearance::parse_da(d); @@ -693,18 +836,18 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m touch(d, meta); })?; let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); - let result = match fitted_box(doc, r, &text) { - Some(fit) => { + let result = match (snug, if snug { snug_refit(doc, r) } else { fitted_box(doc, r, &text) }) { + // A circle snug around its text stays snug around it at the new size. + (true, Some(fit)) => apply_text_box(doc, r, fit, meta, false), + (false, Some(fit)) => { let cur = annot_dict(doc, r); - let nums: Vec = - cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); // Keep the top and sides; only the bottom edge moves, and only downwards. - match four(&nums).map(normalize) { + match text_box_of(&cur) { Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), _ => apply_text_box(doc, r, fit, meta, false), } } - None => redraw_or_restore(doc, r, before.clone()), + (_, None) => redraw_or_restore(doc, r, before.clone()), }; if result.is_err() { doc.set(r, Object::Dict(before)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f451ad0f8e..ff7f8925f0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental}; +use super::modify::text_box_of; use super::*; /// Two pages. Page 1 has an inline Square annotation in a direct /Annots array; page 2 has an @@ -356,6 +357,219 @@ fn restyle_and_resize_redraw_the_appearance() { assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_)))); } +/// Typed text or a text box takes an oval or a circle for its border: the text box keeps its +/// place and the shape goes around it (`/Rect` grows, `/RD` holds the text box, `/PCBorderShape` +/// the shape); back to a rectangle, it is the text box it was. A resized frame takes its text box +/// along, a thicker border leaves it where it is. +#[test] +fn text_boxes_take_an_oval_or_a_circle_around_their_text() { + let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + let nums = |d: &Dict, k: &[u8]| -> Vec { + d.get(k).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default() + }; + // Where the text starts: its first line's text matrix. + let text_at = |doc: &Document, d: &Dict| ap_content(doc, d).lines().find(|l| l.ends_with(" Tm") || l.contains(" Tm ")).map(str::to_owned); + let mut doc = fixture(); + let mut tw = new(0, Shape::Typewriter { rect: [100.0, 100.0, 200.0, 120.0], font_size: 10.0 }); + tw.style.width = 1.0; + let t = add_annotation(&mut doc, &tw, &meta("t")).unwrap(); + // In an oval or a circle the text is centred in its text box (left-aligned in a box): one + // line of "hello" at 10 pt in the 100 × 20 text box centred on (150, 110), 3 pt from its top. + let centred = format!("1 0 0 1 {} {} Tm (hello) Tj", n(150.0 - appearance::text_width("hello", 10.0) / 2.0), n(107.0)); + assert!(text_at(&doc, &list(&doc, 0)[t]).is_some_and(|l| l.starts_with("1 0 0 1 103 ")), "a box: left-aligned"); + set_border_shape(&mut doc, 0, t, BorderShape::Oval, &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[t]; + // Around the 100 × 20 text box, centred: √2 its size, two points more for the 1 pt border. + let (ax, ay) = (50.0 * std::f64::consts::SQRT_2 + 2.0, 10.0 * std::f64::consts::SQRT_2 + 2.0); + assert!(close(&rect(d), &[150.0 - ax, 110.0 - ay, 150.0 + ax, 110.0 + ay]), "{:?}", rect(d)); + assert!(close(&nums(d, b"RD"), &[ax - 50.0, ay - 10.0, ax - 50.0, ay - 10.0]), "{:?}", nums(d, b"RD")); + assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..])); + let ap = ap_content(&doc2, d); + assert!(ap.contains(" c\n") && ap.contains("S\n") && !ap.contains("re S"), "an oval, not a box: {ap}"); + assert_eq!(text_at(&doc2, d).as_deref(), Some(centred.as_str()), "the text keeps its place"); + assert_eq!(props(&doc2, 0, t).unwrap().border_shape, Some(BorderShape::Oval)); + // A circle around the text: the text box shrinks to "hello" with its padding, centred where it + // was, and the circle touches its sides (the text a point clear of the border inside). + set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + let hw = appearance::text_width("hello", 10.0) / 2.0; + let r = (hw + 5.0).max(hw.hypot(6.0) + 2.0); + assert!(close(&rect(d), &[150.0 - r, 110.0 - r, 150.0 + r, 110.0 + r]), "{:?}", rect(d)); + assert!(close(&nums(d, b"RD"), &[r - hw - 5.0, r - 10.0, r - hw - 5.0, r - 10.0]), "{:?}", nums(d, b"RD")); + assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str())); + // A thicker border: the typed text's box grows by the change on every side, so its text keeps + // its place inside the thicker line (as with a rectangle), and the circle goes around that. + set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str())); + let r3 = (hw + 7.0).max(hw.hypot(6.0) + 4.0); + assert!(close(&rect(d), &[150.0 - r3, 110.0 - r3, 150.0 + r3, 110.0 + r3]), "{:?}", rect(d)); + // Its frame resized: the text box follows inside, keeping its proportions, its longer sides + // reaching the circle. + set_rect(&mut doc, 0, t, [150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3], &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + let rd = nums(d, b"RD"); + let tb = [rect(d)[0] + rd[0], rect(d)[1] + rd[1], rect(d)[2] - rd[2], rect(d)[3] - rd[3]]; + assert!(((tb[2] - tb[0]) / (tb[3] - tb[1]) - (hw + 7.0) / 12.0).abs() < 0.01, "the text box keeps its proportions: {tb:?}"); + assert!(close(&rect(d), &[150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3]), "{:?}", rect(d)); + // A rectangle again: the text box it has, nothing around it. + set_border_shape(&mut doc, 0, t, BorderShape::Rectangle, &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert!(close(&rect(d), &tb), "{:?} vs {tb:?}", rect(d)); + assert!(!d.contains(b"RD") && !d.contains(b"PCBorderShape")); + assert!(ap_content(&doc, d).contains("re S")); + + // A filled text box made with an oval: the background takes the shape too. + let mut tb = new(0, Shape::TextBox { rect: [300.0, 500.0, 400.0, 540.0], font_size: 12.0 }); + (tb.style.fill, tb.style.width, tb.style.border_shape) = (Some([1.0, 1.0, 0.6]), 1.0, BorderShape::Oval); + let b = add_annotation(&mut doc, &tb, &meta("b")).unwrap(); + let d = &list(&doc, 0)[b]; + assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..])); + let ap = ap_content(&doc, d); + assert!(ap.contains("1 1 0.6 rg") && ap.contains(" c\nh\nf\n") && !ap.contains(" re f"), "{ap}"); + // A callout's border stays a rectangle. + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [300.0, 300.0, 400.0, 340.0], + knee: [280.0, 320.0], + point: [250.0, 280.0], + ending: LineEnding::OpenArrow, + font_size: 10.0, + }, + ), + &meta("c"), + ) + .unwrap(); + assert!(matches!(set_border_shape(&mut doc, 0, c, BorderShape::Oval, &meta("")), Err(AnnotError::Invalid(_)))); + assert_eq!(props(&doc, 0, c).unwrap().border_shape, None); + assert_eq!(BorderShape::parse("CIRCLE"), Some(BorderShape::Circle)); +} + +/// A circle goes snug around short text, as a number on a drawing is circled: the text box is no +/// wider than its text needs and the circle touches its sides, where one through the box's corners +/// stood well clear. It follows the text and its size as they change; one made larger by hand +/// keeps its size. +#[test] +fn a_circle_goes_snug_around_its_text_and_follows_it() { + let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01; + let mut doc = fixture(); + // "3" at 10 pt with a 1 pt border, in the box Fill & Sign sizes to it: the digit with 2 pt of + // padding and 4 pt to spare across, a line and 6 pt down, the border around that. + let w3 = appearance::text_width("3", 10.0); + let mut typed = new(0, Shape::Typewriter { rect: [100.0, 680.0, 110.0 + w3, 700.0], font_size: 10.0 }); + (typed.contents, typed.style.width, typed.style.border_shape) = ("3".into(), 1.0, BorderShape::Circle); + let t = add_annotation(&mut doc, &typed, &meta("t")).unwrap(); + let frame = |doc: &Document| { + let r = rect(&list(doc, 0)[t]); + ((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)) + }; + let centre = (105.0 + w3 / 2.0, 690.0); + // As tall as the box, 20 pt across: through the corners it was 29. + assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + // The text box Fill & Sign gives a box to type into, 40 pt wide: a circle goes snug around its + // text there too. + let mut wide = typed.clone(); + wide.shape = Shape::Typewriter { rect: [300.0, 680.0, 342.0, 700.0], font_size: 10.0 }; + wide.style.border_shape = BorderShape::Rectangle; + let b = add_annotation(&mut doc, &wide, &meta("b")).unwrap(); + set_border_shape(&mut doc, 0, b, BorderShape::Circle, &meta("")).unwrap(); + let r = rect(&list(&doc, 0)[b]); + assert!(close((r[2] - r[0], r[3] - r[1]), (20.0, 20.0)), "{r:?}"); + // More text: the circle grows with it, about the same centre; less, and it shrinks back. + set_contents(&mut doc, 0, t, "123", &meta("")).unwrap(); + let w = appearance::text_width("123", 10.0) / 2.0; + let d = 2.0 * (w + 5.0).max(10.0).max(w.hypot(6.0) + 2.0); + assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + set_contents(&mut doc, 0, t, "3", &meta("")).unwrap(); + assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + // A larger font: a larger circle, still snug. + set_font_size(&mut doc, 0, t, 20.0, &meta("")).unwrap(); + let w = appearance::text_width("3", 20.0) / 2.0; + let d = 2.0 * (w + 5.0).max(16.0).max(w.hypot(12.0) + 2.0); + assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); + set_font_size(&mut doc, 0, t, 10.0, &meta("")).unwrap(); + assert!(close(frame(&doc).0, (20.0, 20.0)), "{:?}", frame(&doc)); + // Made larger by hand, it keeps its size as its text changes. + set_rect(&mut doc, 0, t, [centre.0 - 20.0, centre.1 - 20.0, centre.0 + 20.0, centre.1 + 20.0], &meta("")).unwrap(); + assert!(close(frame(&doc).0, (40.0, 40.0)), "{:?}", frame(&doc)); + set_contents(&mut doc, 0, t, "4", &meta("")).unwrap(); + assert!(close(frame(&doc).0, (40.0, 40.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc)); +} + +/// A circle around a line wider than it is tall touches its text box's sides: `/RD` is 0 there, +/// not a rounding error below it, and a hair below 0 still reads. (A text box that couldn't be +/// read stopped following its text, and its text wrapped in a narrower one.) The circle keeps its +/// centre as its text changes. +#[test] +fn a_circle_touching_its_text_box_keeps_following_its_text() { + let mut doc = fixture(); + // Sized to its text: 2 pt of padding and half a point to spare across, a line down. + let (w, h) = (appearance::text_width("Testtext", 12.0) + 4.5, 12.0 * 1.11 + 4.0); + let mut b = new(0, Shape::TextBox { rect: [210.79, 700.0 - h, 210.79 + w, 700.0], font_size: 12.0 }); + (b.contents, b.style.width) = ("Testtext".into(), 0.0); + let t = add_annotation(&mut doc, &b, &meta("t")).unwrap(); + // Circle, then a 1 pt border, which the circle grows by. + set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(1.0), None), &meta("")).unwrap(); + let lines = |doc: &Document| ap_content(doc, &list(doc, 0)[t]).matches(" Tj").count(); + let frame = |doc: &Document| { + let r = rect(&list(doc, 0)[t]); + ((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)) + }; + let rd = |doc: &Document| -> Vec { list(doc, 0)[t].get(b"RD").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() }; + assert!(rd(&doc).iter().all(|x| *x >= 0.0) && rd(&doc)[0] < 0.01, "touching the sides: {:?}", rd(&doc)); + assert_eq!(lines(&doc), 1, "Testtext on one line"); + let (wide, centre) = frame(&doc); + set_contents(&mut doc, 0, t, "KRS", &meta("")).unwrap(); + let (small, at) = frame(&doc); + assert!(small.0 < wide.0 - 10.0 && (small.0 - small.1).abs() < 0.01, "a smaller circle: {small:?} from {wide:?}"); + assert!((at.0 - centre.0).abs() < 0.01 && (at.1 - centre.1).abs() < 0.01, "about its centre: {at:?} {centre:?}"); + assert_eq!(lines(&doc), 1); + // A hair below 0, as rounding left it: still read, so the circle follows its text again. + let (_, r) = annot_ref(&mut doc, 0, t).unwrap(); + let mut was = rd(&doc); + was[0] = -2.8e-14; + doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&was))).unwrap(); + assert!(text_box_of(&list(&doc, 0)[t]).is_some()); + set_contents(&mut doc, 0, t, "Testtext", &meta("")).unwrap(); + assert!((frame(&doc).0.0 - wide.0).abs() < 0.01 && lines(&doc) == 1, "{:?}", frame(&doc)); + assert!(rd(&doc).iter().all(|x| *x >= 0.0), "{:?}", rd(&doc)); +} + +/// On a page turned a quarter, typed text is laid out as the page is shown (`/PCTextRotate`), and a +/// circle made with it goes around the text as shown: as large as around the same text on an +/// upright page. (Made before the turn was set, its text wrapped at the box's displayed height and +/// the circle went around several lines.) +#[test] +fn a_circle_made_on_a_turned_page_is_sized_as_on_an_upright_one() { + let w = appearance::text_width("12345", 10.0); + let typed = |rect: [f64; 4]| { + let mut t = new(0, Shape::Typewriter { rect, font_size: 10.0 }); + (t.contents, t.style.width, t.style.border_shape) = ("12345".into(), 1.0, BorderShape::Circle); + t + }; + let size = |doc: &Document, i: usize| { + let r = rect(&list(doc, 0)[i]); + (r[2] - r[0], r[3] - r[1]) + }; + let mut upright = fixture(); + let u = add_annotation(&mut upright, &typed([100.0, 680.0, 110.0 + w, 700.0]), &meta("u")).unwrap(); + for turn in [90, 270] { + let mut doc = fixture(); + let page = page_refs(&doc).unwrap()[0]; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(turn))).unwrap(); + // The same box as shown: its sides swapped in user space. + let t = add_annotation(&mut doc, &typed([100.0, 680.0, 120.0, 690.0 + w]), &meta("t")).unwrap(); + assert_eq!(list(&doc, 0)[t].get(b"PCTextRotate").and_then(Object::as_int), Some(turn)); + let (turned, straight) = (size(&doc, t), size(&upright, u)); + assert!((turned.0 - straight.0).abs() < 0.01 && (turned.1 - straight.1).abs() < 0.01, "{turn}°: {turned:?}, upright {straight:?}"); + } +} + /// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of /// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and /// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text. diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index abc5c17601..f6bcc68a7e 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::{ObjRef, appearance}; +use super::{Dict, ObjRef, appearance}; pub type Rgb = [f64; 3]; @@ -591,6 +591,8 @@ pub struct Style { /// A text box's, typewriter text's or callout's border colour where it differs from the /// text's (`None`: the text colour), the stroking colour in its `/DA`. pub border_color: Option, + /// A text box's or typewriter text's border shape (a callout's is always a rectangle). + pub border_shape: BorderShape, /// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the /// line's or border's); `None`: as `opacity`. pub fill_opacity: Option, @@ -627,7 +629,102 @@ pub struct TextReplacement<'a> { impl Default for Style { fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None, border_color: None } + Self { + color: [1.0, 0.82, 0.0], + opacity: 1.0, + width: 1.0, + fill: None, + fill_opacity: None, + text_opacity: None, + border_color: None, + border_shape: BorderShape::Rectangle, + } + } +} + +/// The shape of a text box's or typewriter text's border (PdfCraft's `/PCBorderShape`, absent for +/// a rectangle). An oval or a circle goes around the text box, which keeps its place: `/Rect` +/// grows to hold it, centred on the text box, and `/RD` says where the text box is inside +/// (§12.5.6.6), as for a callout. Other apps that redraw the comment draw a rectangle. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BorderShape { + #[default] + Rectangle, + Oval, + Circle, +} + +impl BorderShape { + pub const ALL: [BorderShape; 3] = [BorderShape::Rectangle, BorderShape::Oval, BorderShape::Circle]; + + pub fn name(self) -> &'static str { + match self { + BorderShape::Rectangle => "Rectangle", + BorderShape::Oval => "Oval", + BorderShape::Circle => "Circle", + } + } + + /// `rectangle`, `oval` or `circle`, in any case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|b| b.name().eq_ignore_ascii_case(s)) + } + + /// The border shape of FreeText `d`: always a rectangle for a callout. + pub fn of(d: &Dict) -> Self { + if d.contains(b"CL") { + return BorderShape::Rectangle; + } + match d.name(b"PCBorderShape") { + Some(b"Oval") => BorderShape::Oval, + Some(b"Circle") => BorderShape::Circle, + _ => BorderShape::Rectangle, + } + } + + /// The rectangle this shape, with a border `width` wide, takes around text box `tb`, centred on + /// it: an oval through the text box's corners (`√2` its size), a point clear of the border; a + /// circle around the text's lines (`text`: the widest line's width and the lines' height, when + /// known), a point clear of the border and touching the text box's longer sides at least + /// (its corners, padding only, may lie outside), else through the box's corners; the text box + /// itself for a rectangle. + pub fn around(self, tb: [f64; 4], width: f64, text: Option<[f64; 2]>) -> [f64; 4] { + let tb = normalize(tb); + let (cx, cy, hx, hy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0, (tb[2] - tb[0]) / 2.0, (tb[3] - tb[1]) / 2.0); + let gap = width.max(0.0) + 1.0; + let (ax, ay) = match self { + BorderShape::Rectangle => return tb, + BorderShape::Oval => (hx * std::f64::consts::SQRT_2 + gap, hy * std::f64::consts::SQRT_2 + gap), + BorderShape::Circle => { + let r = match text { + Some([w, h]) => ((w / 2.0).hypot(h / 2.0) + gap).max(hx).max(hy), + None => hx.hypot(hy) + gap, + }; + (r, r) + } + }; + [cx - ax, cy - ay, cx + ax, cy + ay] + } + + /// The text box inside `outer` for this shape when none is recorded: the inverse of + /// [`Self::around`] for an oval, a box through a circle's corners for a circle, keeping the + /// proportions of `was` (the one it had). + pub(crate) fn inside(self, outer: [f64; 4], width: f64, was: [f64; 4]) -> [f64; 4] { + let o = normalize(outer); + let (cx, cy, hx, hy) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]) / 2.0, (o[3] - o[1]) / 2.0); + let gap = width.max(0.0) + 1.0; + let (ix, iy) = match self { + BorderShape::Rectangle => return o, + BorderShape::Oval => ((hx - gap) / std::f64::consts::SQRT_2, (hy - gap) / std::f64::consts::SQRT_2), + BorderShape::Circle => { + let w = normalize(was); + let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0); + let k = (hx.min(hy) - gap) / wx.hypot(wy).max(1e-6); + (wx * k, wy * k) + } + }; + let (ix, iy) = (ix.max(0.5), iy.max(0.5)); + [cx - ix, cy - iy, cx + ix, cy + iy] } } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index b8b815ac37..78f4cefbe0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; -use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; +use pdfcraft_engine::{ + BorderShape, Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, +}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -190,6 +192,12 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); + // A text box's or typed text's oval or circle (a rectangle is not reported). + if c.subtype == "FreeText" + && let Some(shape) = doc.comment_props(c.page, c.index).and_then(|p| p.border_shape).filter(|s| *s != BorderShape::Rectangle) + { + v["border_shape"] = json!(shape.name().to_ascii_lowercase()); + } // How opaque it is drawn: as a whole, or its fill and text each at their own. // Not for replies, which are drawn with the comment they answer. if !c.is_reply() @@ -466,6 +474,12 @@ impl Automation { if let Some(w) = a.opt_num("width")? { style.width = w.max(0.0); } + if let Some(b) = border_shape(a)? { + if !matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. }) { + return Err(ToolError::InvalidArgs("border_shape is for a text box or typewriter text".into())); + } + style.border_shape = b; + } let contents = a.opt_str("contents")?.unwrap_or_default().to_string(); let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); // Replace Text: the struck-out text and a grouped caret holding `contents`. @@ -526,6 +540,7 @@ impl Automation { } pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result { + let border_shape = border_shape(a)?; use pdfcraft_engine::FillMark; let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); @@ -565,6 +580,13 @@ impl Automation { if border_color.is_some() && border <= 0.0 { return Err(ToolError::InvalidArgs("border_color needs a border".into())); } + if border_shape.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("border_shape is only for type text or date".into())); + } + // Without a border an oval or a circle would grow the box around nothing drawn. + if border_shape.is_some_and(|s| s != BorderShape::Rectangle) && border <= 0.0 { + return Err(ToolError::InvalidArgs("border_shape needs a border".into())); + } if let Some(path) = a.opt_str("path")? { if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() { return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into())); @@ -588,9 +610,11 @@ impl Automation { let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; return self.apply(a, edit); } + // A circle goes around the text, so its box is no wider than the text needs. + let min_width = if border_shape == Some(BorderShape::Circle) { 0.0 } else { 20.0 }; let text_at = |t: &str| { let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); - let w = (longest + 8.0).clamp(20.0, 600.0); + let w = (longest + 8.0).clamp(min_width, 600.0); let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); let h = rows as f64 * size * 1.2 + 6.0; let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); @@ -644,6 +668,7 @@ impl Automation { if matches!(shape, Shape::Typewriter { .. }) { style.width = border; style.border_color = border_color; + style.border_shape = border_shape.unwrap_or_default(); } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -715,6 +740,9 @@ impl Automation { let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) }; edits.push(Edit::BorderColorAnnotation { page, index, color }); } + if let Some(shape) = border_shape(a)? { + edits.push(Edit::BorderShapeAnnotation { page, index, shape }); + } // After `opacity`, which sets the whole comment's. let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?); if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) { @@ -756,7 +784,7 @@ impl Automation { } if edits.is_empty() { return Err(ToolError::InvalidArgs( - "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, font_size, rect, move or points".into(), + "nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, border_shape, font_size, rect, move or points".into(), )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; @@ -854,6 +882,13 @@ impl Automation { } } +/// `border_shape`: `rectangle`, `oval` or `circle`. +fn border_shape(a: &Args) -> Result> { + a.opt_str("border_shape")? + .map(|s| BorderShape::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown border_shape {s:?}: rectangle, oval or circle")))) + .transpose() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d15c1d915f..bf2abea185 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -862,6 +862,7 @@ pub fn tools() -> Vec { "fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." }, "text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text box." }, "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, "ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, @@ -1004,6 +1005,7 @@ pub fn tools() -> Vec { "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text, which keeps its place." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), @@ -1422,6 +1424,7 @@ pub fn tools() -> Vec { "color": color(), "border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." }, "border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." }, + "border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "With border, for type text or date: the border's shape (default rectangle); an oval or a circle goes around the text." }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a4ee239a62..6f493b824d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// Typed text and text boxes take an oval or a circle for their border through the tools; +/// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes +/// have one, and only the three shapes. +#[test] +fn text_borders_take_an_oval_or_a_circle_through_the_tools() { + let dir = workdir("border-shape"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "12", "border": 1, "border_shape": "circle" }), + ); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "width": 1, "border_shape": "Oval" }), + ); + let list = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc, "page": 1 }))["comments"].as_array().unwrap().clone(); + let shapes: Vec = list(&mut a).iter().map(|c| c["border_shape"].clone()).collect(); + assert_eq!(shapes, [json!("circle"), json!("oval")]); + let typed = list(&mut a)[0].clone(); + // Snug around "12": the box no wider than the text needs, the circle as wide as that box. + let r: Vec = typed["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0; + assert!((r[2] - r[0] - w).abs() < 0.05 && (r[3] - r[1] - w).abs() < 0.05, "{r:?}"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "oval" })); + assert_eq!(list(&mut a)[0]["border_shape"], "oval"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "rectangle" })); + assert!(list(&mut a)[0].get("border_shape").is_none(), "a rectangle is not reported"); + for (tool, args) in [ + ("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [60, 60], "border_shape": "oval" })), + ("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "text": "7", "at": [60, 60], "border_shape": "circle" })), + ("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "border_shape": "oval" })), + ("comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "star" })), + ] { + assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + let callout = ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 100, 190, 140], "to": [40, 60], "contents": "Look" }), + ); + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": callout["comment"]["index"], "border_shape": "oval" })), + Err(ToolError::Failed(_)) + )); +} + /// A rectangle's fill and a text box's text each take an opacity of their own through the tools /// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again. #[test] diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index ab3393a0f5..1bcf24108f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use crate::{ - AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, - LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, - Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, + InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, + Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, + field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, + xfa, xfa_after_data_change, }; mod labels; @@ -285,6 +286,13 @@ pub enum Edit { index: usize, color: Option, }, + /// Give a text box or typewriter text an oval, a circle or a rectangle for its border: the + /// text box keeps its place, the oval or circle goes around it. + BorderShapeAnnotation { + page: usize, + index: usize, + shape: BorderShape, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index a244d70c93..615ac0fea5 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -237,6 +237,7 @@ impl Edit { | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 3d24a22fa2..588a3a847f 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -68,6 +68,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -589,6 +590,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?, + Edit::BorderShapeAnnotation { page, index, shape } => pdfcraft_annot::set_border_shape(doc, *page, *index, *shape, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index c679406154..ce51df5e61 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -72,6 +72,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::PartOpacity { .. } | Edit::FontSizeAnnotation { .. } | Edit::BorderColorAnnotation { .. } + | Edit::BorderShapeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca25500ec0..1c113d0d82 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -113,8 +113,8 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use pdfcraft_annot::{ - Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, - Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, + OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, }; pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7c7710b5b8..61b85cd7f1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -112,6 +112,7 @@ impl PdfCraftApp { fill_text_color: [0.0; 3], fill_text_border: 0.0, fill_text_border_color: None, + fill_text_border_shape: pdfcraft_engine::BorderShape::Rectangle, fill_text_default: fill_sign::TextBorder::NONE, dialog: None, updates: updates::Updates::default(), diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index ee33caeb5c..89789d8a4c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -105,6 +105,7 @@ impl PdfCraftApp { "fill_text_size": self.fill_text_size, "fill_text_border": self.fill_text_default.width, "fill_text_border_color": self.fill_text_default.color, + "fill_text_border_shape": self.fill_text_default.shape.name(), "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(), @@ -123,6 +124,7 @@ impl PdfCraftApp { "width": s.width, "fill": s.fill, "border_color": s.border_color, + "border_shape": s.border_shape.name(), })) .collect::>(), // Drawn signatures keep their original form (older settings read the same). @@ -316,6 +318,9 @@ impl PdfCraftApp { if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) { self.fill_text_default.width = width; } + if let Some(shape) = v["fill_text_border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) { + self.fill_text_default.shape = shape; + } // `null` is a saved "the text's colour"; a missing or malformed one keeps the setting. match v.get("fill_text_border_color") { Some(serde_json::Value::Null) => self.fill_text_default.color = None, @@ -329,6 +334,7 @@ impl PdfCraftApp { // Fill & Sign text starts from the saved border. self.fill_text_border = self.fill_text_default.width; self.fill_text_border_color = self.fill_text_default.color; + self.fill_text_border_shape = self.fill_text_default.shape; if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; @@ -405,6 +411,9 @@ impl PdfCraftApp { } None => {} } + if let Some(shape) = e["border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) { + self.comment_prefs.set_border_shape(tool, shape); + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9681126048..9004d88205 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3161,7 +3161,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, - fill_text_border: crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, + fill_text_border: crate::fill_sign::TextBorder { + width: app.fill_text_border, + color: app.fill_text_border_color, + shape: app.fill_text_border_shape, + }, today, can_paste_image, }; diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index e3762ce0c0..12752dde3c 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -113,8 +113,9 @@ impl PdfCraftApp { self.fill_text_color = color; } let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); - self.fill_text_default = crate::fill_sign::TextBorder { width, color: p.border_color }; - (self.fill_text_border, self.fill_text_border_color) = (width, p.border_color); + let border = crate::fill_sign::TextBorder { width, color: p.border_color, shape: drawn_border_shape(p).unwrap_or_default() }; + self.fill_text_default = border; + (self.fill_text_border, self.fill_text_border_color, self.fill_text_border_shape) = (border.width, border.color, border.shape); self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); return; } @@ -129,6 +130,9 @@ impl PdfCraftApp { self.comment_prefs.set_style(tool, style); // A text box's or callout's own border colour (cleared for tools that have none). self.comment_prefs.set_border_color(tool, p.border_color); + if let Some(shape) = drawn_border_shape(p) { + self.comment_prefs.set_border_shape(tool, shape); + } // The fill's and text's own opacities where they show (else the opacity is the whole // comment's). With neither a border nor a fill the text is the whole comment: its drawn // opacity, which Comment Properties shows, is the whole comment's default. @@ -184,6 +188,12 @@ impl PdfCraftApp { } } +/// The border shape `p` draws: a Thickness of 0 takes an oval or a circle away with the line, as +/// the toolbar's Border ▸ None does. +fn drawn_border_shape(p: &CommentProps) -> Option { + p.border_shape.map(|s| if p.width.is_some_and(|w| w <= 0.0) { pdfcraft_engine::BorderShape::Rectangle } else { s }) +} + /// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's /// text, border and fill each do once it has a border or a fill; a filled shape's line and fill /// do; anything else has one opacity for the whole comment. @@ -270,6 +280,19 @@ fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &Co if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); } + // After the width (StyleAnnotation), whose border the shape goes around: each selected + // text keeps its own shape unless the draft's changed, and loses it with its border. + let mut shaped = after; + if e.border_shape != o.border_shape { + shaped.border_shape = e.border_shape; + } + if t.restylable + && t.border_shape.is_some() + && (e.border_shape != o.border_shape || width.is_some()) + && let Some(shape) = drawn_border_shape(&shaped).filter(|s| Some(*s) != t.border_shape) + { + out.push(Edit::BorderShapeAnnotation { page, index, shape }); + } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); @@ -351,6 +374,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); ui.end_row(); } + // Text boxes and typewriter text: an oval or a circle goes around the text. + if let Some(shape) = e.border_shape.as_mut() { + let label = ui.label(tl!("Border shape")); + craft_ui::ComboBox::from_id_salt("border-shape") + .selected_text(tl!(shape.name())) + .show_ui(ui, |ui| { + for s in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(shape, s, tl!(s.name())); + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index e5e2d49b0d..4439dfe493 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -931,6 +931,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } | Edit::BorderColorAnnotation { page, .. } + | Edit::BorderShapeAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index fafae309ff..8080758598 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -323,8 +323,8 @@ fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { .then_some((page, annotation)) } -/// Fill & Sign's Border controls for typed text: its width and colour, for the selected text -/// (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// Fill & Sign's Border controls for typed text: its width, colour and shape, for the selected +/// text (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the /// font size's does. fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { // The selected text's own border (Comment Properties may have changed it @@ -333,9 +333,13 @@ fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: O // Default on typed text keeps it. let selected = picked.and_then(|(page, annotation)| { let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; - Some((page, annotation, crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color })) + let own = crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color, shape: p.border_shape.unwrap_or_default() }; + Some((page, annotation, own)) }); - let was = selected.map_or(crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color }, |(_, _, own)| own); + let was = selected.map_or( + crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color, shape: app.fill_text_border_shape }, + |(_, _, own)| own, + ); let mut border = was; // A border around the text, in its colour; the text keeps its place. let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; @@ -363,18 +367,48 @@ fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: O { border.color = color; } + // Its shape: an oval or a circle goes around the text, which keeps its place. + if was.width > 0.0 { + craft_ui::ComboBox::from_id_salt("fill-text-border-shape") + .selected_text(tl!(was.shape.name())) + .width(96.0) + .show_ui(ui, |ui| { + for shape in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(&mut border.shape, shape, tl!(shape.name())); + } + }) + .response + .on_hover_text(tl!("Border shape")); + } + // No border, no shape: an oval or a circle left without its line would + // keep its box grown around a shape that isn't drawn. + if border.width <= 0.0 { + border.shape = pdfcraft_engine::BorderShape::Rectangle; + } match selected { Some((page, index, _)) => { + // One undo step for what one choice changed (Border ▸ None takes a + // circle's line and its shape). + let mut edits = Vec::new(); if border.width != was.width { - app.apply_edit(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + edits.push(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); } if border.color != was.color { - app.apply_edit(Edit::BorderColorAnnotation { page, index, color: border.color }); + edits.push(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + if border.shape != was.shape { + edits.push(Edit::BorderShapeAnnotation { page, index, shape: border.shape }); + } + if edits.len() > 1 { + app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } else if let Some(edit) = edits.pop() { + app.apply_edit(edit); } } None => { app.fill_text_border = border.width; app.fill_text_border_color = border.color; + app.fill_text_border_shape = border.shape; } } } @@ -427,18 +461,24 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve if let QuickTool::Comment(tool) = app.quick_tool { controls_divider(ui, t, vertical); // Text boxes and callouts: the border's own colour, for new ones and the selected one - // this tool made (not a locked one). + // this tool made (not a locked one). With one selected the button shows its colours + // (Comment Properties may have changed them), else the tool's. if tool.has_border_color() { let style = app.comment_prefs.style(tool); - if let Some(color) = comments::border_colour_button(ui, style.border_color, style.color) { + let selected = app.active_ids().and_then(|(index, id)| { + let (page, annotation) = app.views.get(index)?.comments.selected?; + let doc = app.session.get(id)?; + let made = doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)); + made.then(|| (page, annotation, doc.comment_props(page, annotation))) + }); + let (shown, text) = match selected.as_ref().and_then(|(.., p)| p.as_ref()) { + Some(p) => (p.border_color, p.color.unwrap_or(style.color)), + None => (style.border_color, style.color), + }; + if let Some(color) = comments::border_colour_button(ui, shown, text) { app.comment_prefs.set_border_color(tool, color); - if let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)) - }) - { + if let Some((page, annotation, _)) = selected { app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cb7237376..94e83f451a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -342,6 +342,8 @@ pub struct PdfCraftApp { pub fill_text_border: f64, /// That border's own colour (`None`: the text's). pub fill_text_border_color: Option, + /// That border's shape (an oval or a circle goes around the text). + pub fill_text_border_shape: pdfcraft_engine::BorderShape, /// The border new Fill & Sign text starts each launch with, kept across launches: what Make /// Current Properties Default last took from typed text (none at first). pub fill_text_default: fill_sign::TextBorder, diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 451e5e4000..a906386d73 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -34,7 +34,7 @@ use geometry::handle_pos; #[cfg(test)] pub(crate) use geometry::{caret_snap, snapped_caret}; pub(crate) use geometry::{corner_ratio, page_rotation, resized}; -pub use geometry::{drawn_text_box_rect, text_box_rect}; +pub use geometry::{drawn_text_box_rect, snug_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; @@ -359,6 +359,13 @@ impl CommentPrefs { } } + /// The text box tool's border shape (the other tools keep a rectangle). + pub fn set_border_shape(&mut self, tool: CommentTool, shape: pdfcraft_engine::BorderShape) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_shape = if tool == CommentTool::TextBox { shape } else { pdfcraft_engine::BorderShape::Rectangle }; + } + } + /// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %), /// for new comments; `None`: as the tool's opacity. pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option, text: Option) { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 4f3b338fe6..7e291be4db 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -159,10 +159,19 @@ pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], /// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by /// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space. pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 40.0) +} + +/// [`text_box_rect`] without its 40 pt minimum width: the box of text a circle goes around. +pub fn snug_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 0.0) +} + +fn sized_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64, min_width: f64) -> [f64; 4] { use pdfcraft_engine::annot_text::{text_width, wrap}; let pad = 2.0; let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); - let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); + let w = (longest + 2.0 * pad + 4.0).clamp(min_width, 300.0); let lines = wrap(text, size, w - 2.0 * pad).len().max(1); let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 7e98eda05b..c7e270fdb9 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{BorderShape, Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -220,16 +220,17 @@ pub struct TypeBox { pub focus: bool, } -/// The border around typed text: its width in points (0: none) and its own colour (`None`: the -/// text's). +/// The border around typed text: its width in points (0: none), its own colour (`None`: the +/// text's) and its shape (an oval or a circle goes around the text). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextBorder { pub width: f64, pub color: Option, + pub shape: BorderShape, } impl TextBorder { - pub const NONE: Self = Self { width: 0.0, color: None }; + pub const NONE: Self = Self { width: 0.0, color: None, shape: BorderShape::Rectangle }; } /// The default size Fill & Sign uses for typed text. @@ -281,12 +282,18 @@ pub struct TypedLook { /// border goes around the box sized to the text, so the text sits where it would without one. pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { let width = valid_text_border(look.border.width).unwrap_or(0.0); - let [x0, y0, x1, y1] = crate::comments::text_box_rect(at, text, size, rotation); + // A circle goes around the text, so its box is no wider than the text needs. + let [x0, y0, x1, y1] = if width > 0.0 && look.border.shape == BorderShape::Circle { + crate::comments::snug_text_box_rect(at, text, size, rotation) + } else { + crate::comments::text_box_rect(at, text, size, rotation) + }; let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; let style = Style { color: valid_text_color(look.color).unwrap_or([0.0; 3]), width, border_color: look.border.color.filter(|_| width > 0.0), + border_shape: if width > 0.0 { look.border.shape } else { BorderShape::Rectangle }, ..Style::default_for(&shape) }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) @@ -642,7 +649,29 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if t.border.width > 0.0 { let w = (t.border.width as f32 * zoom).max(1.0); let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); - ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + match t.border.shape { + BorderShape::Rectangle => { + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } + // A circle around the text typed so far, as `typed` will draw it: the box sized to + // the text, the border around that. + BorderShape::Circle => { + let (bw, size) = (t.border.width, t.size); + let longest = pdfcraft_engine::annot_text::text_width(t.text.trim(), size); + let (tw, th) = (longest + 8.0 + 2.0 * bw, size * 1.2 + 6.0 + 2.0 * bw); + let o = BorderShape::Circle.around([0.0, 0.0, tw, th], bw, Some([longest, size * 1.2])); + let centre = pos + egui::vec2((tw / 2.0 - bw) as f32, (th / 2.0 - bw) as f32) * zoom; + let radius = (o[2] - o[0]) as f32 / 2.0 * zoom - w / 2.0; + ui.painter().add(egui::Shape::circle_stroke(centre, radius, Stroke::new(w, color))); + } + // An oval around it, as it will be drawn (half the line outside). + shape => { + let b = r.rect.expand(w); + let o = shape.around([b.min.x, b.min.y, b.max.x, b.max.y].map(f64::from), f64::from(w), None); + let half = egui::vec2((o[2] - o[0]) as f32 / 2.0 - w / 2.0, (o[3] - o[1]) as f32 / 2.0 - w / 2.0); + ui.painter().add(egui::Shape::ellipse_stroke(r.rect.center(), half, Stroke::new(w, color))); + } + } } if t.focus { r.request_focus(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 0396714c18..2308ba9e0f 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1373,6 +1373,80 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() { assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0])); } +/// Comment Properties ▸ Border shape puts an oval or a circle around a text box's text; a +/// callout has none to choose. Make Properties Default carries it into the Text box tool. +#[test] +fn text_box_properties_choose_the_border_shape() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Border shape"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Rectangle)); + d.edited.border_shape = Some(BorderShape::Oval); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.comment_props(0, 0).unwrap().border_shape, Some(BorderShape::Oval)); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Oval, "the default for new text boxes"); +} + +/// Thickness 0 in Comment Properties takes a circle away with the line, as Border ▸ None does on +/// the toolbar, and a default made from it draws no circle either. +#[test] +fn no_thickness_in_text_box_properties_takes_the_circle_away() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page: 0, index: 0, shape: BorderShape::Circle })); + h.state_mut().comment_prefs.set_border_shape(CommentTool::TextBox, BorderShape::Circle); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Circle)); + d.edited.width = Some(0.0); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let p = doc.comment_props(0, 0).unwrap(); + assert_eq!((p.width, p.border_shape), (Some(0.0), Some(BorderShape::Rectangle))); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Rectangle, "the default for new text boxes"); +} + /// A filled rectangle's line and fill, and a text box's text, border and fill, each take an /// opacity of their own in Properties; Make Properties Default carries them into the tool, so /// the next rectangle is drawn with a see-through fill and an opaque line. @@ -2412,6 +2486,47 @@ fn a_text_box_made_default_gives_the_tool_its_border_colour() { assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); } +/// The Text box tool's Border colour button shows the selected text box's own border colour +/// (Comment Properties may have given it one), and the tool's again with none selected. +#[test] +fn the_border_colour_button_shows_the_selected_text_boxs_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + // A border colour of its own, which the tool doesn't have. + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: Some([0.0, 0.47, 0.84]) })); + h.run_steps(2); + // The colour the button's square is drawn in. + let shown = |h: &Harness<'static, PdfCraftApp>| { + let button = h.get_by_label("Border colour").rect(); + h.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.width == 3.0 && button.contains_rect(r.rect) => Some(r.stroke.color), + _ => None, + }) + }; + // A new text box comes selected. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let tool = shown(&h).expect("the button's square"); + h.state_mut().views[0].comments.selected = Some((0, index)); + h.run_steps(3); + let own = shown(&h).expect("the button's square"); + assert!(own != tool && own.b() > 200 && own.r() < 30, "the selected text box's blue: {own:?}, the tool's {tool:?}"); + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + assert_eq!(shown(&h), Some(tool), "the tool's colour again"); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -3161,6 +3276,7 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { fill_opacity: None, text_opacity: None, border_color: None, + border_shape: pdfcraft_engine::BorderShape::Rectangle, }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 55b6bdafd9..b5bb52cfa7 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -426,6 +426,125 @@ fn the_toolbar_shows_and_changes_the_selected_texts_border() { assert!(shows(&h, "None")); } +/// With a border, typed text can have an oval or a circle around it instead of a box (a +/// position number in a circle on a plan): the toolbar's shape control sets it for new text and +/// for the selected text, and shows the selected text's own. The text stays where it was typed. +#[test] +fn typed_text_takes_an_oval_or_a_circle_from_the_toolbar() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Border shape").count(), 0, "no shape without a border"); + h.state_mut().fill_text_border = 1.0; + h.run_steps(2); + // The shape control shows "Rectangle"; pick Circle for new text. It is the last of the text's + // controls, so in a vertical bar it may sit below the bar's end: scrolled to first, as by hand. + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Rectangle")) + .expect("the shape control") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Rectangle")).expect("the shape control").click(); + h.run_steps(2); + h.get_by_label("Circle").click(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle), "new text gets the circle"); + let r = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.index == index).unwrap().rect; + assert!(((r[2] - r[0]) - (r[3] - r[1])).abs() < 0.01, "a circle's square box: {r:?}"); + // Snug around "12" at 10 pt: its box no wider than the text needs (not the 40 pt a box to type + // into keeps), so the circle is as wide as that box, where it was 50 pt around the wider one. + let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0; + let across = f64::from(r[2] - r[0]); + assert!((across - w.max(20.0)).abs() < 0.01, "the circle is {across} pt across, not {w}: {r:?}"); + // Selected, an oval from the toolbar: one undo step. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Circle")) + .expect("shows the text's own") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Circle")).expect("shows the text's own").click(); + h.run_steps(2); + h.get_by_label("Oval").click(); + h.run_steps(4); + assert_eq!(props(&h).border_shape, Some(BorderShape::Oval)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps the circle"); + // As Comment Properties does: the toolbar shows it. + h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Rectangle }); + h.run_steps(3); + assert!(h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some("Rectangle")), "the selected text's own"); + + // The circle for new text lasts until PdfCraft quits; a saved default is read back. + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!(restored.fill_text_border_shape, BorderShape::Rectangle); + restored.restore(r#"{"fill_text_border_shape":"oval"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval); + restored.restore(r#"{"fill_text_border_shape":"star"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval, "an unknown shape keeps the setting"); +} + +/// Border ▸ None takes an oval or a circle away with its line, from the selected text and for new +/// text: without one, the text's box stayed grown around a shape that isn't drawn. +#[test] +fn no_border_takes_the_oval_or_circle_away_too() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.state_mut().fill_text_border = 1.0; + h.state_mut().fill_text_border_shape = BorderShape::Circle; + h.run_steps(2); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle)); + let no_border = |h: &mut Harness<'static, PdfCraftApp>| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("1 pt")).expect("the Border control").click(); + h.run_steps(2); + h.get_by_label("None").click(); + h.run_steps(4); + }; + // The selected text: no line, and a rectangle again, its box the text's. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + no_border(&mut h); + assert_eq!(props(&h).width, Some(0.0)); + assert_eq!(props(&h).border_shape.unwrap_or_default(), BorderShape::Rectangle); + // One undo step brings back the line and the circle together. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!((props(&h).width, props(&h).border_shape), (Some(1.0), Some(BorderShape::Circle)), "one undo step"); + h.state_mut().redo(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps its circle"); + // New text: none, and a rectangle when a border is chosen again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + no_border(&mut h); + assert_eq!((h.state().fill_text_border, h.state().fill_text_border_shape), (0.0, BorderShape::Rectangle)); +} + /// The Border control puts a frame around new Fill & Sign text, and around the text that is /// selected; the box grows outwards, so the text stays where it was typed. The choice persists. #[test] @@ -487,7 +606,7 @@ fn fill_text_border_frames_new_and_selected_text() { /// border, and keeps it across launches. #[test] fn typed_text_made_default_is_what_new_text_gets() { - use pdfcraft_engine::Edit; + use pdfcraft_engine::{BorderShape, Edit}; let mut h = harness(); h.state_mut().execute("sign.fill.text"); let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { @@ -504,6 +623,7 @@ fn typed_text_made_default_is_what_new_text_gets() { for edit in [ Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, Edit::BorderColorAnnotation { page, index, color: red }, + Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Circle }, Edit::FontSizeAnnotation { page, index, size: 14.0 }, ] { assert!(h.state_mut().apply_edit(edit)); @@ -515,14 +635,18 @@ fn typed_text_made_default_is_what_new_text_gets() { h.get_by_label("OK").click(); h.run_steps(3); let s = h.state(); - assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color), (14.0, 1.5, red)); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color, s.fill_text_border_shape), (14.0, 1.5, red, BorderShape::Circle)); let mut restored = PdfCraftApp::new(); restored.restore(&h.state().persist()); - assert_eq!((restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color), (14.0, 1.5, red), "kept across launches"); + assert_eq!( + (restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color, restored.fill_text_border_shape), + (14.0, 1.5, red, BorderShape::Circle), + "kept across launches" + ); h.state_mut().views[0].comments.selected = None; let (page, index) = type_at(&mut h, 350.0, "Next"); let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); - assert_eq!((p.font_size, p.width, p.border_color), (Some(14.0), Some(1.5), red)); + assert_eq!((p.font_size, p.width, p.border_color, p.border_shape), (Some(14.0), Some(1.5), red, Some(BorderShape::Circle))); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b6c764af48..3523db9eed 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -76,7 +76,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Print dialog with preview | yes | yes | shipped | | Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | | Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | -| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped | +| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment); text boxes and typed text take an oval or a circle for their border (Acrobat: a rectangle) | shipped | | Select several comments; copy and paste comments between documents | yes | yes: ⌘/Ctrl-click adds, Delete, a drag, Properties and Colour act on all, ⌘C/⌘V as XFDF (also across PdfCraft processes) | shipped | | Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf6a3db0a9..9635898230 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3761,6 +3761,18 @@ tools = ["fill_sign_add"] evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." +[[feature]] +id = "comment.text-border-shape" +title = "An oval or a circle around a text box's or typed text's text" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "fill_sign_add", "comment_list"] +evidence = ["crates/annot/src/tests.rs::text_boxes_take_an_oval_or_a_circle_around_their_text", "crates/annot/src/tests.rs::a_circle_goes_snug_around_its_text_and_follows_it", "crates/annot/src/tests.rs::a_circle_touching_its_text_box_keeps_following_its_text", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_takes_an_oval_or_a_circle_from_the_toolbar", "crates/ui-egui/tests/it/comments.rs::text_box_properties_choose_the_border_shape", "crates/automation/tests/it/automation.rs::text_borders_take_an_oval_or_a_circle_through_the_tools"] +notes = "Comment Properties ▸ Border shape (text boxes, typewriter text) and Fill & Sign's toolbar beside Border (typed text and dates, for new text and the selected one, and showing the selected one's) choose Rectangle, Oval or Circle: a position number in a circle, a label in an oval on a plan. The text box keeps its place and the shape goes around it, centred: an oval through its corners (√2 its size), a point clear of the border; the text is centred in it. A circle goes snug around its text: the text box is no wider than its text needs (typed text with a circle skips the 40 pt a box to type into keeps; choosing Circle shrinks an existing box to its lines), the circle a point clear of the text's lines and touching the box's longer sides, so a 10 pt \"3\" gets a 20 pt circle; a snug circle follows its text and font size, one made larger by hand keeps its size. /Rect grows to hold the shape and /RD keeps the text box inside it (§12.5.6.6, as a callout's; 0 where a circle touches the box, a hair below 0 read as 0), with PdfCraft's /PCBorderShape; background and border take the shape, the appearance draws it so every viewer shows it, and an app that redraws the comment from its keys draws a rectangle. Resizing the frame takes the text box along (a circle's keeping its proportions); a thicker border leaves the text where it is. Make Properties Default carries it into the Text box tool, and on typed text into Fill & Sign's new text. Headless: comment_add / comment_edit / fill_sign_add `border_shape` (rectangle, oval, circle); comment_list reports an oval or a circle. Callouts keep a rectangle. Acrobat's text boxes have a rectangular border only." + [[feature]] id = "comment.text-box" title = "Text box comment with rich text" From 6f63f98717aab80d4f9575b74fc72fecac5dc141 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:37 +0200 Subject: [PATCH 26/59] docs: add comparative PDF workflow research reference (#2940) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- ROADMAP.md | 1 + docs/research/pdf-workflows/README.md | 73 +++ .../pdf-workflows/competitive-research.md | 211 +++++++++ .../pdf-workflows/feature-mapping.csv | 125 +++++ .../pdf-workflows/interaction-evidence.md | 143 ++++++ .../pdf-workflows/source-register.csv | 181 ++++++++ .../pdf-workflows/workflow-reference.md | 430 ++++++++++++++++++ docs/roadmap.md | 2 + docs/ui-parity.md | 2 + 9 files changed, 1168 insertions(+) create mode 100644 docs/research/pdf-workflows/README.md create mode 100644 docs/research/pdf-workflows/competitive-research.md create mode 100644 docs/research/pdf-workflows/feature-mapping.csv create mode 100644 docs/research/pdf-workflows/interaction-evidence.md create mode 100644 docs/research/pdf-workflows/source-register.csv create mode 100644 docs/research/pdf-workflows/workflow-reference.md diff --git a/ROADMAP.md b/ROADMAP.md index f104ee0d80..efe122a8b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -339,6 +339,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged. +- **2026-10-17 (research):** Added a [PDF workflow research reference](docs/research/pdf-workflows/README.md): dated public-source locators, 20 workflow research domains, a pinned feature mapping and interaction evidence. Reference documentation; no feature statuses, milestone priorities or estimates changed. - **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). - **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. diff --git a/docs/research/pdf-workflows/README.md b/docs/research/pdf-workflows/README.md new file mode 100644 index 0000000000..32a855a570 --- /dev/null +++ b/docs/research/pdf-workflows/README.md @@ -0,0 +1,73 @@ +# PDF workflow research reference + +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Status:** reference documentation. Feature and roadmap snapshot: [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Detailed code inspection remains pinned to `3f044b62`, dated 2026-10-13. + +This reference consolidates archived comparative research, public source locators and +PdfCraft feature evidence. It separates documented workflows, historical observations, +evaluation criteria and current feature records so contributors can assess the collected +material without treating an old research claim as a present product fact. + +The documentation is independent of feature implementation. It introduces no new project +roadmap, implementation assignments, delivery estimates or feature-completion claims. +Planning remains in [ROADMAP.md](../../../ROADMAP.md) and the +[feature checklist](../../../parity/acrobat-features.toml). + +## Contents + +| Reference | Contents | +|---|---| +| [Comparative research](competitive-research.md) | Nineteen product studies and eighteen workflow families, with evidence limitations and archived corrections. | +| [Source register](source-register.csv) | 180 unique public locators, their archive dates, recovered provenance and explicit verification states. | +| [Workflow reference](workflow-reference.md) | Twenty research domains: existing foundations, retained findings and evaluation dimensions. | +| [Feature mapping](feature-mapping.csv) | 124 existing parity IDs with their exact status and milestone at the pinned snapshot, linked to the research domains. | +| [Interaction evidence](interaction-evidence.md) | Inspected document ownership, job outcomes, save, keyboard and review foundations, distinguished from historical evaluation criteria. | + +## Evidence and dates + +The archive includes sixteen product studies, a community-demand sample, an expanded +desktop/platform survey and interface investigations. Research dates span July–October +2026. Three additional desktop products appear in the expanded survey. The source register +is a navigation aid rather than independent proof of any product's current behavior. + +`archived-not-revalidated` means that the locator was recovered from a dated study; its +supporting passage and current edition have not been checked for this handoff. +`locator-reconstructed-not-revalidated` means that the original study named a source +without preserving its complete URL. Neither state means a web-archive capture exists. +Vendor-hosted community posts remain community reports, not vendor policy statements. + +Community snippets describe questions and reported pain points. They do not establish +market share, incidence or representative satisfaction. Historical pricing, licensing, +legal, security and superiority assertions are excluded. No competitor was installed or +benchmarked for this documentation contribution. + +## Reconciliation with PdfCraft + +Earlier research described measurement, a command palette, visual comparison, Tags panels +and reusable actions as missing. The inspected source and newer checklist already contain +those foundations, including synchronized side-by-side comparison and recursive folder +batches. The workflow reference records those corrections and reuses the existing oracle +tools rather than describing a second implementation. + +The dated mapping is not another live checklist. Current status comes from +`parity/acrobat-features.toml`; the ranked priorities and progress remain in +[gaps.md](../../gaps.md), [roadmap.md](../../roadmap.md) and +[ui-parity.md](../../ui-parity.md). An existing inconsistency in knockout-rendering +evidence is identified without changing its recorded status. + +## Publication and verification scope + +These are newly written summaries and public source links. No third-party screenshots, +visual designs, source code, fonts, sample documents, private paths, account data or raw +session logs are included. Historical implementation classes, toolkit choices and old +effort estimates are not adopted as PdfCraft architecture. + +Static checks cover the feature IDs, statuses, milestone assignments, reference IDs and +local links. Evaluation scenarios are research criteria, not tests already executed. +This handoff claims no native acceptance, screen-reader verification, external-reader +compatibility result, vendor benchmark or compliance certification. + +## Revision history + +| Date | Change | +|---|---| +| 2026-10-17 | Consolidated the archived research, source register, workflow evidence and pinned feature mapping. | diff --git a/docs/research/pdf-workflows/competitive-research.md b/docs/research/pdf-workflows/competitive-research.md new file mode 100644 index 0000000000..f5dc1400dc --- /dev/null +++ b/docs/research/pdf-workflows/competitive-research.md @@ -0,0 +1,211 @@ +# Comparative PDF workflow research + +Prepared 2026-10-17 from a September–October 2026 research archive. This reference summarizes product coverage, workflow distinctions and evidence limits. It is not a current competitor parity assessment, implementation specification or additional roadmap. + +## Scope and evidence rules + +The archive contains sixteen product studies dated September 7–8, a community-demand study, a September 8 synthesis with September 20 reconciliation, September 25 desktop/web/platform and interaction studies, and detailed design investigations through October 8. Three additional desktop products were examined on September 25: Power PDF, PDFelement and SumatraPDF. The accompanying [source register](source-register.csv) contains 180 distinct public source locators recovered from this material or reconstructed from its source titles. + +The product sheets combined documented capabilities, approximate edition/version history, secondary reviews, inference and historical implementation observations. These categories have different evidentiary limits: + +| Evidence category | Information retained | Limit | +|---|---|---| +| Archived primary documentation | A dated vendor-described workflow or technical interface | Not independent reliability evidence, a current edition guarantee or proof of superiority | +| Archived release notes | A reported change in a named release | Not proof of availability in every platform or edition | +| Archived source inspection | Behavior recorded at the inspected revision | Not verification of another revision, release or backend | +| Community reports and search snippets | Reported pain points and terminology | Not market share, incidence, representative satisfaction or security conclusions | +| Historical design investigation | Documented problem, constraints and evaluation dimensions | Not PdfCraft implementation status, an accepted architecture or dependency approval | + +Unless explicitly updated in the register, links are **archived-not-revalidated**. An archive date is the date written in the collected study, not a web-archive capture, freshness guarantee or current product test. **Locator-reconstructed-not-revalidated** identifies a URL reconstructed from a named source rather than recovered as a complete citation. Vendor-hosted community discussions remain community evidence; their hosting does not make them vendor capability statements. + +The register is a source-finding aid, not a claim that every linked page supports every observation in this document. Its scope notes distinguish general entry pages from more specific references. No competitor was installed or benchmarked for this handoff. Screenshots, downloaded proprietary documents, user documents, implementation prompts and copied source code are not included. + +No competitor absence counts, rankings, prices, subscription assertions, security certifications, processing-retention promises or legal deadlines are reproduced as current facts. Corrections within the archive are recorded below. PdfCraft implementation status and planning remain in its existing roadmap, parity records and source evidence. + +## Product coverage + +This table is an index to the subjects examined by each archived study. It does not assert that a product uniquely provides a capability or that PdfCraft lacks it. Product editions and platforms differed within the original studies. + +| Product study | Workflow subjects recorded | Comparison dimensions used in the research | +|---|---|---| +| Adobe Acrobat | Tool contexts; staged redaction; form events; validation/fixups; reusable actions; search and structure | Review versus apply, repeated-operation settings, document structure and edition boundaries | +| ABBYY FineReader | Linked scan/text/word zoom; uncertain-item navigation; dictionaries; page verification; cross-format comparison; scheduled processing | Correction effort, source/text synchronization, review completion and recognition output | +| Foxit PDF Editor | Custom actions; search; form events; structured review; layers; region recognition; deployment policies | GUI/automation consistency, search scope, form compatibility and platform/edition differences | +| PDF-XChange Editor | Object/field properties; annotation filters; dynamic stamps; scan correction; tags; identifiers | Selection precision, property inspection, annotation interchange and reusable configuration | +| Nitro PDF Pro | Sensitive-data review; table/form extraction; Office-oriented navigation; repeated operations | Detection versus removal, structured extraction and task-level discoverability | +| Bluebeam Revu | Markup records; reusable symbols; calibrated measurements; revisions; compare and overlay | Review metadata, technical drawing scale, revision correspondence and interchange | +| Master PDF Editor | Object inspector; menus/toolbars; forms; certificate operations | Live-document inspection, field properties and advanced operations beside the page | +| UPDF | Tool access; reading comfort; cross-page operations; summaries; platform variations | Reading/editing state, page-spanning work and platform capability differences | +| PDF Expert | Focused reading/editing; thumbnail operations; customizable access; signature placement | Page-centered work, document manipulation and contextual properties | +| PDFgear | Task-led entry; region extraction; conversion | Goal-oriented entry, region selection and output artifacts | +| Sejda | Verb-first tools; per-operation limits; filename templates; chains; unsupported-input explanations | Prerequisites, output naming, operation chaining and declared limits | +| iLovePDF | Task grid; chained processing; page/conversion utilities; desktop/web differences | Single-task entry, result reuse and processing-location disclosure | +| PDF24 | Local utilities; scanning/recognition; compression readouts; batch options; hybrid invoices | Measured outcomes, repeated processing and embedded business data | +| Smallpdf | Narrow task flows; conversion; signing categories | Entry friction, option disclosure and task-specific outputs | +| LibreOffice Draw | Import/export; headless conversion; command options; accessibility/export settings | Native editing versus conversion, semantic preservation and format tradeoffs | +| Okular | Keyboard annotation; view history; reading transforms; backend capabilities; signatures | Review navigation, reading state and unsupported-format reporting | +| Power PDF | Office-oriented navigation; deployment differences; refreshed entry surfaces | Navigation, product/edition boundaries and installation context | +| PDFelement | Customizable tool access; reusable annotations; table/form/document workflows | Repeated-action access and grouping of related operations | +| SumatraPDF | Minimal chrome; searchable commands/navigation targets; keyboard reading | Discoverability, reading navigation and command access | + +Public entry points include [Acrobat help](https://helpx.adobe.com/acrobat/user-guide.html), [FineReader help](https://help.abbyy.com/en-us/finereader/16/), [Foxit user guides](https://www.foxit.com/resource-hub/user-guide/), the [PDF-XChange feature catalogue](https://www.pdf-xchange.com/product/pdf-xchange-editor), [Bluebeam markup guidance](https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html), and [SumatraPDF command-palette documentation](https://www.sumatrapdfreader.org/docs/Command-Palette). Their archive or reconstruction states are recorded in the register; listing them here does not assert fresh product verification. + +## Community evidence and its limits + +The September community study used sixteen targeted searches across PDF, administration, legal, archival and related communities. Direct thread retrieval was frequently blocked, so many observations came from indexed snippets. Counts in that study represented distinct surfaced threads with unknown search coverage and overlapping audiences. They were not survey percentages. No thread-frequency ranking or verbatim snippet quotation is reproduced here. + +Five recurring themes were recorded: + +| Theme in the collected material | Distinction expressed by the reports | Evidence limit | +|---|---|---| +| Redaction and saving | Visual covering versus content removal; saved artifact versus on-screen appearance | Reports did not independently establish removal correctness across products | +| Confidential and repeated processing | Processing location, input/output predictability and reuse of options | Search results did not establish representative privacy preferences | +| Layout damage during editing | A small intended change versus font substitution or wider reflow | Individual complaints did not establish defect rates or comparative fidelity | +| Form interactivity | Filling, authoring, flattening, printing and data interchange | Similar terminology did not imply identical field or script compatibility | +| Large and mixed-result jobs | Overall progress versus per-file success, failure and unchanged output | Reported jobs were not a controlled performance benchmark | + +Ownership expectations, interruptions, keyboard review and readable themes also appeared in the collected material. They remain historical observations, rather than current pricing conclusions or statistically measured demand. + +## Workflow families examined + +The archive grouped PDF work into the following eighteen families. The dimensions below describe what the studies compared or distinguished. They do not introduce new acceptance contracts or implementation assignments for PdfCraft. + +### 1. Reading, navigation and search + +The viewing research distinguished page content from temporary reading state: zoom, viewport rotation, reading colors, navigation history and validation indicators. Search and bookmark movement were examined in relation to page labels, physical page numbers and returning to the prior reading context. + +The studies treated current-document search, cross-document search, folder indexing and recognition-backed search as different scopes. Saved sessions and view history were also distinct from saved document edits. Historical comparisons of search depth were documentation-based, not measurements of retrieval accuracy or speed. + +### 2. Text and object editing + +The editing studies separated replacement of existing text, added overlays, image/object changes and whole-page conversion. Font substitution, subset fonts, positioned text, unsupported glyphs, rotation and mixed writing systems were recurring explanations for unexpected editing results. + +The archive evaluated fidelity in terms of both appearance and extractable text. It also examined object bounds, selection properties, reflow disclosure and external-editor round trips. Reports of formatting damage did not establish a product-wide defect rate, and a single successful font/script example did not establish general CJK or right-to-left editing support. + +### 3. Page organization and composition + +Page studies covered selection, insertion, extraction, reordering, reversal, splitting, interleaving and assembly from multiple documents. They distinguished thumbnail position from page identity and distinguished a displayed page label from its physical index. + +Recorded evaluation dimensions included output-name previews, split boundaries, unequal source lengths, page-size differences, retained links/bookmarks/forms and the relationship between undo and selection. The archived cross-document staging layouts were design explorations; they are not adopted interface requirements or evidence of a current PdfCraft gap. + +### 4. OCR and human verification + +The recognition research examined the correction loop around recognition results: source image, recognized text, magnified region, uncertain-item navigation, replacement, skipping and human-reviewed state. The FineReader study emphasized synchronization and repetitive keyboard navigation rather than confidence coloring alone. + +Engine confidence and human review were separate concepts. The archive also distinguished an edited preview from a corrected searchable text layer, region recognition from whole-page recognition, and already-text input from scanned input. Language selection, preprocessing, alternate suggestions, page orientation and failed/cancelled recognition were recorded as separate dimensions. Ensemble architecture and language counts were not independent accuracy evidence; no held-out comparative accuracy result is established by this handoff. + +### 5. Forms and document scripts + +Forms research separated authoring, filling, formatting, validation, calculation, flattening and data interchange. Field lists, tab order, calculation order and event dispatch appeared in the archived comparisons. A populated field and a functioning calculation/event model were not treated as interchangeable evidence. + +The historical script investigations recorded runtime compatibility and security constraints: allowed events and APIs, resource bounds, event recursion, external access and non-executing inspection. Those investigations evaluated another implementation's options. They do not select a runtime, authorize a dependency or establish PdfCraft script behavior. Unsupported fields and unsupported events were distinct from a document that had been verified to execute correctly. + +### 6. Comments, markups and review data + +Annotation studies examined both visible appearance and review records: author, subject, page, status, replies, timestamps and summaries. Bluebeam's study devoted particular attention to structured markup lists, reusable symbol sets and drawing-review metadata. Other studies covered annotation filters, stamps, comment summaries and import/export. + +The archive distinguished filtering from deletion, literal stamp content from values inserted at placement, and standard PDF relationships from application-specific metadata. Interchange and CSV export were considered separately from the appearance of the annotation in its originating application. Specialized construction-review workflows were a distinct research category rather than a general requirement for every PDF editor. + +### 7. Redaction and sanitization + +The collected material separated candidate detection, human review, application of removal, sanitization and verification. Search patterns and entity detection concerned finding possible sensitive material; they did not themselves establish removal. Visual black fill, blur and pixelation were distinguished from removal of the underlying PDF content. + +Recorded assurance dimensions included saved-file extraction, affected images, metadata, annotations, attachments, layers, previous revisions and nested content. Geometry on rotated pages and partial-word cases were separate concerns. The reports also noted that an audit artifact can leak the very content being removed. Claims of uniquely verifiable or universally safe redaction are not reproduced; the archive is not a security certification. + +### 8. Encryption, permissions and hostile-document handling + +Security studies distinguished open-password encryption, permission flags and recipient-certificate encryption. They also examined external links, launch actions, scripts, attachments and processing of malformed files. These are different controls and do not have the same threat model. + +The archive's evaluation dimensions included credential handling, failure/refusal behavior, resource limits and consistency between the displayed operation and its execution policy. PDF permission flags were not equivalent to preventing arbitrary software from extracting accessible content. Historical algorithm and certification references remain source locators, not current conformance findings. + +### 9. Digital signatures and signing requests + +Signing research distinguished a drawn appearance, certificate-based signing, document certification, permitted changes and validation. Integrity of signed bytes, signer trust, timestamps, revocation evidence and later document changes were separate observations. A displayed validation result was tied to the document revision inspected. + +The archived local-request investigation examined prepared documents, manifests, handoff and inspection of returned files. That historical design was not a hosted signing service or identity-verification system. The archive also distinguished the local embedding of validation evidence from retrieval that may require network access. Its PAdES, dependency and legal terminology does not establish PdfCraft's attained signature profiles or the legal status of an output. + +### 10. Document comparison + +Comparison studies separated text, raster/visual, page-structure and annotation differences. They examined page correspondence after insertions, removals, duplication and reordering, together with linked scrolling, change lists, filtering, navigation and report export. + +An overlay and a textual diff supplied different observations; neither alone established comprehensive equivalence. Scanned comparison introduced recognition dependence. The archive also recorded uncertain alignment and the distinction between a filtered view, a complete report and a user-reviewed subset. No comparative false-positive or missed-change rate was measured for this handoff. + +### 11. Batch processing and presets + +The detailed batch investigations recorded ordered steps, versioned preset data, option validation, output naming, conflicts, stop/continue policy, numbering continuity and per-file reports. They distinguished inert configuration data from executable scripts or shell commands. + +Atomicity was an explicit research dimension: a per-file chain could be all-or-nothing while a whole run had mixed outcomes; a whole-run transaction was a different guarantee. Cancellation could leave previously committed work, depending on the documented boundary. The archive also examined watch-folder repetition, result retention and deterministic numbering when files completed in a different order. These historical designs are not a replacement action format or another batch runner for PdfCraft. + +### 12. Conversion, extraction and document fidelity + +Conversion studies distinguished extracted PDF text, reconstructed document structure, page images, editable Office output and tabular data. A file extension, package structure check and visual rendering each supplied different evidence. Table boundaries, cell order, merged cells, page ranges and output settings were recorded separately. + +The archived fidelity questions concerned retained bookmarks, annotations, attachments, form semantics and unsupported content as well as visual layout. The studies also noted that exported text can be interpreted as spreadsheet formulas. Historical claims about conversion breadth or editable output were not independent measurements of arbitrary-document fidelity. + +### 13. Compression, fonts and color + +Optimization studies separated lossless cleanup, image recompression, resolution reduction, font subsetting and color conversion. Estimated savings and measured before/after bytes were different quantities. An output larger than its input was still a possible result of processing. + +The October font investigation documented that a control's name alone did not establish actual subsetting for every font class in the implementation studied. The color investigation examined decoder behavior, embedded profiles, image representations and rendering intent. Their findings are historical case studies, not PdfCraft defect reports or dependency choices. Rendering, selectable text, font handling and retention of associated files were independent evaluation dimensions. + +### 14. Printing, measurements and preflight + +Printing research covered page order, N-up, booklet, duplex, comments and preview. Measurement research distinguished viewport scale, physical page geometry, calibration, units and stored annotations. Rotated pages and nonzero page-box origins affected the interpretation of geometric results. + +Preflight, fixups and production previews were separate categories. A check reported an observation; a fixup changed the document; a production preview concerned the intended output process. The archive examined named profiles and skipped checks without treating a collection of lightweight checks as a complete production or standards validator. + +### 15. Accessibility and archival formats + +The archive distinguished accessible application operation from accessible PDF output. For the application, it examined keyboard paths, focus, labels, status, contrast, custom-painted controls and actual assistive-technology behavior. For the document, it examined tags, reading order, alternative text, language, lists, tables and the relationship between content identifiers and structure. + +The historical tagging investigation bounded the document classes it considered and separated automatic classification from human review. A structure check, a screen-reader session and a standards validator reported different forms of evidence. Automated checks did not by themselves establish document accessibility or legal compliance. The register retains standards references without reproducing the archive's changing legal-deadline assertions. + +### 16. Layers, attachments and structured business documents + +Layer research separated visibility, printing state, membership, ordering and properties. Displaying a layer panel was not evidence that every optional-content transformation was supported. Hidden content also had implications for searching and sanitization. + +The archive treated attachments and embedded XML as potentially essential document data. Hybrid-invoice discussions distinguished detecting and preserving a payload from reading it, validating it, editing it or generating a compliant invoice. Those were separate scopes; the handoff carries no legal mandate, invoice-conformance finding or current product absence assertion. + +### 17. Deployment, automation and integration + +Integration research examined menus, keyboard access, contextual commands, command search, CLI operations, structured automation and watched folders. They were access paths to operations, not equivalent guarantees about authorization, cancellation or execution policy. + +The archive recorded coordinate naming, document identity, input/output paths, confinement, authorization, limits and headless reporting as evaluation dimensions. Silent installation, managed policies, portable builds, platform signing and architecture support were additional deployment subjects. Descriptions of other applications' registries or toolkits do not establish a new architecture for PdfCraft. + +### 18. Assisted recognition and grounded document answers + +The expanded survey covered suggested sensitive entities, image descriptions, extracted structures and answers about document content. It distinguished suggestions from accepted edits, source-grounded answers from unsupported statements, and processing location from the appearance of the interface. + +The studies recorded language coverage, available runtimes, network behavior, source-page references and editable suggestions as evaluation dimensions. They did not supply held-out accuracy measurements, an approved dependency selection or proof that generated descriptions established accessibility. Sensitive-data suggestions and actual redaction remained different stages. + +## Cross-cutting interface observations + +The September 25 interaction studies recorded several recurring dimensions across product families: + +| Dimension | Distinctions recorded in the archive | +|---|---| +| Command access | A command's identity, its entry points, its availability and its explanation when unavailable | +| Task surface | One-shot configuration, repeated review, live-document inspection and long-running monitoring | +| Document context | Active tab versus operation owner; current document versus the revision analyzed | +| Result state | Preview, pending mutation, completed edit, saved output, stale report and partial result | +| Focus and navigation | Entering a tool, moving between related views and returning to the prior reading position | +| State communication | Color, text, shape, control state and assistive-technology semantics | +| Layout and scaling | Narrow windows, large text, display scales, long translations and secondary-panel visibility | +| Visual consistency | Icon meaning, labels, themes and the difference between page colors and application chrome | +| Long operations | Start, cancellation request, partial completion, error, retry and completed output | + +The archive included sketches of persistent OCR/comparison views and inspectors. Those sketches are historical research artifacts, not adopted navigation plans, prescribed workspace layouts or new implementation requirements. No third-party visual design is imported here. + +## Corrections and unresolved evidence in the archive + +Several later reports qualified earlier claims. The following distinctions prevent an archived comparison from becoming an inaccurate current scoreboard: + +1. **Licensing:** later research qualified an early universal subscription assertion. Product licensing varied by edition and date. No current price or subscription conclusion is carried forward. +2. **Shipping evidence:** later inspection distinguished a low-level engine capability, a visible entry point and tested end-to-end behavior. They were not equivalent evidence of a complete product workflow. +3. **Signing terminology:** appearance, certification, long-term validation evidence and qualified-signature services were conflated in parts of the earlier material. This reference treats them separately. +4. **Absence claims:** some negative findings came from documentation searches or snippets rather than exhaustive product tests. They are not reproduced as current facts. +5. **Accuracy and security:** architecture and feature lists were sometimes used to infer recognition accuracy or removal reliability. This handoff records no such superiority or safety conclusion. +6. **Regulatory references:** accessibility and invoicing deadlines were corrected within the archive. No legal deadline or compliance conclusion is asserted here. +7. **Implementation transfer:** toolkit-specific source paths, proposed dependencies and defects from another codebase did not establish PdfCraft behavior. This document retains workflow distinctions and historical observations, not those implementation directions. + +Current PdfCraft planning and feature status remain in the existing roadmap and parity records. The source register and this reference document only describe the collected research and its limits. diff --git a/docs/research/pdf-workflows/feature-mapping.csv b/docs/research/pdf-workflows/feature-mapping.csv new file mode 100644 index 0000000000..d2daefabe7 --- /dev/null +++ b/docs/research/pdf-workflows/feature-mapping.csv @@ -0,0 +1,125 @@ +domain,research_topic,parity_id,status,milestone,snapshot_commit,feature_evidence_source,research_reference,source_register,source_inspection_commit,evidence_limit,evidence_note +B01,Rendering and output fidelity,view.render-pages,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-shadings-patterns,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-transparency,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-knockout,shipped,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Recorded shipped status conflicts with signing-related notes/evidence and transparency notes; maintainer evidence reconciliation required; no status changed here +B01,Rendering and output fidelity,view.render-type3,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-cid-fonts,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B01,Rendering and output fidelity,view.render-image-codecs,partial,M2,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.text-edit,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rtl-cjk-editing,partial,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.rotated-text-editing,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B02,Existing-text editing,edit.keep-tags-on-edit,planned,M7,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-text,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-visual,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-report,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-side-by-side,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-filters,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-page-ranges,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B03,Document comparison,misc.compare-scanned,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.correct-recognized-text,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.editable-text-output,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-text,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-range,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B04,Recognition proofing and correction,ocr.recognize-multiple,shipped,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-languages,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.rotation-detection,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.deskew,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.ocr-engines,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.despeckle,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.background-removal,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B05,Recognition languages and scan preparation,ocr.camera-perspective,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-run,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-create,shipped,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-wizard-manage,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B06,Reusable batch processing,misc.action-prompt-steps,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.autotag,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tags-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.tag-properties,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.reading-order-tool,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.order-panel,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.table-editor,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.create-tag-from-selection,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B07,Document structure and accessibility authoring,a11y.artifact-marking,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-validate,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.pdfa-convert,partial,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-results,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B08,Preflight and archival validation,optimize.preflight-reports,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-verification,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-glyph-removal,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-image-pixels,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-vectors,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-annots-fields,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-struct-tree,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-pattern-locales,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-partial-words,planned,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redact-folder,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-properties,shipped,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B09,Redaction and assurance evidence,protect.redaction-codes,partial,M8,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.cades-pades-bb,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs12,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.windows-cert-store,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.macos-keychain,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.certify,shipped,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.timestamp-servers,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pkcs11,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.field-mdp,planned,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-bt,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ltv,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.pades-blta,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B10,Digital signatures and validation evidence,sign.ocsp-crl,partial,M9,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.custom-scripts,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.event-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.calculation-order,shipped,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.submit-form,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",edit.action-javascript,planned,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B11,"Forms, events and scripting",form.push-buttons,partial,M6,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-distance,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-scale,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-perimeter,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-area,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-snap,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-info-panel,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B12,Measurement and scale interpretation,comment.measure-export,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-files,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-choose-pages,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-sort,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.combine-thumbnails,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.field-name-conflicts,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-every-n,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-by-bookmarks,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.struct-tree-merge,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-multiple-files,planned,M13,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B13,Page assembly and document composition,organize.split-naming,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.from-office,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-docx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-xlsx,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B14,Creation and conversion fidelity,create.export-pptx,partial,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,view.large-document-performance,shipped,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,misc.performance-budgets,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B15,Document performance and resource use,core.lazy-loading,planned,M1,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.advanced-search,partial,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-folder,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-boolean,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B16,Search across document collections,view.search-index,planned,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.app-screen-reader,partial,M3,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.keyboard-only,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B17,Keyboard and assistive-technology interaction,a11y.replace-page-colors,shipped,M12,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.preferences-dialog,partial,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.prefs-search,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.admin-preferences,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B18,Preferences and capability disclosure,misc.rtl-ui,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,organize.print-presets,planned,M10,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.output-preview,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-warnings,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.convert-colors,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B19,Print preparation and color behavior,print.color-management,planned,M11,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-redo,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.undo-after-save,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.atomic-batch-edits,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.autosave,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.crash-recovery,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.unsaved-changes-prompt,shipped,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",core.web-recovery,planned,M4,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package +B20,"Document identity, history and failure outcomes",view.multiple-windows,planned,M14,924c4da4ac2879ac9107c6c82e20ac6be5570f0a,parity/acrobat-features.toml,workflow-reference.md,source-register.csv,3f044b6263aa6605c3aa44ca6d065e1ab37d13b6,Checklist snapshot checked; implementation tests not rerun.,Catalogued status/evidence inspected; cited implementation tests were not rerun for this research package diff --git a/docs/research/pdf-workflows/interaction-evidence.md b/docs/research/pdf-workflows/interaction-evidence.md new file mode 100644 index 0000000000..7773505747 --- /dev/null +++ b/docs/research/pdf-workflows/interaction-evidence.md @@ -0,0 +1,143 @@ +# Interaction evidence and evaluation reference + +Detailed source inspection used PdfCraft revision +`3f044b6263aa6605c3aa44ca6d065e1ab37d13b6`. A later documentation review used +`924c4da4ac2879ac9107c6c82e20ac6be5570f0a` on 2026-10-17. Links retain those +inspected revisions. Neither review was a runtime parity assessment. + +This reference summarizes interaction findings from archived comparative research and +relates them to existing PdfCraft mechanisms. It contains no implementation plan, +proposed workspace design, feature commitment or new architecture. The project's +roadmap and machine-readable parity checklist retain their planning and status roles. +Historical design examples and test descriptions are distinguished from current product +evidence below. + +## Existing mechanisms and corrected historical descriptions + +Several early comparative notes described capabilities that later existed in PdfCraft. +The table records the inspected evidence and its limits rather than preserving those +absence claims. + +| Area | Inspected evidence | What that evidence establishes | +|---|---|---| +| Command discovery and dispatch | [`commands.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/commands.rs), [`palette.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/palette.rs) | Menus, shortcuts, palette and other entry points share command metadata/dispatch. The palette is an existing feature, not an absent research concept. | +| Measurement | [`measure_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/measure_ui.rs) | Distance/perimeter/area controls, calibration, snapping and saved-measurement state exist. An older session-only calibration description does not describe every current persisted scale. | +| Comparison | [`compare_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/compare_ui.rs) | Text changes, visual regions, document highlights, report and comment actions exist. This older source link does not establish all later split-view behaviour. | +| OCR | [`ocr_ui.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/ocr_ui.rs) | Background recognition, cancellation request and multiple-file processing exist. Cancellation can retain pages already recognized; it is not synonymous with rollback. | +| Accessibility review | [`accessibility.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/accessibility.rs) | Source includes checks for finding navigation, Tags-panel review, automatic text tagging and per-document skipped-rule state. Tests were inspected, not executed for this handoff. | +| Interface appearance | [`theme.rs`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/src/theme.rs) | A shared token type and System/Light/Dark preferences exist. Their presence is not a measured contrast result. | +| International text input | [`commands.rs` tests](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/ui-egui/tests/commands.rs) | Palette tests cover idle composition frames, Chinese preedit/commit, cancellation and outside-click behaviour. This does not establish native input-method behaviour in every editable field. | +| Automation conventions | [`automation/README.md`](https://github.com/storytold/pdfcraft/blob/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6/crates/automation/README.md) | The documented contract uses displayed top-left rectangles, 1-based pages/positions, confined paths and a shared tool table. Historical mixed-coordinate criticism is not current evidence of a defect. | + +The later [feature checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) +marks synchronized side-by-side comparison shipped. Its notes describe two document +views when the older version remains open and the window is at least 1000 pixels wide; +synchronization follows offsets rather than matching content. The record identifies +page-size drift and old-tab closure as limits. It also records Action Wizard +creation/editing/running and recursive CLI/MCP folder batches with bounded recipes, +relative paths, collision-safe output names and per-file reports. + +The later [roadmap](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/ROADMAP.md) +records changed-on-disk Save confirmation and a shared staging-file writer. It describes +new outputs being reopened/checked before publication. These are existing safety +mechanisms; the archived research is not evidence that atomic saves or conflict +notifications are absent. + +Some prose gap tables at the same revision still describe comparison or Tags panels as +missing, while detailed checklist entries and inspected source describe implementations. +This is a documentation inconsistency in the inspected snapshot, not evidence that the +code is absent or that its listed limitations are solved. + +## What the archived interaction research distinguished + +The research compared workflow structures rather than implementation languages. Its +recurring distinctions are useful when reading feature descriptions and test evidence: + +- **Document identity versus current selection.** A tab index, display name and filesystem + path describe different things. Changing the selected tab does not change which source + a previously started operation analyzed. Reopening the same path can create a different + session, and an edit can change the revision within one session. +- **Analysis versus edit versus export.** A finding list describes an input revision; a + preview can remain uncommitted; an export can describe an invocation snapshot. The + existence of a preview does not establish that document bytes changed or a file saved. +- **Cancellation request versus terminal cancellation.** A request can be pending while + a worker reaches a boundary. A completed subset may be intentionally retained. An + atomic commit and the surrounding multi-file run have different interruption scopes. +- **History availability versus restoration outcome.** A named undo entry establishes + that history exists. Successful restoration, rendering refresh and save/reopen are + separate observable outcomes. The research did not reproduce a PdfCraft history failure. +- **Atomic replacement versus destination conflict.** Staging avoids half-written output; + conflict detection deals with another writer changing the intended destination. They + address different failure modes. The roadmap records mechanisms in both categories. +- **Automated finding versus conformance.** A checker pass describes supported checks. + Manual review, unsupported constructs and a report's input identity affect how that + result can be interpreted. A pass is not independent certification. + +These distinctions are an interpretation vocabulary, not a checklist of newly required +features. The underlying product's declared behaviour determines whether retaining a +partial result or refusing an operation is the expected outcome. + +## Evaluation observations by workflow + +The following table describes what the archived evaluations observed or separated. It +neither reports a fresh PdfCraft test run nor assigns implementation tasks. Example +conditions describe evidence scope rather than newly discovered bugs. + +| Workflow | Observable relationship examined | Limits on interpreting the observation | +|---|---|---| +| OCR review | The recognized word, source region, selected row and magnified crop referred to the same token; a saved correction was distinguished from text displayed only in a preview. | Confidence was backend evidence, not a universal accuracy score. Correcting invisible searchable text and visibly editing a scanned phrase were distinct operations. | +| Comparison | Original/revised direction, selected change, displayed pages and exported report referred to the same comparison. Filtering and review disposition were distinguished from the full result set. | Equal text did not establish equal appearance or structure. Offset synchronization and content correspondence were distinct. A missing corresponding page was not page zero or a valid empty match. | +| Batch processing | The input, selected action, current step, saved outputs and per-file failures were separately observable. Completed outputs remained meaningful in a partially failed run. | Per-file atomicity did not imply a whole-run transaction. A retry could repeat a previously committed effect. `command_batch` documents that earlier edits can remain after a later error. | +| Accessibility review | Findings, selected tags and page locations referred to the same reachable objects. Proposed classification and applied structure were distinguished. | An automatic structural result did not establish reading quality. Text-only tagging evidence did not cover arbitrary images, tables or existing structure. | +| Standards checks | The profile, checked revision, findings and saved conversion artifact were separate evidence fields. | Structural checks for a subset of a standard were not a full validation claim. An unavailable external validator was not a passing result. | +| Page organization | Source selection, destination identity, permutation and resulting saved page order were evaluated separately. | Successful page transfer did not by itself establish preservation of every bookmark, form relationship or structure tree. | +| Redaction review | Marked regions, committed removal and checks on the saved output were separate stages. Verification wording was tied to the supported check scope. | A visual rectangle was not removal evidence. Reports and logs could themselves expose removed content. Absence under one extraction strategy did not prove absence under all encodings. | + +Archived state-machine notes used terms such as running, review-ready, saving, complete, +partial, failed and stale. Those terms describe distinct user-visible outcomes; they do +not establish that PdfCraft has, or needs, a single global state-machine type. Likewise, +historical multi-pane layouts described a way to observe several related values at once; +they are not adopted interface designs or a request for a replacement shell. + +## Keyboard, input and appearance evidence + +The research separated semantic labels from complete task access. A headless +accessibility tree can expose a control's name, role and state without establishing that +a person can finish a keyboard-only task or that a native screen reader announces the +result clearly. Focus ownership, return focus after closing a surface, background focus +changes and non-drag alternatives were observed independently from visual arrangement. +No native screen-reader session was performed for this handoff. + +[WCAG 2.2](https://www.w3.org/TR/WCAG22/) supplies technology-neutral criteria for +keyboard operation, focus, contrast and target sizes. It is a reference, not evidence +that this native application conforms to a level. The [WAI-ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) +describes web tab focus/activation semantics; it does not determine native widget or +platform accessibility implementations. + +Input-method research distinguished preedit text, committed text, selection, undo and +cancellation. Enter/Escape can have composition meanings in addition to dialog meanings. +Chinese palette composition tests exist in the inspected source. Their presence does +not establish candidate-window behaviour on Windows, macOS or Linux, or equivalent +coverage in other fields. RTL interface direction and PDF coordinate conventions were +also separate concerns. + +Appearance research distinguished interface chrome, page content and reading-colour +transforms. Switching an interface theme did not constitute editing a PDF's colours. +Token tables described intended colours; painted states, alpha composition, focus and +selection established different evidence. Layout evaluation recorded viewport, scaling, +translation length and text direction because a layout observation at one size did not +establish results at other sizes. No archived palette, icon family, font, screenshot or +prototype layout is included or recommended by this reference. + +## Evidence boundaries + +The material includes archived rationale, source inspection, documented conventions and +proposed-test descriptions from historical research. It does not include fresh native +acceptance, manual assistive-technology checks, independently measured OCR accuracy, +rendering benchmarks or external-reader compatibility results for this handoff. + +Names of tests in linked source establish that the source contains those tests. They do +not mean the tests were rerun here, or that an archived implementation's test result +transfers to PdfCraft. The code-inspection and documentation baselines differ and remain +explicit. Current statuses and future work belong to the repository's existing roadmap +and parity records, not this evaluation reference. diff --git a/docs/research/pdf-workflows/source-register.csv b/docs/research/pdf-workflows/source-register.csv new file mode 100644 index 0000000000..558fef67a5 --- /dev/null +++ b/docs/research/pdf-workflows/source-register.csv @@ -0,0 +1,181 @@ +source_id,product,topic,url,archive_date,evidence_state,origin_id,scope_note +SR001,ABBYY FineReader,Start window and task entry (FineReader 14 guide),https://help.abbyy.com/en-us/finereader/14/user_guide/startwindow_firstpage/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR002,ABBYY FineReader,"User guide index: verification, comparison and automation",https://help.abbyy.com/en-us/finereader/16/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names guide chapters but not full URLs; locator reconstructed for discovery. +SR003,ABBYY FineReader,Product and feature documentation entry,https://pdf.abbyy.com/,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study identifies product documentation and brochure; root locator reconstructed. +SR004,ABBYY FineReader Engine SDK,FineReader Engine 12 SDK release notes,https://support.abbyy.com/hc/en-us/articles/37502278922642-FineReader-Engine-12-for-Windows-Release-8-Update-2,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR007,Adobe Acrobat,Acrobat product help index,https://helpx.adobe.com/acrobat/user-guide.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names Help Center but not an exact URL; locator reconstructed for discovery. +SR008,Adobe Express,Adobe Express PDF editing guide,https://helpx.adobe.com/express/web/documents-and-presentations/edit-pdf.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR009,Adobe Express,Adobe Express release notes,https://helpx.adobe.com/express/web/whats-new/release-notes.html,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR010,Adobe Acrobat,Acrobat Studio product announcement,https://news.adobe.com/news/2026/01/adobe-acrobat-studio-transforms,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR011,Adobe Acrobat,Acrobat productivity product announcement,https://news.adobe.com/news/2026/05/adobes-new-productivity-agent,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR012,Adobe Acrobat,Acrobat JavaScript API reference,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR013,Adobe Acrobat,Acrobat JavaScript developer guide,https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR014,Adobe Acrobat,ISO 32000-1:2008 PDF specification,https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR015,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/363,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR016,Bluebeam Revu,Community discussion (secondary workflow evidence),https://community.bluebeam.com/discussion/5299/about-studio-prime-end-of-life-eol,2026-09-07,archived-not-revalidated,core-bluebeam,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR017,Bluebeam Revu,Documentation locator: markups list mtv,https://support.bluebeam.com/online-help/revu20/Content/RevuHelp/Menus/Window/Panels/Markups/Markups-List--MTV.htm,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR018,Bluebeam Revu,Documentation locator: compare documents vs overlay pages,https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR019,Bluebeam Revu,Documentation locator: manage and review markups with filter list,https://support.bluebeam.com/revu/how-to/manage-and-review-markups-with-filter-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR020,Bluebeam Revu,Documentation locator: track and manage markups using markups list,https://support.bluebeam.com/revu/how-to/track-and-manage-markups-using-markups-list.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR021,Bluebeam Revu,Documentation locator: revu 20 eol,https://support.bluebeam.com/revu/resources/revu-20-eol.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR022,Bluebeam Revu,Release notes and change history,https://support.bluebeam.com/revu/resources/revu-21-release-notes.html,2026-09-07,archived-not-revalidated,core-bluebeam;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR023,Bluebeam Revu,Documentation locator: revu mcp,https://support.bluebeam.com/revu/resources/revu-mcp.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR024,Bluebeam Revu,Documentation locator: bluebeam subscription faq,https://support.bluebeam.com/revu/subscription/onboarding/bluebeam-subscription-faq.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR025,Bluebeam Revu,Documentation locator: subscription features,https://support.bluebeam.com/revu/subscription/subscription-features.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR026,Bluebeam Revu,Documentation locator: studio prime eol announcement,https://support.bluebeam.com/studio/resources/studio-prime-eol-announcement.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR027,Bluebeam Revu,Documentation locator: link,https://support.bluebeam.com/user-manual/menus/batch/link.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR028,Bluebeam Revu,Documentation locator: sign seal,https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR029,Bluebeam Revu,Documentation locator: slip sheet,https://support.bluebeam.com/user-manual/menus/batch/slip-sheet.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR030,Bluebeam Revu,Documentation locator: compare documents,https://support.bluebeam.com/user-manual/menus/document/compare-documents.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR031,Bluebeam Revu,Documentation locator: ocr,https://support.bluebeam.com/user-manual/menus/document/ocr.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR032,Bluebeam Revu,Documentation locator: overlay pages,https://support.bluebeam.com/user-manual/menus/document/overlay-pages.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR033,Bluebeam Revu,Documentation locator: erase content,https://support.bluebeam.com/user-manual/menus/edit/erase-content.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR034,Bluebeam Revu,Documentation locator: redaction,https://support.bluebeam.com/user-manual/menus/edit/redaction.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR035,Bluebeam Revu,Documentation locator: create forms,https://support.bluebeam.com/user-manual/menus/tools/create-forms.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR036,Bluebeam Revu,Documentation locator: form fields,https://support.bluebeam.com/user-manual/menus/tools/form-fields.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR037,Bluebeam Revu,Documentation locator: manage self signed ids,https://support.bluebeam.com/user-manual/menus/tools/manage-self-signed-ids.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR038,Bluebeam Revu,Documentation locator: create edit sets,https://support.bluebeam.com/user-manual/menus/window/create-edit-sets.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR039,Bluebeam Revu,Documentation locator: punch tool set,https://support.bluebeam.com/user-manual/menus/window/punch-tool-set.html,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR040,Bluebeam Revu,Licensing and edition reference (historical; no current price assertion),https://www.bluebeam.com/pricing/,2026-09-07,archived-not-revalidated,core-bluebeam,"Archived source locator; supporting passage, edition and present behavior require review." +SR041,ETSI,Documentation locator: 31914201,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR042,ETSI,Documentation locator: en 31914201v010201p,https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR043,Firefox / pdf.js,Form scripting API implementation reference,https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR044,Firefox / pdf.js,Documentation locator: pdf alt text,https://support.mozilla.org/en-US/kb/pdf-alt-text,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR045,Foxit PDF Editor,Documentation locator: pdf products comparison,https://cdn01.foxitsoftware.com/pub/foxit/datasheet/general/en_us/pdf-products-comparison.pdf,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR046,Foxit PDF Editor,Documentation locator: articles,https://kb.foxit.com/s/articles/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR047,Foxit PDF Editor,Documentation locator: 360040658511,https://kb.foxit.com/s/articles/360040658511,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR048,Foxit PDF Editor,Documentation locator: 4410630911764,https://kb.foxit.com/s/articles/4410630911764,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR049,Foxit PDF Editor,Documentation locator: foxit pdf editor perpetual license support policy,https://kb.foxit.com/s/articles/Foxit-PDF-Editor-Perpetual-License-Support-Policy,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR050,Foxit PDF Editor,Documentation locator: steps to upgrade to foxit pdf editor version 14 on windows,https://kb.foxit.com/s/articles/Steps-to-Upgrade-to-Foxit-PDF-Editor-Version-14-on-Windows,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR051,Foxit PDF Editor,Documentation locator: foxit pdf editor aec drawing sets update,https://www.foxit.com/blog/foxit-pdf-editor-aec-drawing-sets-update/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR052,Foxit PDF Editor,Documentation locator: foxits perpetual license support policy what you need to know,https://www.foxit.com/blog/foxits-perpetual-license-support-policy-what-you-need-to-know/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR053,Foxit PDF Editor,Documentation locator: 11200,https://www.foxit.com/company/press/11200.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR054,Foxit PDF Editor,Licensing and edition reference (historical; no current price assertion),https://www.foxit.com/pdf-editor/pricing/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR055,Foxit PDF Editor,Release notes and change history,https://www.foxit.com/pdf-editor/version-history.html,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR056,Foxit PDF Editor,Documentation locator: add custom commands to custom actions,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR057,Foxit PDF Editor,Documentation locator: shared review,https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/shared-review/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR058,Foxit PDF Editor,Published policy or security reference (historical),https://www.foxit.com/support/refund-policy/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR059,Icon system,Icon asset provenance and licensing reference,https://github.com/google/material-design-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR060,Icon system,Icon asset provenance and licensing reference,https://github.com/lucide-icons/lucide,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR061,Icon system,Icon asset provenance and licensing reference,https://github.com/microsoft/fluentui-system-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR062,Icon system,Icon asset provenance and licensing reference,https://github.com/tabler/tabler-icons,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR063,Icon system,License text (historical locator; review exact version before reuse),https://raw.githubusercontent.com/lucide-icons/lucide/main/LICENSE,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR064,IETF,Documentation locator: rfc3161,https://datatracker.ietf.org/doc/html/rfc3161,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR065,iLovePDF,Documentation locator: pdf web or desktop,https://www.ilovepdf.com/blog/pdf-web-or-desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR066,iLovePDF,Documentation locator: desktop,https://www.ilovepdf.com/desktop,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR067,iLovePDF,Licensing and edition reference (historical; no current price assertion),https://www.ilovepdf.com/pricing,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR068,iLovePDF,Documentation locator: sitemap.xml,https://www.ilovepdf.com/sitemap.xml,2026-09-08,archived-not-revalidated,core-ilovepdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR069,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=443403,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR070,KDE,Documentation locator: show bug.cgi,https://bugs.kde.org/show_bug.cgi?id=489299,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR072,KDE,Release notes and change history,https://kde.org/announcements/changelogs/gear/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR073,KDE,Documentation locator: 22.04.0,https://kde.org/announcements/gear/22.04.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR074,KDE,Documentation locator: 23.08.0,https://kde.org/announcements/gear/23.08.0/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR075,KDE,Documentation locator: 26.08.0,https://kde.org/announcements/gear/26.08.0/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR076,LibreOffice Draw,PDF export and security help index,https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html,2026-09-07,locator-reconstructed-not-revalidated,source-title-recovery,Core study names export help paths without full URLs; locator reconstructed for discovery. +SR077,Master PDF Editor,Documentation locator: downloads,https://code-industry.net/downloads/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR078,Master PDF Editor,Documentation locator: faq,https://code-industry.net/faq/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR079,Master PDF Editor,Documentation locator: masterpdfeditor help,https://code-industry.net/masterpdfeditor-help/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR080,Master PDF Editor,Workspace navigation and object inspection,https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR081,Master PDF Editor,Documentation locator: masterpdfeditor,https://code-industry.net/masterpdfeditor/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR082,Master PDF Editor,Licensing and edition reference (historical; no current price assertion),https://code-industry.net/purchase/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR083,Master PDF Editor,Documentation locator: what is new in master pdf editor 5,https://code-industry.net/what-is-new-in-master-pdf-editor-5/,2026-09-07,archived-not-revalidated,core-masterpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR084,Microsoft platform documentation,Documentation locator: 9n41msq1wnm8,https://apps.microsoft.com/detail/9n41msq1wnm8,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR085,Microsoft platform documentation,Documentation locator: xpfd51h3vqzfm0,https://apps.microsoft.com/detail/xpfd51h3vqzfm0,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR086,Microsoft platform documentation,Documentation locator: pdf reader,https://explore.microsoft.com/en-us/edge/features/pdf-reader,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR087,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/12609/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR088,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/21169/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR089,Nitro PDF Pro,Community discussion (secondary workflow evidence),https://community.gonitro.com/topic/22140/,2026-09-07,archived-not-revalidated,core-nitro,Vendor-hosted community discussion; authorship and supporting passage need review. Not a vendor capability or policy statement. +SR090,Nitro PDF Pro,Documentation locator: automate,https://www.gonitro.com/automate,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR091,Nitro PDF Pro,Documentation locator: bulk send and templates,https://www.gonitro.com/bulk-send-and-templates,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR092,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/documentation/release-notes,2026-09-07,archived-not-revalidated,core-nitro;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR093,Nitro PDF Pro,Documentation locator: pdf pro,https://www.gonitro.com/pdf-pro,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR094,Nitro PDF Pro,Licensing and edition reference (historical; no current price assertion),https://www.gonitro.com/pricing,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR095,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR096,Nitro PDF Pro,Release notes and change history,https://www.gonitro.com/release-hub/releases/sign-api,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR097,Nitro PDF Pro,Documentation locator: nitros complete guide to secure automated pdf redaction,https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR098,Nitro PDF Pro,Documentation locator: sign,https://www.gonitro.com/sign,2026-09-07,archived-not-revalidated,core-nitro,"Archived source locator; supporting passage, edition and present behavior require review." +SR099,Okular,Project source and technical documentation reference,https://github.com/KDE/okular,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR100,Okular,Product or project documentation entry,https://okular.kde.org/,2026-09-07,archived-not-revalidated,core-okular,Landing/index locator only; locate a specific supporting page before citing a feature. +SR101,Okular,Documentation locator: download,https://okular.kde.org/download/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR102,Okular,Documentation locator: faq,https://okular.kde.org/faq/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR103,Okular,Documentation locator: formats,https://okular.kde.org/formats/,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR104,Okular,Project source and technical documentation reference,https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook,2026-09-07,archived-not-revalidated,core-okular,"Archived source locator; supporting passage, edition and present behavior require review." +SR105,PDF Association,Product or project documentation entry,https://pdfa.org,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR106,PDF Association,Release notes and change history,https://pdfa.org/foxit-20252-and-v14-release-highlights-faster-smarter-more-secure/,2026-09-07,archived-not-revalidated,core-foxit,"Archived source locator; supporting passage, edition and present behavior require review." +SR107,PDF Association,Foxit PDF Editor 2026.2 release announcement,https://pdfa.org/foxit-pdf-editor-20262-release-notes/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR108,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR109,PDF Association,UPDF 2.5 release announcement,https://pdfa.org/updf-25-officially-launches-worldwide-the-first-pdf-editor-powered-by-autonomous-ai-agents/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR110,PDF Association,Accessible PDF standards and reference material,https://pdfa.org/wtpdf/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR111,PDF Expert,Product or project documentation entry,https://copilot.pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,Landing/index locator only; locate a specific supporting page before citing a feature. +SR112,PDF Expert,Product or project documentation entry,https://pdfexpert.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR113,PDF Expert,PDF Expert assisted document-reading announcement,https://pdfexpert.com/blog/introducing-ai-chat,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR114,PDF Expert,Product or project documentation entry,https://readdle.com/,2026-09-07,archived-not-revalidated,core-pdfexpert,Landing/index locator only; locate a specific supporting page before citing a feature. +SR115,PDF Expert,Documentation locator: is pdf expert available on windows,https://support.readdle.com/pdfexpert/en_US/billing-subscription/is-pdf-expert-available-on-windows,2026-09-07,archived-not-revalidated,core-pdfexpert;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR116,PDF library documentation,Project source and technical documentation reference,https://raw.githubusercontent.com/podofo/podofo/1.1.0/src/podofo/main/PdfSignerCms.cpp,2026-09-09,archived-not-revalidated,design-send-for-signing,"Archived source locator; supporting passage, edition and present behavior require review." +SR117,PDF-XChange,Documentation locator: features added in v9,https://help.pdf-xchange.com/pdfxe11/features-added-in-v9.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR118,PDF-XChange,Documentation locator: 352,https://www.pdf-xchange.com/knowledgebase/352-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR119,PDF-XChange,Documentation locator: 554,https://www.pdf-xchange.com/knowledgebase/554-,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR120,PDF-XChange,Documentation locator: pdf xchange ocr,https://www.pdf-xchange.com/pdf-xchange-ocr,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR121,PDF-XChange,Documentation locator: pdf xchange products comparison chart,https://www.pdf-xchange.com/pdf-xchange-products-comparison-chart,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR122,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/privacy-policy,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR123,PDF-XChange,Documentation locator: pdf xchange editor,https://www.pdf-xchange.com/product/pdf-xchange-editor,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR124,PDF-XChange,Release notes and change history,https://www.pdf-xchange.com/product/pdf-xchange-editor/history,2026-09-07,archived-not-revalidated,core-pdfxchange;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR125,PDF-XChange,Published policy or security reference (historical),https://www.pdf-xchange.com/support/security-bulletins.html,2026-09-07,archived-not-revalidated,core-pdfxchange,"Archived source locator; supporting passage, edition and present behavior require review." +SR126,PDF24,Desktop Creator documentation entry,https://creator.pdf24.org/,2026-09-08,locator-reconstructed-not-revalidated,source-title-recovery,Product documentation index; exact supporting feature pages require review. +SR127,PDF24,Release notes and change history,https://creator.pdf24.org/changelog/en.html,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR128,PDF24,Documentation locator: 11,https://creator.pdf24.org/manual/11/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR129,PDF24,Documentation locator: avoid autostart of pdf24,https://help.pdf24.org/en/forums/topic/avoid-autostart-of-pdf24/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR130,PDF24,Documentation locator: all tools,https://tools.pdf24.org/en/all-tools,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR131,PDF24,Documentation locator: compress pdf,https://tools.pdf24.org/en/compress-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR132,PDF24,Documentation locator: creator,https://tools.pdf24.org/en/creator,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR133,PDF24,Documentation locator: ocr pdf,https://tools.pdf24.org/en/ocr-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR134,PDF24,Documentation locator: redact pdf,https://tools.pdf24.org/en/redact-pdf,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR135,PDF24,Documentation locator: en,https://www.pdf24.org/en/,2026-09-08,archived-not-revalidated,core-pdf24,"Archived source locator; supporting passage, edition and present behavior require review." +SR136,PDF4QT,Project source and technical documentation reference,https://github.com/JakubMelka/PDF4QT,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR138,PDFelement,PDFelement version history,https://pdf.wondershare.com/whats-new.html,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR139,PDFgear,Product or project documentation entry,https://www.pdfgear.com/,2026-09-07,archived-not-revalidated,core-pdfgear,Landing/index locator only; locate a specific supporting page before citing a feature. +SR140,PDFgear,PDFgear version history,https://www.pdfgear.com/whats-new/,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR141,PDFsam,Documentation locator: faq,https://pdfsam.org/faq/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR142,Power PDF,Power PDF licensing and edition reference (historical),https://www.tungstenautomation.com/Products/power-pdf/pricing,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR143,Power PDF,Power PDF 2025.3 release announcement,https://www.tungstenautomation.com/press-releases/2025/tungsten-automation-launches-power-pdf-2025-3,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR144,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6.8/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR145,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qcolorspace.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR146,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qimage.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR147,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/qtquickcontrols-fluentwinui3.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR148,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/style-reference.html,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR149,Qt documentation,Native UI or image/color API documentation,https://doc.qt.io/qt-6/whatsnew68.html,2026-10-04,archived-not-revalidated,design-cmyk-lcms2-plan-2026-10-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR150,Qt documentation,Release notes and change history,https://forum.qt.io/topic/156001/style-mistake-in-win11-with-qt-6-7-mingw-11-2-official-online-release,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR151,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.7-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR152,Qt documentation,Release notes and change history,https://www.qt.io/blog/qt-6.8-released,2026-09-25,archived-not-revalidated,expanded-04,"Archived source locator; supporting passage, edition and present behavior require review." +SR153,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/download,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR154,Script runtime documentation,Script runtime technical and licensing reference,https://duktape.org/guide,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR155,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/ArtifexSoftware/mujs/tags,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR156,Script runtime documentation,Script runtime repository and licensing reference,https://github.com/quickjs-ng/quickjs,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR157,Script runtime documentation,Script runtime technical and licensing reference,https://github.com/svaarala/duktape,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR158,Script runtime documentation,Script runtime technical and licensing reference,https://mujs.com/,2026-09-07,archived-not-revalidated,design-form-js,Landing/index locator only; locate a specific supporting page before citing a feature. +SR159,Script runtime documentation,License text (historical locator; review exact version before reuse),https://mujs.com/license.html,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR160,Script runtime documentation,Script runtime technical and licensing reference,https://quickjs-ng.github.io/quickjs/developer-guide/intro/,2026-09-07,archived-not-revalidated,design-form-js,"Archived source locator; supporting passage, edition and present behavior require review." +SR161,Sejda,Project source and technical documentation reference,https://github.com/torakiki/sejda,2026-09-08,archived-not-revalidated,core-sejda,"Archived source locator; supporting passage, edition and present behavior require review." +SR162,Sejda,Product or project documentation entry,https://www.sejda.com/,2026-09-08,archived-not-revalidated,core-sejda,Landing/index locator only; locate a specific supporting page before citing a feature. +SR163,Sejda,Documentation locator: workflows,https://www.sejda.com/workflows,2026-09-09,archived-not-revalidated,design-batch-presets,"Archived source locator; supporting passage, edition and present behavior require review." +SR164,Smallpdf,Documentation locator: compare,https://smallpdf.com/compare,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR165,Smallpdf,Documentation locator: desktop,https://smallpdf.com/desktop,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR166,Smallpdf,Documentation locator: windows,https://smallpdf.com/download/windows,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR167,Smallpdf,Documentation locator: edit pdf,https://smallpdf.com/edit-pdf,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR168,Smallpdf,Licensing and edition reference (historical; no current price assertion),https://smallpdf.com/pricing,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR169,Smallpdf,Published policy or security reference (historical),https://smallpdf.com/privacy,2026-09-08,archived-not-revalidated,core-smallpdf,"Archived source locator; supporting passage, edition and present behavior require review." +SR170,Stirling-PDF,Product or project documentation entry,https://docs.stirlingpdf.com/,2026-09-25,archived-not-revalidated,expanded-02,Landing/index locator only; locate a specific supporting page before citing a feature. +SR171,Stirling-PDF,Structured automation interface documentation,https://docs.stirlingpdf.com/Configuration/Automation/MCP%20Server/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR172,Stirling-PDF,Automation pipeline documentation,https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR173,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Modes%20and%20Licensing/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR174,Stirling-PDF,Licensing and edition reference (historical; no current price assertion),https://docs.stirlingpdf.com/Paid-Offerings/,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR175,Stirling-PDF,Project source and technical documentation reference,https://github.com/Stirling-Tools/Stirling-PDF,2026-09-25,archived-not-revalidated,expanded-02,"Archived source locator; supporting passage, edition and present behavior require review." +SR176,SumatraPDF,SumatraPDF change history,https://www.sumatrapdfreader.org/docs/Changelog,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR177,SumatraPDF,Command palette and navigation prefixes,https://www.sumatrapdfreader.org/docs/Command-Palette,2026-09-25,archived-not-revalidated,expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR178,UPDF,Product or project documentation entry,https://ai.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR179,UPDF,Documentation locator: newgoodslist,https://checkout.updf.com/v1/order/newGoodsList?goodsType=1&channel=SEO-EN-AI¤cy=USD,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR180,UPDF,Product or project documentation entry,https://sign.updf.com/,2026-09-07,archived-not-revalidated,core-updf,Landing/index locator only; locate a specific supporting page before citing a feature. +SR181,UPDF,Licensing and edition reference (historical; no current price assertion),https://updf.com/pricing/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR182,UPDF,Documentation locator: tech spec,https://updf.com/tech-spec/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." +SR183,UPDF,Release notes and change history,https://updf.com/whats-new/,2026-09-07,archived-not-revalidated,core-updf;expanded-01,"Archived source locator; supporting passage, edition and present behavior require review." +SR184,UPDF,Documentation locator: write a review for updf,https://updf.com/write-a-review-for-updf/,2026-09-07,archived-not-revalidated,core-updf,"Archived source locator; supporting passage, edition and present behavior require review." diff --git a/docs/research/pdf-workflows/workflow-reference.md b/docs/research/pdf-workflows/workflow-reference.md new file mode 100644 index 0000000000..c0a419711a --- /dev/null +++ b/docs/research/pdf-workflows/workflow-reference.md @@ -0,0 +1,430 @@ +# Workflow research and verification reference + +This reference connects archived workflow research with PdfCraft's existing feature catalogue. It is not a roadmap, a set of implementation assignments, or an approval to introduce features, storage formats, dependencies or architecture. Planning remains in the repository's existing `ROADMAP.md` and `parity/` records. +The catalogue and roadmap snapshot is [`924c4da4`](https://github.com/storytold/pdfcraft/tree/924c4da4ac2879ac9107c6c82e20ac6be5570f0a), dated 2026-10-16. Engine and automation names were inspected separately at [`3f044b62`](https://github.com/storytold/pdfcraft/tree/3f044b6263aa6605c3aa44ca6d065e1ab37d13b6), dated 2026-10-13. Catalogue status means the status recorded at that revision; it is not an independent verification verdict. No implementation tests, benchmarks, competitor sessions or conformance checks were executed for this reference. +The [competitive research digest](competitive-research.md) provides the archived context. The [source register](source-register.csv) records source identifiers, URLs, archive dates and verification limits. Links below are locators for the retained research, not fresh confirmations of vendor behavior. The [feature mapping](feature-mapping.csv) contains the corresponding catalogue records. B01–B20 are reference section identifiers, not a second task or priority system. +Evaluation dimensions name what an observation describes; they do not prescribe targets or new acceptance gates. Availability, structural validity, visual fidelity, interoperability and human usability are distinct evidence. Negative or incomplete observations remain distinguishable from successful checks. + +## B01 — Rendering and output fidelity + +**Recorded baseline.** The checklist records six mapped rendering features as partial. Its knockout entry is marked shipped, but cites signing tests and signed-save notes; the transparency entry describes knockout rendering as incomplete. Those are inconsistent catalogue references, not evidence from a fresh rendering test. The current progress log and gap 12 document existing `cargo xtask render-oracle` and `cargo xtask text-oracle` tools, including pixel disagreements and reading-order measurements. These tools report differences; their output is not a fidelity certification. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.render-pages` | partial | M2 | +| `view.render-shadings-patterns` | partial | M2 | +| `view.render-transparency` | partial | M2 | +| `view.render-knockout` | shipped | M11 | +| `view.render-type3` | partial | M2 | +| `view.render-cid-fonts` | partial | M2 | +| `view.render-image-codecs` | partial | M2 | + +**Archived research question.** The archived viewing and conversion studies distinguish whether a file opens, whether its text can be extracted, and whether the visible page matches a reference. These observations concern different kinds of fidelity. + +**Evaluation dimensions.** Pixel differences and their locations; glyph identity and reading order; clipping and transparency; independent-reader outcomes; oracle version and tolerance; unsupported or incomplete analysis. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/render/src/raster.rs::renders_a_blue_rectangle`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/render/src/raster.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B02 — Existing-text editing + +**Recorded baseline.** Text editing and RTL/CJK editing are partial. The mapped rotated-text and tag-preservation entries remain planned. Inspected source exposes text_edit, text_edit_layout, text_fonts and text_font_import; this does not establish general support for every font or script. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `edit.text-edit` | partial | M7 | +| `edit.rtl-cjk-editing` | partial | M7 | +| `edit.rotated-text-editing` | planned | M7 | +| `edit.keep-tags-on-edit` | planned | M7 | + +**Archived research question.** The archived editing studies ask whether correcting text preserves its font, baseline, writing order, nearby content and document structure. A visually acceptable edit and a semantically faithful edit are separate observations. + +**Evaluation dimensions.** Extracted Unicode and logical order; shaping and missing-glyph behavior; retained font/style; neighbouring content; selection/caret behavior; saved/reopened layout; unsupported transformations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/tests.rs); [`crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/edit/src/bidi.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B03 — Document comparison + +**Recorded baseline.** Text comparison, visual comparison, PDF reports and side-by-side views are shipped in the current checklist. The side-by-side notes describe an old-tab requirement, a minimum window width of 1000 px and offset-based synchronization. They identify content-aligned scrolling and retaining comparison after the old tab closes as limitations. Filters, selected ranges and scanned comparison remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.compare-text` | shipped | M12 | +| `misc.compare-visual` | shipped | M12 | +| `misc.compare-report` | shipped | M12 | +| `misc.compare-side-by-side` | shipped | M12 | +| `misc.compare-filters` | planned | M12 | +| `misc.compare-page-ranges` | planned | M12 | +| `misc.compare-scanned` | planned | M12 | + +**Archived research question.** Archived comparison studies distinguish a textual change list, raster differences, overlays and synchronized old/new context. The research question is how a reviewer associates a reported difference with both document locations. + +**Evaluation dimensions.** Difference type and location; page alignment with insertions or unequal sizes; zoom/scroll correspondence; navigation counts; report destinations; identical-input outcomes; input immutability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/compare/src/lib.rs::insertions_deletions_and_replacements`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/compare/src/lib.rs); [`crates/engine/src/tests.rs::comparing_two_versions_of_a_document`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR018: Bluebeam Revu — Documentation locator: compare documents vs overlay pages](https://support.bluebeam.com/revu/features/compare-documents-vs-overlay-pages.html); [SR030: Bluebeam Revu — Documentation locator: compare documents](https://support.bluebeam.com/user-manual/menus/document/compare-documents.html); [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/). Verification state and date are in the source register. + +## B04 — Recognition proofing and correction + +**Recorded baseline.** Recognition of pages, ranges and multiple files is shipped; editable-text output is partial; correction of low-confidence suspects is planned. Inspected source includes ocr_recognize, ocr_recognize_files and ocr_status. These operations alone do not establish a proofing workflow. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.correct-recognized-text` | planned | M10 | +| `ocr.editable-text-output` | partial | M10 | +| `ocr.recognize-text` | shipped | M10 | +| `ocr.recognize-range` | shipped | M10 | +| `ocr.recognize-multiple` | shipped | M10 | + +**Archived research question.** The archived recognition study separates image/text/zoom synchronization, navigation among uncertain material, correction, skipping and human-reviewed state. Confidence from an engine and a human review decision are distinct evidence. + +**Evaluation dimensions.** Word-to-image correspondence; confidence provenance or absence; correction in extracted text after save/reopen; retained scan appearance; skipped versus reviewed state; stale recognition results; keyboard reachability. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/tests/ocr_edit.rs); [`crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR031: Bluebeam Revu — Documentation locator: ocr](https://support.bluebeam.com/user-manual/menus/document/ocr.html). Verification state and date are in the source register. + +## B05 — Recognition languages and scan preparation + +**Recorded baseline.** Language support is partial; orientation detection, deskew and pluggable engines are planned. Camera-image cleanup provides partial despeckle, background-removal and perspective capabilities. The notes do not establish equivalent preprocessing of arbitrary scanned pages. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `ocr.ocr-languages` | partial | M10 | +| `ocr.rotation-detection` | planned | M10 | +| `ocr.deskew` | planned | M10 | +| `ocr.ocr-engines` | planned | M10 | +| `ocr.despeckle` | partial | M10 | +| `ocr.background-removal` | partial | M10 | +| `ocr.camera-perspective` | partial | M10 | + +**Archived research question.** The archived scan studies distinguish selected language from the language actually recognized and distinguish camera perspective correction from text orientation, deskew and image cleanup. + +**Evaluation dimensions.** Actual backend/language; model availability and provenance; rotation/skew geometry; text accuracy by script; annotation/search coordinate agreement; pages with existing text; duplicate-layer behavior; explicit unsupported options. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/ocr.rs::recognize_text_dialog_adds_searchable_text`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ocr.rs); [`crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/camera_clean.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR002: ABBYY FineReader — User guide index: verification, comparison and automation](https://help.abbyy.com/en-us/finereader/16/); [SR120: PDF-XChange — Documentation locator: pdf xchange ocr](https://www.pdf-xchange.com/pdf-xchange-ocr); [SR133: PDF24 — Documentation locator: ocr pdf](https://tools.pdf24.org/en/ocr-pdf). Verification state and date are in the source register. + +## B06 — Reusable batch processing + +**Recorded baseline.** Action creation and execution are shipped; management/exchange and prompt-user steps remain planned. The inspected 2026-10-13 actions.rs has 12 step kinds, and action_list/action_run are present. This is an older source observation, not a new inspection of the current runner. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.action-wizard-run` | shipped | M13 | +| `misc.action-wizard-create` | shipped | M13 | +| `misc.action-wizard-manage` | planned | M13 | +| `misc.action-prompt-steps` | planned | M13 | + +**Archived research question.** Archived workflow studies describe named sequences, ordered steps, portable definitions and per-file outcomes. They distinguish a saved process definition from the outcomes of a particular execution. + +**Evaluation dimensions.** Definition/version round-trip; step order and arguments; input/output identity; occupied output names; individual failure reporting; partial batch outcomes; cancellation; path confinement; secret handling in reports. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/actions.rs); [`crates/automation/tests/automation.rs::actions_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR056: Foxit PDF Editor — Documentation locator: add custom commands to custom actions](https://www.foxit.com/resource-hub/user-guide/foxit-pdf-editor-for-windows/add-custom-commands-to-custom-actions/); [SR172: Stirling-PDF — Automation pipeline documentation](https://docs.stirlingpdf.com/Configuration/Automation/Pipeline/). Verification state and date are in the source register. + +## B07 — Document structure and accessibility authoring + +**Recorded baseline.** Text-only autotag, Tags/property editing, sibling reading-order edits and artifact marking are partial. The current artifact entry covers reachable indirect leaf tags owning direct MCIDs through the panel and accessibility_mark_tag_artifact. Arbitrary painted selection, direct dictionaries and whole tagged subtrees remain outside that entry. accessibility_tags is described as paginated and filterable. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.autotag` | partial | M12 | +| `a11y.tags-panel` | partial | M12 | +| `a11y.tag-properties` | partial | M12 | +| `a11y.reading-order-tool` | partial | M12 | +| `a11y.order-panel` | partial | M12 | +| `a11y.table-editor` | planned | M12 | +| `a11y.create-tag-from-selection` | planned | M12 | +| `a11y.artifact-marking` | partial | M12 | + +**Archived research question.** Archived accessibility studies distinguish automatic classification, actual structure-tree changes, reading order and human interpretation. A visual panel, a structurally valid parent tree and an accessible reading experience are different observations. + +**Evaluation dimensions.** MCID/parent-tree consistency; content ownership; tag and reading order; alternate text; direct/indirect dictionary coverage; malformed or ambiguous structure; appearance after artifact marking; saved/reopened structure; manual-check findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/accessibility.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/); [SR110: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/wtpdf/); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B08 — Preflight and archival validation + +**Recorded baseline.** PDF/A verification and conversion are partial. Preflight analysis, result presentation and reports remain planned. Inspected source exposes pdfa_verify, pdfa_convert and print_readiness; their availability is not proof of full conformance validation. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `optimize.pdfa-validate` | partial | M11 | +| `optimize.pdfa-convert` | partial | M11 | +| `optimize.preflight` | planned | M11 | +| `optimize.preflight-results` | planned | M11 | +| `optimize.preflight-reports` | planned | M11 | + +**Archived research question.** The archived standards studies distinguish declared conformance, implemented structural checks, conversion changes and independent validation. A repairable finding is different from a condition the software cannot evaluate. + +**Evaluation dimensions.** Profile and rule coverage; page/object identification; unsupported checks; fonts and output intents; changes made by conversion; remaining findings after save/reopen; agreement and disagreement with an identified independent validator. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/preflight/src/tests.rs); [`crates/automation/tests/automation.rs::pdfa_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR105: PDF Association — Product or project documentation entry](https://pdfa.org). Verification state and date are in the source register. + +## B09 — Redaction and assurance evidence + +**Recorded baseline.** The mapped excision and verification features are shipped. Redaction properties are also shipped, including overlay/mark styling, persistent defaults, session-only changes and cancel behavior. Pattern locales, partial words and folder redaction remain planned; code sets are partial. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `protect.redaction-verification` | shipped | M8 | +| `protect.redact-glyph-removal` | shipped | M8 | +| `protect.redact-image-pixels` | shipped | M8 | +| `protect.redact-vectors` | shipped | M8 | +| `protect.redact-annots-fields` | shipped | M8 | +| `protect.redact-struct-tree` | shipped | M8 | +| `protect.redact-pattern-locales` | planned | M8 | +| `protect.redact-partial-words` | planned | M8 | +| `protect.redact-folder` | planned | M13 | +| `protect.redaction-properties` | shipped | M8 | +| `protect.redaction-codes` | partial | M8 | + +**Archived research question.** Archived redaction studies distinguish a visible cover from removal of underlying content and distinguish a scoped verification result from an unlimited security guarantee. A report containing removed text can itself disclose sensitive content. + +**Evaluation dimensions.** Search/extraction after save/reopen; image/vector/annotation/structure paths covered; hidden-information treatment; input/output hashes; unsupported constructs; false success on a cover-only control; refusal/cancellation behavior; sensitive data in exported records. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/redact/src/tests.rs::unreadable_content_fails_closed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/redact/src/tests.rs); [`crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/ui_group/redact.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR034: Bluebeam Revu — Documentation locator: redaction](https://support.bluebeam.com/user-manual/menus/edit/redaction.html); [SR097: Nitro PDF Pro — Documentation locator: nitros complete guide to secure automated pdf redaction](https://www.gonitro.com/resources/nitros-complete-guide-to-secure-automated-pdf-redaction); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B10 — Digital signatures and validation evidence + +**Recorded baseline.** The mapped B-B, PKCS #12, native Windows/macOS identity and certification capabilities are shipped. Timestamp preferences, PKCS #11 and FieldMDP are planned; B-T/LTV/B-LTA and online revocation support are partial. Inspected source exposes sign_document, sign_list and sign_trust. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `sign.cades-pades-bb` | shipped | M9 | +| `sign.pkcs12` | shipped | M9 | +| `sign.windows-cert-store` | shipped | M9 | +| `sign.macos-keychain` | shipped | M9 | +| `sign.certify` | shipped | M9 | +| `sign.timestamp-servers` | planned | M9 | +| `sign.pkcs11` | planned | M9 | +| `sign.field-mdp` | planned | M9 | +| `sign.pades-bt` | partial | M9 | +| `sign.ltv` | partial | M9 | +| `sign.pades-blta` | partial | M9 | +| `sign.ocsp-crl` | partial | M9 | + +**Archived research question.** Archived signing studies distinguish document integrity, signer trust, permitted changes, timestamp evidence and long-term validation material. A local signing workflow is different from a hosted signature-request service. + +**Evaluation dimensions.** Covered revision; permitted/disallowed changes; intact versus trusted status; timestamp presence and trust; revocation-evidence freshness; requested versus attained level; network-consent state; private-key custody; reopened independent validation. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/sign/tests/crypto.rs::signatures_round_trip_for_every_key_type`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/crypto.rs); [`crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/sign/tests/pdf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR028: Bluebeam Revu — Documentation locator: sign seal](https://support.bluebeam.com/user-manual/menus/batch/sign-seal.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B11 — Forms, events and scripting + +**Recorded baseline.** Custom scripts, event order and calculation order are shipped. Form submission and the mapped general JavaScript action are planned; push-button actions are partial. Inspected source includes js_run, js_document_scripts, form_set_script, form_actions, form_set_actions and js_enabled. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `form.custom-scripts` | shipped | M6 | +| `form.event-order` | shipped | M6 | +| `form.calculation-order` | shipped | M6 | +| `form.submit-form` | planned | M6 | +| `edit.action-javascript` | planned | M6 | +| `form.push-buttons` | partial | M6 | + +**Archived research question.** Archived form studies separate compatibility with installed forms from authoring tools and separate field-event order from document-level actions. Scripting availability does not imply filesystem or network privileges. + +**Evaluation dimensions.** Trigger timing; validation/calculation/format order; committed values after save/reopen; enabled/disabled script state; resource bounds; malformed scripts; unwanted external effects; cancellation and refusal outcomes. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/forms/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR012: Adobe Acrobat — Acrobat JavaScript API reference](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/index.html); [SR013: Adobe Acrobat — Acrobat JavaScript developer guide](https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsdevguide/index.html); [SR043: Firefox / pdf.js — Form scripting API implementation reference](https://github.com/mozilla/pdf.js/blob/master/src/scripting_api/aform.js). Verification state and date are in the source register. + +## B12 — Measurement and scale interpretation + +**Recorded baseline.** Distance and scale are partial; perimeter, area, snapping, information display and CSV export are shipped. Current mapped measurement entries are assigned to M12. Inspected source includes the measure_* tool family. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `comment.measure-distance` | partial | M12 | +| `comment.measure-scale` | partial | M12 | +| `comment.measure-perimeter` | shipped | M12 | +| `comment.measure-area` | shipped | M12 | +| `comment.measure-snap` | shipped | M12 | +| `comment.measure-info-panel` | shipped | M12 | +| `comment.measure-export` | shipped | M12 | + +**Archived research question.** Archived technical-review studies distinguish measurement geometry from unit formatting, calibration and interchange. Reading a saved measurement is different from assuming every imported scale is supported. + +**Evaluation dimensions.** Known dimensions and numeric tolerance; selected viewport and scale; compound/fractional formats; rotated/cropped pages; imported unsupported formats; self-intersecting boundaries; saved/reopened values; CSV escaping and spreadsheet safety. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/automation/tests/automation.rs::measurements_calibrate_draw_save_reopen_and_export`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs); [`crates/measure/src/tests.rs::viewport_precedence_first_point_user_unit_and_roundtrip`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/measure/src/tests.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR107: PDF Association — Foxit PDF Editor 2026.2 release announcement](https://pdfa.org/foxit-pdf-editor-20262-release-notes/). Verification state and date are in the source register. + +## B13 — Page assembly and document composition + +**Recorded baseline.** Combine, selected page ranges, sorting/grid previews, every-N/bookmark splitting and field-name conflict handling are shipped. Structure-tree merging, multiple-file split and the mapped naming refinements remain planned. Existing combine and page tools were present in the inspected source. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.combine-files` | shipped | M4 | +| `organize.combine-choose-pages` | shipped | M4 | +| `organize.combine-sort` | shipped | M4 | +| `organize.combine-thumbnails` | shipped | M4 | +| `organize.field-name-conflicts` | shipped | M4 | +| `organize.split-every-n` | shipped | M4 | +| `organize.split-by-bookmarks` | shipped | M4 | +| `organize.struct-tree-merge` | planned | M12 | +| `organize.split-multiple-files` | planned | M13 | +| `organize.split-naming` | planned | M4 | + +**Archived research question.** Archived composition studies examine how users review source pages, order and destination before a transfer. Visible selection, the captured source revision and the resulting document are separate pieces of evidence. + +**Evaluation dimensions.** Page order; source/target identity; unsaved-source handling; links, fields and bookmarks; size mismatch; field-name conflicts; structure preservation; source immutability; undo; saved/reopened destinations. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/organize/src/tests.rs); [`crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/editing.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B14 — Creation and conversion fidelity + +**Recorded baseline.** Office import and spreadsheet export are planned; DOCX and presentation export are partial. DOCX notes record package validation and DrawingML picture anchors on positioned pages, with that positioned output still unverified in Word. Package validity does not establish visual fidelity. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `create.from-office` | planned | M10 | +| `create.export-docx` | partial | M10 | +| `create.export-xlsx` | planned | M10 | +| `create.export-pptx` | partial | M10 | + +**Archived research question.** Archived conversion studies distinguish reflowed editable output, positioned output, raster output and native PDF edits. Their suitability depends on what information the user needs to retain. + +**Evaluation dimensions.** Text and table semantics; headers/footers; page dimensions; images and vector rules; paint order; exact font matching; package validity; reopened rendering; independent application/version; explicit unsupported input and output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/lib.rs); [`crates/export/src/pptx.rs::pptx_is_a_presentationml_package`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/export/src/pptx.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B15 — Document performance and resource use + +**Recorded baseline.** Smooth 500-page scrolling is shipped. Performance budgets and lazy loading of very large files are planned. Existing notes describe whole-file input and remaining save-memory peaks; they do not establish bounded performance for every document. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.large-document-performance` | shipped | M3 | +| `misc.performance-budgets` | planned | M14 | +| `core.lazy-loading` | planned | M1 | + +**Archived research question.** Archived performance studies distinguish open time, first useful page, interaction latency and job completion. Input byte size and page count alone do not describe rendering complexity. + +**Evaluation dimensions.** Release build and hardware; corpus generation; fresh-process runs and variance; peak resident memory; open/edit/save timings; UI-thread stalls; cache state; cancellation latency; bounds on hostile dimensions; output validity after reopen. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/perf.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. + +## B16 — Search across document collections + +**Recorded baseline.** Advanced current-document search is partial; folder, Boolean and indexed search are planned. Inspected source includes text_find and extraction. Mapped search entries belong to M12. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `view.advanced-search` | partial | M12 | +| `view.search-folder` | planned | M12 | +| `view.search-boolean` | planned | M12 | +| `view.search-index` | planned | M12 | + +**Archived research question.** Archived search studies distinguish current-file matching, collection traversal, persistent indexing and navigation back to a location. A result count is incomplete without the set of files actually searched. + +**Evaluation dimensions.** Query semantics; searched/excluded/failed files; path aliases and cycles; root confinement; result and cache bounds; stale locations after edits; location after reopen; cancellation; truncation and incomplete-result reporting. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/viewer.rs::advanced_search_lists_results_with_context`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/viewer.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette); [SR007: Adobe Acrobat — Acrobat product help index](https://helpx.adobe.com/acrobat/user-guide.html). Verification state and date are in the source register. + +## B17 — Keyboard and assistive-technology interaction + +**Recorded baseline.** App screen-reader support is partial and keyboard-only operation is planned. Reading colors are shipped; current notes cover annotation appearances and highlight legibility. Existing labels and display-color behavior do not establish completion of every task with assistive technology. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `a11y.app-screen-reader` | partial | M3 | +| `a11y.keyboard-only` | planned | M14 | +| `a11y.replace-page-colors` | shipped | M12 | + +**Archived research question.** Archived interaction studies distinguish the accessibility tree, keyboard focus and what a platform screen reader announces. Successful automated tree inspection and a successful human task are different evidence. + +**Evaluation dimensions.** Names, roles, values and states; complete focus path; focus restoration; error/completion announcements; disabled-state explanations; selection after page changes; non-color information; platform/screen-reader version; manual versus automated findings. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/control.rs); [`crates/automation/tests/automation.rs::reading_colors_through_tools`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/automation/tests/automation.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular); [SR104: Okular — Project source and technical documentation reference](https://raw.githubusercontent.com/KDE/okular/master/doc/index.docbook); [SR108: PDF Association — Accessible PDF standards and reference material](https://pdfa.org/iso-14289-2-pdf-ua-2-the-gold-standard-for-accessibility-in-pdf-2-0-has-arrived/). Verification state and date are in the source register. + +## B18 — Preferences and capability disclosure + +**Recorded baseline.** The preferences dialog is partial; settings search, admin defaults and RTL UI are planned. Existing categories include language, document/view, identity, Fill & Sign and JavaScript. Current notes describe XDG migration that preserves configured preferences; they do not establish a complete signature-preferences category. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `misc.preferences-dialog` | partial | M14 | +| `misc.prefs-search` | planned | M14 | +| `misc.admin-preferences` | planned | M14 | +| `misc.rtl-ui` | planned | M14 | + +**Archived research question.** Archived shell and deployment studies distinguish application preferences, document overrides, session state and managed policy. A visible option is different from an available runtime capability. + +**Evaluation dimensions.** Scope and precedence; default versus override; persistence across restart/reopen; malformed stored values; migration preservation; policy-locked state; cancellation of drafts; consistency among supported entry points. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/javascript.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR080: Master PDF Editor — Workspace navigation and object inspection](https://code-industry.net/masterpdfeditor-help/workspace-master-pdf-editor/); [SR177: SumatraPDF — Command palette and navigation prefixes](https://www.sumatrapdfreader.org/docs/Command-Palette). Verification state and date are in the source register. + +## B19 — Print preparation and color behavior + +**Recorded baseline.** Printing, n-up/booklet and previews exist. Saved print presets and the mapped output-preview/color-production features are planned. Inspected source includes print_readiness; that inspection path is not a full production Preflight. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `organize.print-presets` | planned | M10 | +| `print.output-preview` | planned | M11 | +| `print.color-warnings` | planned | M11 | +| `print.convert-colors` | planned | M11 | +| `print.color-management` | planned | M11 | + +**Archived research question.** Archived print studies distinguish display-only reading colors, print-preview appearance, color conversion and the output intent. An ICC profile or a printer selection has meaning only with an identified processing path. + +**Evaluation dimensions.** Page order and dimensions; persistent print options; preview/output correspondence; fonts and color-space findings; profile provenance and rendering intent; unsupported separations; device/platform differences; reopened print-ready output. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites the per-feature evidence recorded in its entries. These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR076: LibreOffice Draw — PDF export and security help index](https://help.libreoffice.org/latest/en-US/text/shared/01/ref_pdf_export.html); [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). Verification state and date are in the source register. + +## B20 — Document identity, history and failure outcomes + +**Recorded baseline.** The mapped undo, atomic batch-edit, autosave, crash-recovery and unsaved-change capabilities are shipped. Current recovery notes cover instance locks that prevent another running instance from offering/discarding live autosaves. Browser recovery and multiple windows remain planned. + +| Existing parity ID | Recorded status | Existing milestone | +|---|---|---| +| `core.undo-redo` | shipped | M4 | +| `core.undo-after-save` | shipped | M4 | +| `core.atomic-batch-edits` | shipped | M4 | +| `core.autosave` | shipped | M4 | +| `core.crash-recovery` | shipped | M4 | +| `core.unsaved-changes-prompt` | shipped | M4 | +| `core.web-recovery` | planned | M4 | +| `view.multiple-windows` | planned | M14 | + +**Archived research question.** Archived document-ownership studies distinguish the active tab from the document captured by a long-running operation and distinguish a staged candidate from an accepted save. Those studies are not evidence of a defect in current PdfCraft. + +**Evaluation dimensions.** Captured document/revision; destination identity; close-during-save outcome; external file replacement; write failure; coherent undo/redo; unrelated-document preservation; recovery binding; saved/reopened accepted state; cancellation and reported non-interruptible stages. + +**Existing implementation evidence.** The [pinned checklist](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/parity/acrobat-features.toml) cites [`crates/engine/src/tests.rs::undo_and_redo_restore_exact_states`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/engine/src/tests.rs); [`crates/ui-egui/tests/recovery.rs::saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents`](https://github.com/storytold/pdfcraft/blob/924c4da4ac2879ac9107c6c82e20ac6be5570f0a/crates/ui-egui/tests/recovery.rs). These are existing evidence references, not checks rerun for this document. + +**Archived source locators.** [SR014: Adobe Acrobat — ISO 32000-1:2008 PDF specification](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf); [SR099: Okular — Project source and technical documentation reference](https://github.com/KDE/okular). Verification state and date are in the source register. diff --git a/docs/roadmap.md b/docs/roadmap.md index a7f4a86b7e..023688ddc4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -147,6 +147,8 @@ repaint at the existing 60-second autosave deadline, even with no mouse or keybo Clean and already-snapshotted documents do not schedule recovery wakeups. Snapshot contents, encryption and storage-write retry behavior are unchanged; this does not resolve #1803. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects archived findings and a dated feature mapping. Planning remains in this roadmap and the parity checklist. + ## Alpha gate The alpha gate: Acrobat Pro's everyday core workflows must each work end to end on the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3523db9eed..a0ea857f1a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -9,6 +9,8 @@ interface is copied. Gaps ranked in [gaps.md](gaps.md). **UI/UX fidelity ≈ 55% ready** (estimated), **30–60 h** additional to the features total. +The [PDF workflow research reference](research/pdf-workflows/README.md) collects comparative source locators, interaction evidence and evaluation criteria. It does not change this checklist. + ## Measured - **184 registered commands** (`crates/engine/src/commands.rs`), every one in the menus, the From 431a4e68aecde25a67af1c9aa66c3759c5067e18 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:43 +0200 Subject: [PATCH 27/59] Test that moving an image keeps its marked-content sequence intact (#4248) * Test that moving an image keeps it inside its marked-content sequence translate_images changes the image's own cm and leaves its Do where it was, so a moved image stays in the BDC ... EMC sequence that tags it, whether that is a tagged-PDF element or an optional-content layer. Nothing pinned this: the image-move tests covered clips, transforms and repeated moves but not marked content. The test moves an image inside /P <> BDC and inside /OC /L0 BDC and checks that both sequences remain, the Do is still inside its own, and the other sequence gained no image. * Test image moves on a real optional-content layer --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/edit/src/tests.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cbea5c21d2..ecbb8b3645 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3514,7 +3514,10 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec { } } -/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2). +/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2), on a real optional +/// content layer: the catalog's `/OCProperties` declares object 7 as the only OCG and the page +/// resources' `/Properties` maps `/L0` to it, so `/OC /L0 BDC` marks a valid optional-content +/// member rather than a dangling name. fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } @@ -3534,12 +3537,13 @@ fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), Edit fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R] /D << /Order [7 0 R] >> >> >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> /Properties << /L0 7 0 R >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /OCG /Name (Layer 0) >>".into(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -3753,6 +3757,30 @@ fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { assert_eq!(caption, ["Caption"]); } +#[test] +fn moving_an_image_inside_marked_content_leaves_every_sequence_intact() { + // `translate_images` changes the image's own `cm`; its `Do` stays where it was, so a moved + // image stays in the sequence that tags it (an image on a layer sits in `/OC … BDC`). + for content in [ + "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + "/OC /L0 BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC", + ] { + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap(); + let after = images::page_images(&doc, 0).unwrap()[0].rect; + assert!(close(after, [before[0] + 10.0, before[1], before[2] + 10.0, before[3]]), "{content}: {after:?}"); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let count = |name: &str| ops.iter().filter(|op| op.is(name)).count(); + assert_eq!((count("BDC"), count("EMC")), (2, 2), "{content}: both sequences remain"); + let first = ops.iter().position(|op| op.is("BDC")).unwrap(); + let end = ops.iter().position(|op| op.is("EMC")).unwrap(); + let image = ops.iter().position(|op| op.is("Do")).unwrap(); + assert!(first < image && image < end, "{content}: the image is still inside its own sequence"); + assert!(!ops[end..].iter().any(|op| op.is("Do")), "{content}: the other sequence gained no image"); + } +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); From 7fd6860f4ad81d9cdbfc2d9d80488c837f26f3b0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:49 +0200 Subject: [PATCH 28/59] M13 batch: pin the command-line exit codes before any change (#6296) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- apps/pdfcraft-cli/tests/it/batch_exit.rs | 111 +++++++++++++++++++++++ apps/pdfcraft-cli/tests/it/main.rs | 1 + 2 files changed, 112 insertions(+) create mode 100644 apps/pdfcraft-cli/tests/it/batch_exit.rs diff --git a/apps/pdfcraft-cli/tests/it/batch_exit.rs b/apps/pdfcraft-cli/tests/it/batch_exit.rs new file mode 100644 index 0000000000..6dfb9f61e9 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/batch_exit.rs @@ -0,0 +1,111 @@ +//! The exit codes the CLI has today: `ExitCode::SUCCESS` (0) or `ExitCode::FAILURE` (1). +//! +//! Pinned here so that the exit-code contract is a visible, reviewed change when it comes: a +//! failed file, an outside-root path and a usage error all exit 1 today. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A fresh folder for one test, so the tests can run in parallel and leave nothing behind. +fn workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-exit-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `batch` with `args`, run from the working directory `dir`. +fn batch(dir: &Path, args: &[&str]) -> Output { + Command::new(BIN).current_dir(dir).arg("batch").args(args).output().unwrap() +} + +/// `run --script --report` from the working directory `dir`: one JSON document for the run. +fn run_report(dir: &Path, script: &Path) -> Output { + Command::new(BIN).current_dir(dir).args(["run", "--script"]).arg(script).arg("--report").output().unwrap() +} + +/// The one JSON document a command prints on stdout. +fn reported(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn batch_exits_0_when_every_file_succeeds() { + let dir = workdir("batch-ok"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + let output = batch(&dir, &["page.txt", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 0, "{report}"); + assert!(dir.join("out/page.pdf").exists(), "the converted page is written"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_exits_1_when_one_file_fails() { + let dir = workdir("batch-fail"); + std::fs::write(dir.join("bad.pdf"), "broken").unwrap(); + let output = batch(&dir, &["bad.pdf", "--steps", "convert", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let report = reported(&output); + assert_eq!(report["failed"], 1, "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_usage_error_exits_1_and_creates_nothing() { + let dir = workdir("batch-usage"); + std::fs::write(dir.join("page.txt"), "Exit code page").unwrap(); + // Baseline: a usage error exits 1 today. A later change to the exit-code contract will + // update this assertion on purpose. + let output = batch(&dir, &["page.txt", "--out-dir", "out"]); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "a usage error prints to stderr, not a report on stdout"); + assert!(!output.stderr.is_empty(), "a usage error says what is wrong"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn batch_outside_root_exits_1_and_creates_nothing() { + let dir = workdir("batch-outside-root"); + let output = Command::new(BIN) + .current_dir(&dir) + .args(["batch", "../outside.pdf", "--steps", "convert", "--out-dir", "out", "--root"]) + .arg(&dir) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("is outside the allowed directory"), "the input is refused by the root: {stderr}"); + assert!(output.stdout.is_empty(), "a refused batch prints no report"); + assert!(!dir.join("out").exists(), "a refused batch creates no output"); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn run_script_report_exits_0_when_ok_and_1_when_a_step_fails() { + let dir = workdir("run-report"); + let script = dir.join("steps.json"); + let blank = serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }); + let steps = serde_json::json!([blank.clone(), { "tool": "no_such_tool", "args": {} }]); + std::fs::write(&script, steps.to_string()).unwrap(); + + let failing = run_report(&dir, &script); + assert_eq!(failing.status.code(), Some(1), "{}", String::from_utf8_lossy(&failing.stderr)); + let report = reported(&failing); + assert_eq!(report["ok"], false, "{report}"); + assert_eq!(report["steps"][1]["status"], "error", "{report}"); + + std::fs::write(&script, serde_json::json!([blank]).to_string()).unwrap(); + let good = run_report(&dir, &script); + assert_eq!(good.status.code(), Some(0), "{}", String::from_utf8_lossy(&good.stderr)); + let report = reported(&good); + assert_eq!(report["ok"], true, "{report}"); + assert_eq!(report["steps"][0]["status"], "ok", "{report}"); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs index f0b87c97a7..30a6e25882 100644 --- a/apps/pdfcraft-cli/tests/it/main.rs +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -3,6 +3,7 @@ mod audit; mod automation_roots; mod batch; +mod batch_exit; mod check; mod edit; mod extract; From 5aff7c83db443352968d12bd1ad47b6fa9b28e52 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:10:55 +0200 Subject: [PATCH 29/59] M13 batch: test that failed folder runs leave no output or staging file (#6298) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/batch_failures.rs | 89 ++++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + 2 files changed, 90 insertions(+) create mode 100644 crates/automation/tests/it/batch_failures.rs diff --git a/crates/automation/tests/it/batch_failures.rs b/crates/automation/tests/it/batch_failures.rs new file mode 100644 index 0000000000..f993d85eab --- /dev/null +++ b/crates/automation/tests/it/batch_failures.rs @@ -0,0 +1,89 @@ +//! A batch run that fails a file publishes only the completed outputs, and a +//! publish the file system refuses publishes nothing. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use serde_json::{Value, json}; + +fn result(auto: &mut Automation, args: Value) -> Value { + match auto.call("doc_batch", &args).unwrap().remove(0) { + Content::Json(value) => value, + _ => panic!("expected a report"), + } +} + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +/// Sets a folder's permission bits. +#[cfg(unix)] +fn set_mode(path: &std::path::Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); +} + +/// Puts back the write permission the temporary folder needs before it is removed. +#[cfg(unix)] +struct RestoreMode(std::path::PathBuf); + +#[cfg(unix)] +impl Drop for RestoreMode { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); + } +} + +#[test] +fn failed_inputs_publish_only_completed_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + std::fs::write(dir.path().join("input/bad.pdf"), "not a pdf").unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "{report}"); + let bad = files.iter().find(|file| file["path"].as_str().is_some_and(|path| path.ends_with("bad.pdf"))).unwrap(); + assert_eq!(bad["status"], "error", "{report}"); + assert!(bad["output"].is_null(), "a failed file reports no output: {report}"); + let names: Vec = + std::fs::read_dir(dir.path().join("out")).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(); + // Publish stages its bytes under a dot-prefixed name in the output folder, so + // no entry carrying that prefix may survive the run. + for name in &names { + assert!(!name.starts_with('.'), "temporary name left behind: {name} ({names:?})"); + } + assert_eq!(names, vec!["good.pdf".to_string()], "only the completed output is published"); +} + +#[cfg(unix)] +#[test] +fn read_only_output_folder_publishes_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input/good.pdf"), pdf()).unwrap(); + let out = dir.path().join("out"); + std::fs::create_dir(&out).unwrap(); + set_mode(&out, 0o555); + let _restore = RestoreMode(out.clone()); + // A root process ignores the mode bits, and then the assertions below would + // pass without testing the refusal. Fail loudly instead of skipping. + assert!(std::fs::write(out.join("probe"), b"").is_err(), "run as a non-root user: the read-only output folder accepted a new file"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":false})); + assert_eq!(report["completed"], 0, "{report}"); + assert_eq!(report["failed"], 1, "{report}"); + let files = report["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{report}"); + // Publish cannot create its staging file in a folder it may not write, so + // the entry carries the system's error and no output. + assert_eq!(files[0]["status"], "error", "{report}"); + assert!(files[0]["output"].is_null(), "a refused publish reports no output: {report}"); + let error = files[0]["error"].as_str().unwrap(); + assert!(!error.is_empty(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "the refused publish writes nothing into the read-only folder: {report}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 3122fe5825..39f2b8a30d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -5,6 +5,7 @@ mod automation; mod automation_roots; mod backdrop; mod batch; +mod batch_failures; mod button_caption; mod form_bulk; mod hostile_args; From b1c1dbdad3d44b2517dafc418ba4690fe0b3f1fb Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:00 +0200 Subject: [PATCH 30/59] One known-answer case per implemented clause (#6301) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/tests/known_answers.rs | 403 ++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 crates/preflight/tests/known_answers.rs diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs new file mode 100644 index 0000000000..23d503dc62 --- /dev/null +++ b/crates/preflight/tests/known_answers.rs @@ -0,0 +1,403 @@ +//! What `verify` answers for each clause it implements, pinned as a known answer. +//! +//! Each test builds a small file by hand, from the shape the clause describes rather than from +//! anything this repository saves, so a bug shared between the verifier and the writer cannot make a +//! case pass. Two things in the file are not hand-written: the output intent profiles come from +//! `icc::srgb()`, the crate's own sRGB generator, and the encrypted document in the 6.1.3 case is +//! encrypted by the object layer, because writing an encryption dictionary by hand would restate the +//! rules under test. +//! +//! The clause strings are the verifier's own citations, pinned here as `lib.rs` writes them. They +//! have not been compared with the text of the profile yet, so a number that does not name the rule +//! it is attached to is a finding about the verifier and not about this file. +//! +//! The positive case expects no issue under the clause and the negative case expects one; both are +//! asserted by clause string, so a later change that alters an answer fails here first. Cases where +//! the verifier is known to disagree with the standard are left out on purpose and recorded as gaps +//! where the change that adds them is reviewed. Nothing here is a conformance claim. + +use pdfcraft_cos::{Algorithm, Document, NewEncryption}; +use pdfcraft_preflight::{Level, convert, declared, icc, verify}; + +/// A document from hand-written objects and extra trailer entries (copied from the crate's unit +/// tests). +fn build(objs: &[&str], trailer_extra: &str) -> Document { + 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 {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// The byte-order mark an XMP packet's `xpacket` instruction names. +const BOM: char = '\u{feff}'; + +/// The XMP packet identifying a file as `part`/`conformance`, with `title` as its `dc:title`. +fn xmp(part: u8, conformance: &str, title: Option<&str>) -> String { + let title = match title { + Some(t) => format!(" {t}\n"), + None => String::new(), + }; + format!( + r#" + + + + {part} + {conformance} +{title} + + +"# + ) +} + +/// The XMP packet of a PDF/A-2b file, with `title` as its title when one is given. +fn a2b_packet(title: Option<&str>) -> String { + xmp(2, "B", title) +} + +/// The catalog's `/Metadata` stream object holding `packet`. +fn xmp_object(packet: &str) -> String { + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{packet}\nendstream", packet.len()) +} + +/// A content stream object holding `content`. +fn content(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// An ASCII-hex stream object holding `bytes`, with `dict` as its other entries. +fn hex_stream(dict: &str, bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect(); + let data = format!("{hex}>"); + format!("<< /Filter /ASCIIHexDecode {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// A 128-byte ICC header for a four-component profile, with no `/N` so the header decides. +fn cmyk_profile_header() -> Vec { + let mut header = vec![0u8; 128]; + header[0..4].copy_from_slice(&128u32.to_be_bytes()); + header[16..20].copy_from_slice(b"CMYK"); + header[20..24].copy_from_slice(b"XYZ "); + header[36..40].copy_from_slice(b"acsp"); + header +} + +/// The catalog's `/OutputIntents`: a `GTS_PDFA1` intent naming the profile in object `n`. +fn output_intent(n: usize) -> String { + let condition = "/OutputConditionIdentifier (sRGB IEC61966-2.1)"; + let intent = format!("<< /Type /OutputIntent /S /GTS_PDFA1 {condition} /DestOutputProfile {n} 0 R >>"); + format!("/OutputIntents [{intent}]") +} + +/// An embedded font program stream object, compressed with `filter` when one is given. Its bytes +/// are not read by any rule: a font program is data, not content. +fn font_program(filter: Option<&str>) -> String { + let filter = match filter { + Some(filter) => format!("/Filter {filter} "), + None => String::new(), + }; + format!("<< /Length1 4 {filter}/Length 4 >>\nstream\nabcd\nendstream") +} + +/// An image XObject that asks to be interpolated, or does not. +fn image(interpolate: bool) -> String { + format!( + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate} /Length 1 >>\nstream\n\u{0}\nendstream" + ) +} + +/// A document whose catalog (object 1) carries `catalog_extra` and, when `packet` is given, the +/// metadata object 3; whose page tree (object 2) holds the single page `page` (object 4); and whose +/// objects 5 onwards are `more`, which `page` and `catalog_extra` refer to by number. An absent +/// `packet` leaves object 3 empty. The page must resolve, so a case that loses it fails instead of +/// passing because no rule ever reached it. +fn page_document(page: &str, packet: Option<&str>, catalog_extra: &str, more: &[&str], trailer_extra: &str) -> Document { + let metadata = match packet { + Some(packet) => xmp_object(packet), + None => "null".to_owned(), + }; + let catalog = match packet { + Some(_) => format!("<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R {catalog_extra} >>"), + None => format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + }; + let pages = "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>"; + let mut objs = vec![catalog.as_str(), pages, metadata.as_str(), page]; + objs.extend_from_slice(more); + let doc = build(&objs, trailer_extra); + assert_eq!(pdfcraft_model::pages(&doc).len(), 1, "the page of this case does not resolve"); + doc +} + +/// Asserts that `verify` reports `clause` for `doc`. +fn assert_reported(doc: &Document, level: Level, clause: &str) { + let issues = verify(doc, level); + assert!(issues.iter().any(|i| i.clause == clause), "no {clause} issue in {issues:#?}"); +} + +/// Asserts that `verify` reports nothing under `clause` for `doc`. +fn assert_not_reported(doc: &Document, level: Level, clause: &str) { + let found: Vec<_> = verify(doc, level).into_iter().filter(|i| i.clause == clause).collect(); + assert!(found.is_empty(), "{clause} was reported: {found:#?}"); +} + +#[test] +fn xmp_metadata_is_required_clause_6_6_2_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let identified = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&identified, Level::A2b, "6.6.2.1"); + assert_eq!(declared(&identified).pdfa, Some((2, "B".into()))); + let bare = page_document(page, None, "", &[], ""); + assert_reported(&bare, Level::A2b, "6.6.2.1"); +} + +#[test] +fn the_declared_part_and_conformance_must_be_the_level_clause_6_6_4() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let matching = a2b_packet(None); + let doc = page_document(page, Some(matching.as_str()), "", &[], ""); + assert_not_reported(&doc, Level::A2b, "6.6.4"); + for (part, conformance) in [(3, "B"), (2, "A")] { + let packet = xmp(part, conformance, None); + let doc = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_reported(&doc, Level::A2b, "6.6.4"); + assert_eq!(declared(&doc).pdfa, Some((part, conformance.to_owned()))); + } +} + +#[test] +fn the_xmp_title_must_match_the_information_dictionary_clause_6_6_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let info = "/Info << /Title (Report) >>"; + let matching = a2b_packet(Some("Report")); + let doc = page_document(page, Some(matching.as_str()), "", &[], info); + assert_not_reported(&doc, Level::A2b, "6.6.3"); + let differing = a2b_packet(Some("Draft")); + let doc = page_document(page, Some(differing.as_str()), "", &[], info); + assert_reported(&doc, Level::A2b, "6.6.3"); +} + +#[test] +fn the_trailer_must_not_name_encryption_clause_6_1_3() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.1.3"); + // An encrypted file's keys are not written out by hand here, so the /Encrypt dictionary comes + // from the object layer; the rule under test only asks whether the trailer names one. + let mut encrypted = page_document(page, Some(packet.as_str()), "", &[], ""); + let params = NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [7; 32], + }; + encrypted.set_encryption(¶ms).unwrap(); + assert_reported(&encrypted, Level::A2b, "6.1.3"); + let report = convert(&mut encrypted, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.1.3"), "{:#?}", report.remaining); +} + +#[test] +fn device_colour_needs_an_output_intent_clause_6_2_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let doc = page_document(page, Some(packet.as_str()), "", &[rgb.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.3"); + let none = content("q Q"); + let doc = page_document(page, Some(packet.as_str()), "", &[none.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.3"); +} + +#[test] +fn device_rgb_needs_an_rgb_output_intent_clause_6_2_4_3() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << >> >>"; + let packet = a2b_packet(None); + let rgb = content("1 0 0 rg 10 10 20 20 re f"); + let srgb = hex_stream("/N 3", &icc::srgb()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), srgb.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.4.3"); + // The intent is there, so the neighbour rule about device colour without one stays quiet. + assert_not_reported(&doc, Level::A2b, "6.2.3"); + let cmyk = hex_stream("", &cmyk_profile_header()); + let doc = page_document(page, Some(packet.as_str()), &output_intent(6), &[rgb.as_str(), cmyk.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.4.3"); +} + +#[test] +fn the_catalog_must_not_have_additional_actions_clause_6_5_2() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.2"); + let acting = page_document(page, Some(packet.as_str()), "/AA << /WC << /S /GoTo >> >>", &[], ""); + assert_reported(&acting, Level::A2b, "6.5.2"); +} + +#[test] +fn the_catalog_must_not_have_document_javascript_clause_6_5_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.5.1"); + // An empty name tree is enough: an action object here would report the same clause, and this + // case is about the catalog's own entry. + let tree = "/Names << /JavaScript << /Names [] >> >>"; + let scripted = page_document(page, Some(packet.as_str()), tree, &[], ""); + assert_reported(&scripted, Level::A2b, "6.5.1"); +} + +#[test] +fn the_form_must_not_ask_viewers_for_appearances_clause_6_4_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let plain = page_document(page, Some(packet.as_str()), "/AcroForm << /Fields [] >>", &[], ""); + assert_not_reported(&plain, Level::A2b, "6.4.1"); + let asking = "/AcroForm << /Fields [] /NeedAppearances true >>"; + let doc = page_document(page, Some(packet.as_str()), asking, &[], ""); + assert_reported(&doc, Level::A2b, "6.4.1"); +} + +#[test] +fn forbidden_annotation_types_are_reported_clause_6_3_1() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let link = "<< /Type /Annot /Subtype /Link /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[link], ""); + assert_not_reported(&doc, Level::A2b, "6.3.1"); + let sound = "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[sound], ""); + assert_reported(&doc, Level::A2b, "6.3.1"); +} + +#[test] +fn annotations_must_print_and_not_hide_clause_6_3_2() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let printing = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[printing, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.2"); + let unprinted = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 0 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[unprinted, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); + let hidden = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 6 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[hidden, appearance], ""); + assert_reported(&doc, Level::A2b, "6.3.2"); +} + +#[test] +fn annotations_need_an_appearance_stream_clause_6_3_3() { + let page = "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>"; + let packet = a2b_packet(None); + let appearance = "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream"; + let with = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 /AP << /N 6 0 R >> >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[with, appearance], ""); + assert_not_reported(&doc, Level::A2b, "6.3.3"); + let without = "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] /P 4 0 R /F 4 >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[without], ""); + assert_reported(&doc, Level::A2b, "6.3.3"); +} + +#[test] +fn lzw_compressed_streams_are_reported_clause_6_1_7_2() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let text = content("BT /F1 12 Tf 10 10 Td (Hi) Tj ET"); + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FirstChar 72 /LastChar 105 /Widths [500 500] /FontDescriptor 7 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 8 0 R >>"; + let flate = font_program(Some("/FlateDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, flate.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.2"); + // The neighbour rule holds: the font still reads as embedded. + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let lzw = font_program(Some("/LZWDecode")); + let doc = page_document(page, Some(packet.as_str()), "", &[text.as_str(), font, descriptor, lzw.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.1.7.2"); +} + +#[test] +fn streams_must_not_read_external_files_clause_6_1_7_1() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(None); + let held = "<< /Length 4 >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[held], ""); + assert_not_reported(&doc, Level::A2b, "6.1.7.1"); + // A stream object nothing refers to: the rule looks at every stream in the file. + let external = "<< /Length 4 /F (data.bin) >>\nstream\nabcd\nendstream"; + let doc = page_document(page, Some(packet.as_str()), "", &[external], ""); + assert_reported(&doc, Level::A2b, "6.1.7.1"); +} + +#[test] +fn images_must_not_be_interpolated_clause_6_2_8() { + let page = "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q 10 0 0 10 0 0 cm /Im0 Do Q"); + let smooth = image(true); + let doc = page_document(page, Some(packet.as_str()), "", &[smooth.as_str(), drawing.as_str()], ""); + assert_reported(&doc, Level::A2b, "6.2.8"); + let crisp = image(false); + let doc = page_document(page, Some(packet.as_str()), "", &[crisp.as_str(), drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.8"); +} + +#[test] +fn postscript_xobjects_are_reported_clause_6_2_9() { + let plain = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>"; + let with = "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /PS0 6 0 R >> >> >>"; + let packet = a2b_packet(None); + let drawing = content("q Q"); + let doc = page_document(plain, Some(packet.as_str()), "", &[drawing.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.9"); + let postscript = "<< /Type /XObject /Subtype /PS /Length 0 >>\nstream\n\nendstream"; + let doc = page_document(with, Some(packet.as_str()), "", &[drawing.as_str(), postscript], ""); + assert_reported(&doc, Level::A2b, "6.2.9"); +} + +#[test] +fn fonts_must_be_embedded_clause_6_2_11_4() { + let page = "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> >>"; + let packet = a2b_packet(None); + let embedded = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>"; + let descriptor = "<< /Type /FontDescriptor /FontName /Sans /Flags 32 /FontFile2 7 0 R >>"; + let program = font_program(None); + let doc = page_document(page, Some(packet.as_str()), "", &[embedded, descriptor, program.as_str()], ""); + assert_not_reported(&doc, Level::A2b, "6.2.11.4"); + let missing = "<< /Type /Font /Subtype /TrueType /BaseFont /Sans >>"; + let doc = page_document(page, Some(packet.as_str()), "", &[missing], ""); + assert_reported(&doc, Level::A2b, "6.2.11.4"); +} + +/// A file built by hand, apart from its output profile, with everything the verifier asks for: an +/// XMP packet naming PDF/A-2b with a title that matches the document information, an sRGB output +/// intent and a blank page. It checks that the rules agree with each other, which is not by itself a +/// conformance claim. +#[test] +fn minimal_document_has_no_verifier_issues() { + let page = "<< /Type /Page /Parent 2 0 R >>"; + let packet = a2b_packet(Some("Report")); + let profile = hex_stream("/N 3", &icc::srgb()); + let info = "/Info << /Title (Report) >>"; + let mut doc = page_document(page, Some(packet.as_str()), &output_intent(5), &[profile.as_str()], info); + let issues = verify(&doc, Level::A2b); + assert!(issues.is_empty(), "{issues:#?}"); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"]); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.remaining.is_empty(), "{:#?}", report.remaining); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); +} From 9ff881e533be68ae1a9f8d556dea79d6df39a30a Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:05 +0200 Subject: [PATCH 31/59] The PDF/A panel reports its own checks, not conformance (#6302) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/ui-egui/src/dialogs/standards_ui.rs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/standards_ui.rs b/crates/ui-egui/src/dialogs/standards_ui.rs index 11c0d1fd50..de6052b05c 100644 --- a/crates/ui-egui/src/dialogs/standards_ui.rs +++ b/crates/ui-egui/src/dialogs/standards_ui.rs @@ -35,7 +35,10 @@ impl PdfCraftApp { self.pdfa_verify(); let left = self.pdfa.issues.as_ref().map_or(0, Vec::len); self.notify(if left == 0 { - crate::i18n::fmt(tl!("The document now conforms to {level}; save it to keep the changes"), &[("level", level.label())]) + crate::i18n::fmt( + tl!("PdfCraft's checks now find no problems for {level}. This is not a validator result. Save to keep the changes."), + &[("level", level.label())], + ) } else if left == 1 { crate::i18n::fmt(tl!("Fixed what could be fixed; 1 problem remains"), &[]) } else { @@ -91,7 +94,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.label(egui::RichText::new(tl!("Verify to see what the document needs.")).color(t.text_muted)); } Some(v) if v.is_empty() => { - ui.label(crate::i18n::fmt(tl!("No problems found: the document conforms to {level}."), &[("level", app.pdfa.level.label())])); + ui.label(crate::i18n::fmt( + tl!("PdfCraft's checks found no problems for {level}. This is not a validator result."), + &[("level", app.pdfa.level.label())], + )); } Some(v) => { for i in v { @@ -129,3 +135,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool } close } + +#[cfg(test)] +mod wording_tests { + /// The Standards panel reports what PdfCraft's own checks found; only a run of an external + /// validator can state the stronger thing, and none is recorded here. + #[test] + fn no_standards_panel_wording_claims_conformance() { + let source = include_str!("standards_ui.rs"); + // Built in pieces so that this file does not match itself. + for phrase in [concat!("conform", "s to"), concat!("now ", "conforms")] { + assert!(!source.contains(phrase), "a standards message makes a claim only a validator run can make: {phrase}"); + } + } +} From 0d7ae1b0fc1fffa6d792193cddecff2920e03c46 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:10 +0200 Subject: [PATCH 32/59] Tests: a signed encrypted document's byte ranges cover its earlier revision (#6303) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/sign/tests/it/encrypted_signing.rs | 157 ++++++++++++++++++++++ crates/sign/tests/it/main.rs | 1 + 2 files changed, 158 insertions(+) create mode 100644 crates/sign/tests/it/encrypted_signing.rs diff --git a/crates/sign/tests/it/encrypted_signing.rs b/crates/sign/tests/it/encrypted_signing.rs new file mode 100644 index 0000000000..f1887ff358 --- /dev/null +++ b/crates/sign/tests/it/encrypted_signing.rs @@ -0,0 +1,157 @@ +//! The byte ranges of a signature on an encrypted document. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions}; +use pdfcraft_sign::{SignOptions, Status, TrustStore, pkcs12, signatures}; + +fn data(name: &str) -> Vec { + std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +/// One page with text and an unsigned signature field "Approval". +fn fixture() -> Vec { + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", + ]; + objs.extend_from_slice(more); + write_pdf(&objs) +} + +/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog. +fn write_pdf(objs: &[&[u8]]) -> 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(b: &[u8]) -> Document { + Document::open(Arc::new(b.to_vec())).unwrap() +} + +fn opts() -> SignOptions { + SignOptions { + page: 0, + rect: Some([20.0, 100.0, 220.0, 150.0]), + reason: Some("I approve this document".into()), + location: Some("London".into()), + date: "D:20261002120000+01'00'".into(), + ..SignOptions::default() + } +} + +/// `fixture()` protected with an empty user password (opens without asking, like most +/// "secured" forms) and an owner password, then saved. +fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec { + protected_with(alg, "", permissions) +} + +/// `fixture()` protected with the user password `user` and an owner password, then saved. +fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec { + let mut doc = open(&fixture()); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn open_pw(b: &[u8], pw: Option<&str>) -> Document { + Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap() +} + +const ALL_ALGORITHMS: [pdfcraft_cos::Algorithm; 4] = + [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256]; + +/// Where `needle` first appears in `haystack`. +fn find(haystack: &[u8], needle: &[u8], alg: pdfcraft_cos::Algorithm) -> usize { + haystack + .windows(needle.len()) + .position(|w| w == needle) + .unwrap_or_else(|| panic!("{alg:?}: {:?} is not in the added bytes", String::from_utf8_lossy(needle))) +} + +/// The four numbers of the signature dictionary's `/ByteRange` in the added revision. +fn byte_range(added: &[u8], alg: pdfcraft_cos::Algorithm) -> [usize; 4] { + let prefix = b"/ByteRange ["; + let at = find(added, prefix, alg) + prefix.len(); + let end = at + added[at..].iter().position(|b| *b == b']').unwrap_or_else(|| panic!("{alg:?}: the /ByteRange array is not closed")); + let text = std::str::from_utf8(&added[at..end]).unwrap_or_else(|_| panic!("{alg:?}: the /ByteRange numbers are not ASCII")); + let nums: Vec = + text.split_ascii_whitespace().map(|n| n.parse().unwrap_or_else(|_| panic!("{alg:?}: {n:?} is not a byte offset"))).collect(); + assert_eq!(nums.len(), 4, "{alg:?}: /ByteRange has {} numbers, not 4", nums.len()); + [nums[0], nums[1], nums[2], nums[3]] +} + +/// The signature dictionary's `/ByteRange` gives the offsets and lengths of the parts of the +/// file its digest covers. For a signature on an encrypted document that is the whole earlier +/// revision, its encrypted objects included, leaving out only the hex text of `/Contents`. +#[test] +fn signed_encrypted_documents_cover_their_earlier_revision() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for alg in ALL_ALGORITHMS { + let base = protected(alg, -1); + let doc = open_pw(&base, None); + let signed = pdfcraft_sign::sign(&doc, &id, &opts()).unwrap(); + assert!(signed.starts_with(&base), "{alg:?}: an incremental update"); + let added = &signed[base.len()..]; + // The hole: the `<...>` hex string of `/Contents`. + let at = base.len() + find(added, b"/Contents <", alg); + let hole_start = at + b"/Contents ".len(); + let hole_end = hole_start + signed[hole_start..].iter().position(|b| *b == b'>').expect("a closed /Contents string") + 1; + let [a, b, c, d] = byte_range(added, alg); + assert_eq!(a, 0, "{alg:?}: the covered range starts at the start of the file"); + assert_eq!(b, hole_start, "{alg:?}: the covered range ends at {hole_start}, where /Contents begins"); + assert_eq!(c, hole_end, "{alg:?}: the second covered range starts at {hole_end}, where /Contents ends"); + assert_eq!(c + d, signed.len(), "{alg:?}: the covered range ends at the file's end ({c} + {d} of {})", signed.len()); + assert!(hole_start >= base.len(), "{alg:?}: the hole at {hole_start} comes after the whole earlier revision of {} bytes", base.len()); + assert_eq!(signed[hole_start], b'<', "{alg:?}: the hole opens with '<' at {hole_start}"); + assert_eq!(signed[hole_end - 1], b'>', "{alg:?}: the hole closes with '>' at {}", hole_end - 1); + assert!( + signed[hole_start + 1..hole_end - 1].iter().all(u8::is_ascii_hexdigit), + "{alg:?}: /Contents between {hole_start} and {hole_end} is hexadecimal" + ); + let sigs = signatures(&open_pw(&signed, None), &signed, &trust); + let s = sigs.iter().find(|s| s.signed).expect("a signed field"); + assert_eq!(s.status, Status::Valid, "{alg:?}: {:?}", s.details); + // One changed byte inside the covered range, in the earlier revision. + let mut tampered = signed.clone(); + let flip = base.len() - 1; + tampered[flip] ^= 1; + let sigs = signatures(&open_pw(&tampered, None), &tampered, &trust); + let signed_sigs: Vec<_> = sigs.iter().filter(|s| s.signed).collect(); + assert!(!signed_sigs.is_empty(), "{alg:?}: the tampered file still lists its signature"); + assert!( + signed_sigs.iter().all(|s| s.status == Status::Invalid), + "{alg:?}: the byte changed at {flip}, inside the covered range, makes every signature invalid" + ); + } +} diff --git a/crates/sign/tests/it/main.rs b/crates/sign/tests/it/main.rs index 7b8790850b..d526b01e7c 100644 --- a/crates/sign/tests/it/main.rs +++ b/crates/sign/tests/it/main.rs @@ -5,5 +5,6 @@ mod ber; mod chain; mod cms; mod crypto; +mod encrypted_signing; mod pdf; mod store_keys; From a2c6e63d0a4e8b461ecda2aebd7942bca5ca1c6f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:15 +0200 Subject: [PATCH 33/59] Fuzz target for the JavaScript engine's script runner (#6304) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/js/fuzz/Cargo.toml | 24 +++++++++++++++++++++ crates/js/fuzz/fuzz_targets/run_script.rs | 26 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 crates/js/fuzz/Cargo.toml create mode 100644 crates/js/fuzz/fuzz_targets/run_script.rs diff --git a/crates/js/fuzz/Cargo.toml b/crates/js/fuzz/Cargo.toml new file mode 100644 index 0000000000..373028192e --- /dev/null +++ b/crates/js/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pdfcraft-js-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run run_script` +# from crates/js/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-js = { path = ".." } + +[[bin]] +name = "run_script" +path = "fuzz_targets/run_script.rs" +test = false +doc = false +bench = false diff --git a/crates/js/fuzz/fuzz_targets/run_script.rs b/crates/js/fuzz/fuzz_targets/run_script.rs new file mode 100644 index 0000000000..57ccb9ea66 --- /dev/null +++ b/crates/js/fuzz/fuzz_targets/run_script.rs @@ -0,0 +1,26 @@ +//! Feeds arbitrary bytes, read as a script, to the JavaScript run entry with fixed event and document values. +//! +//! The run's result is discarded: the target only checks that no input panics or hangs past the limits. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// The largest script the target runs (64 KiB); larger inputs are skipped. +const MAX_INPUT: usize = 64 << 10; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let script = String::from_utf8_lossy(data).into_owned(); + let event = Event { + name: "Keystroke".into(), + kind: "Field".into(), + target: Some("field1".into()), + value: "value".into(), + change: String::new(), + will_commit: true, + }; + let _outcome = run(&script, &event, &DocInfo::default(), &[], &[], Limits::default()); +}); From c15c909745242013d81e9a7bcea451ff918dc7d7 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:21 +0200 Subject: [PATCH 34/59] Fuzz target for redaction apply (#6305) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/redact/fuzz/Cargo.toml | 25 ++++++++++++++++++++++++ crates/redact/fuzz/fuzz_targets/apply.rs | 24 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 crates/redact/fuzz/Cargo.toml create mode 100644 crates/redact/fuzz/fuzz_targets/apply.rs diff --git a/crates/redact/fuzz/Cargo.toml b/crates/redact/fuzz/Cargo.toml new file mode 100644 index 0000000000..716aa7715a --- /dev/null +++ b/crates/redact/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pdfcraft-redact-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run apply` +# from crates/redact/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-redact = { path = ".." } + +[[bin]] +name = "apply" +path = "fuzz_targets/apply.rs" +test = false +doc = false +bench = false diff --git a/crates/redact/fuzz/fuzz_targets/apply.rs b/crates/redact/fuzz/fuzz_targets/apply.rs new file mode 100644 index 0000000000..347e5ed120 --- /dev/null +++ b/crates/redact/fuzz/fuzz_targets/apply.rs @@ -0,0 +1,24 @@ +//! Feeds arbitrary bytes, read as a PDF, to the redaction marks and the apply entry. +//! +//! Results are discarded: the target checks that no input panics. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_redact::{apply, marks}; + +/// The largest input the target opens (16 MiB); larger inputs are skipped. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _count = marks(&doc).len(); + let _result = apply(&mut doc, None); +}); From 9a95273c7c7f4485503f2d534ca5899948768bef Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:27 +0200 Subject: [PATCH 35/59] Fuzz targets for PDF/A verify and convert (#6308) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/fuzz/Cargo.toml | 32 +++++++++++++++++++ crates/preflight/fuzz/fuzz_targets/convert.rs | 21 ++++++++++++ crates/preflight/fuzz/fuzz_targets/verify.rs | 22 +++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 crates/preflight/fuzz/Cargo.toml create mode 100644 crates/preflight/fuzz/fuzz_targets/convert.rs create mode 100644 crates/preflight/fuzz/fuzz_targets/verify.rs diff --git a/crates/preflight/fuzz/Cargo.toml b/crates/preflight/fuzz/Cargo.toml new file mode 100644 index 0000000000..0e9e77f105 --- /dev/null +++ b/crates/preflight/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "pdfcraft-preflight-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +# Detached from the PdfCraft workspace: run with `cargo +nightly fuzz run verify` +# from crates/preflight/. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +pdfcraft-cos = { path = "../../cos" } +pdfcraft-preflight = { path = ".." } + +[[bin]] +name = "verify" +path = "fuzz_targets/verify.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "convert" +path = "fuzz_targets/convert.rs" +test = false +doc = false +bench = false diff --git a/crates/preflight/fuzz/fuzz_targets/convert.rs b/crates/preflight/fuzz/fuzz_targets/convert.rs new file mode 100644 index 0000000000..6340bd585e --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/convert.rs @@ -0,0 +1,21 @@ +//! Feeds arbitrary bytes to `convert` at level A2b; the report it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, convert}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(mut doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _ = convert(&mut doc, Level::A2b); +}); diff --git a/crates/preflight/fuzz/fuzz_targets/verify.rs b/crates/preflight/fuzz/fuzz_targets/verify.rs new file mode 100644 index 0000000000..7d02aa3ca4 --- /dev/null +++ b/crates/preflight/fuzz/fuzz_targets/verify.rs @@ -0,0 +1,22 @@ +//! Feeds arbitrary bytes to `verify` at both levels; the issue list it returns is discarded. +#![no_main] + +use std::sync::Arc; + +use libfuzzer_sys::fuzz_target; +use pdfcraft_cos::Document; +use pdfcraft_preflight::{Level, verify}; + +/// Largest input the target parses; a longer input is dropped before `Document::open` copies it. +const MAX_INPUT: usize = 16 << 20; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let Ok(doc) = Document::open(Arc::new(data.to_vec())) else { + return; + }; + let _a2b = verify(&doc, Level::A2b); + let _a3b = verify(&doc, Level::A3b); +}); From 0c744357cb6e17bb5dfc5306eff7a58a2e652972 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:11:33 +0200 Subject: [PATCH 36/59] M13 batch: an action step that fails now fails its file (#6307) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/automation/tests/it/action_failures.rs | 66 +++++++++++++++++++ crates/automation/tests/it/main.rs | 1 + crates/engine/src/actions.rs | 8 +-- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 crates/automation/tests/it/action_failures.rs diff --git a/crates/automation/tests/it/action_failures.rs b/crates/automation/tests/it/action_failures.rs new file mode 100644 index 0000000000..b487103a21 --- /dev/null +++ b/crates/automation/tests/it/action_failures.rs @@ -0,0 +1,66 @@ +//! A step that fails fails its file: the file is reported with an error and nothing is written +//! for it. A step that finds nothing to do is still not a failure. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use pdfcraft_engine::actions::{Action, Step, run_on}; +use serde_json::json; + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +fn one_step(step: Step) -> Action { + Action { name: "Custom".into(), description: String::new(), steps: vec![step], builtin: false } +} + +/// The standard fonts can't draw the character, so the watermark step fails and no file is produced. +#[test] +fn failing_watermark_step_fails_the_file_and_publishes_nothing() { + let r = run_on(&one_step(Step::AddWatermark("中".into())), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_err(), "a failed step must fail its file: {r:?}"); +} + +/// A comment step on a document with no comments has nothing to do, which is not a failure. +#[test] +fn nothing_to_do_step_still_succeeds() { + let r = run_on(&one_step(Step::FlattenComments), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "a step with nothing to do is not a failure: {r:?}"); +} + +/// Through the tool: the failing file gets an error entry and no output, and the folder stays empty. +#[test] +fn action_run_marks_the_failing_file_as_error_without_output() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("in"); + std::fs::create_dir_all(&input).unwrap(); + let in_file = input.join("one.pdf"); + std::fs::write(&in_file, pdf()).unwrap(); + let out = dir.path().join("out"); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let reply = auto.call( + "action_run", + &json!({ + "paths": [in_file.to_string_lossy()], + "folder": out.to_string_lossy(), + "steps": [{ "step": "add_watermark", "arg": "中" }], + }), + ); + let report = match reply.unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected a report: {other:?}"), + }; + let entry = &report["files"][0]; + assert!(entry["error"].is_string(), "{report}"); + assert!(entry.get("output").is_none(), "{report}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing is published for a failed file"); +} + +/// A removal step with nothing to remove keeps its existing note and succeeds. +#[test] +fn removal_step_with_nothing_to_remove_still_succeeds() { + let r = run_on(&one_step(Step::RemoveHiddenInformation), "one.pdf", Arc::new(pdf()), |_, _| {}); + assert!(r.is_ok(), "nothing to remove is not a failure: {r:?}"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 39f2b8a30d..a3cd55623c 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod action_failures; mod attachment_key; mod automation; mod automation_roots; diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index 3e942f6a2f..249279e09d 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step - // that didn't remove is: the file would be passed on as cleaned with the data still in it. + // A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is + // published for it: the file would otherwise be passed on without the step it was asked to apply. let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), - Err(e) if removes => return Err(e), - Err(e) => log.push(format!("{}: {e}", step.label())), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())), + Err(e) => return Err(e), } Ok(None) } From ae893c1517d2d6281547c1bf902a403aa1094703 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:13:45 +0200 Subject: [PATCH 37/59] M13 batch: correct parity status and scope the no-replace sentence (#6299) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- docs/cli-batch.md | 4 +++- parity/acrobat-features.toml | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/cli-batch.md b/docs/cli-batch.md index ad2982bb14..dd0339cd12 100644 --- a/docs/cli-batch.md +++ b/docs/cli-batch.md @@ -42,12 +42,14 @@ only `.pdf` inputs are processed. Unsupported extensions are reported as skipped remaining files continue. Earlier successful outputs stay, and unprocessed files are listed as skipped. A file is published only after all its steps succeed. -Existing output names are never replaced, even by concurrent batch writers: +For `batch`, existing output names are never replaced, even by concurrent batch writers: `book.pdf`, `book (2).pdf`, and so on. A temporary file in the destination folder is published without overwriting. Sources are never moved or deleted. An output folder inside an input folder is excluded from discovery, including on reruns. The output folder cannot equal or contain a selected input folder. +The Action Wizard (`action_run`) and folder OCR (`ocr_recognize_files`) do not give this guarantee yet. + The report contains `completed`, `failed`, `skipped`, `stopped` and `files`. Each file records its status, output and step results, or the error/skip reason. The `batch` command exits nonzero on any file failure while still printing the diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9635898230..2e82a0c430 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7844,11 +7844,11 @@ title = "Optimize multiple files" area = "K" tier = "P2" milestone = "M13" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_batch"] +evidence = ["apps/pdfcraft-cli/tests/it/batch.rs::cli_batch_reports_partial_failure_and_continues_into_subfolders"] +notes = "Headless and CLI only: doc_batch with the optimize step (`pdfcraft-cli batch --steps optimize`, or `convert,optimize` as the cited test runs) over files and folders. No GUI batch dialog, no report file, no parallel workers, no resume. See docs/cli-batch.md." [[feature]] id = "optimize.preflight" @@ -9452,11 +9452,11 @@ title = "Run guided actions on files" area = "N" tier = "P1" milestone = "M13" -status = "shipped" +status = "partial" commands = ["actions.wizard"] tools = ["action_list", "action_run"] evidence = ["crates/ui-egui/tests/it/actions.rs::desktop_actions_preserve_duplicate_names_and_results_from_earlier_runs", "crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/it/automation.rs::actions_through_tools", "crates/ui-egui/tests/it/actions.rs::run_and_create_actions"] -notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress." +notes = "Desktop output filenames receive numbered suffixes when occupied, preserving every batch result and previous outputs. Pick an action and files; results go to a chosen folder, in the background with progress. Known gap: a failing non-removal step still publishes its output as success (crates/engine/src/actions.rs:200-208)." [[feature]] id = "misc.action-wizard-create" From f9c150c17c2f717e0795b0820b1d5d4fba79f660 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:24 +0200 Subject: [PATCH 38/59] Add IBAN matching to Search and Redact patterns (#4332) * Add IBAN matching to Search and Redact patterns The Pattern/PATTERNS/from_id interface is consumed generically by redact_mark and the UI pattern combobox through the engine re-exports, so one new variant and matcher exposes IBAN in Search and Redact everywhere. A unit test shows valid compact and spaced IBANs are marked while wrong check digits, short runs, unknown countries, letter check digits and token-glued IBANs are not. Add an Iban variant to Pattern, list it in PATTERNS, handle it in label, id and find, and add an iban matcher with a country-code length table, compact or group-of-four-spaced acceptance, alphanumeric start and end boundaries and ISO 7064 mod-97 check-digit validation. Test: iban_matches_compact_spaced_and_rejects_near_matches failed before the change and passes after. * Address review for pull request 4332 * Address review for pull request 4332 * review: address CodeRabbit findings - patterns.rs: fix SWIFT registry IBAN lengths EG 27->29, LY 27->25, SD 24->18 and add NI 28, BI 27, DJ 27, OM 23 (crates/redact/src/patterns.rs:545,549,551,558-561) - patterns.rs: state table source as SWIFT IBAN registry without claiming full ISO 13616 coverage (crates/redact/src/patterns.rs:475) - patterns.rs: regression test with valid registry examples for EG, LY, SD, NI, BI, DJ, OM and a wrong-length EG rejection (crates/redact/src/patterns.rs:892) * Complete the IBAN country table and accept spaces only between groups The country table now lists all 89 countries of the SWIFT IBAN registry (release 99) as one sorted constant. Honduras, Russia, Yemen, Falkland Islands, Mongolia and Somalia were missing. A space is accepted only after a full group of four characters, so a code followed by ordinary words ("BA37 leaves at noon") ends the run instead of being tried against the check digits. Tests: the registry example of every country matches compact and printed, changes to one digit are rejected, and the table and the examples agree. * Advertise the IBAN pattern in the redact_mark tool schema The pattern enum in the advertised schema was kept by hand and still listed phone, email, credit-card, ssn and date, so clients that obey the schema could not select iban, which the runtime already accepted. The enum is now built from the engine's pattern registry, so it cannot drift from it again, and the description names iban. Tests: tools/list advertises exactly the registry's pattern ids and names each in the description, and an iban mark made through the tool covers the spaced German sample and nothing else. --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/automation/src/tools.rs | 12 +- crates/automation/tests/it/automation.rs | 48 +++ crates/redact/src/patterns.rs | 358 ++++++++++++++++++++++- 3 files changed, 414 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bf2abea185..54d94ff4bf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; +use pdfcraft_engine::REDACT_PATTERNS; + use crate::ToolError; #[derive(Clone, Debug)] @@ -30,6 +32,12 @@ fn pages(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 1, "description": format!("1-based page numbers {what}.") }) } +/// The Search & Redact pattern ids, from the engine's registry, so the advertised schema cannot +/// drift from the patterns the runtime accepts. +fn pattern_enum() -> Value { + json!({ "type": "string", "enum": REDACT_PATTERNS.iter().map(|p| p.id()).collect::>() }) +} + fn path(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": format!("{what}: 1-based positions from the top level, e.g. [2, 1] = the first child of the second bookmark.") }) } @@ -605,7 +613,7 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ @@ -616,7 +624,7 @@ pub fn tools() -> Vec { "words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 }, "case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." }, "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, - "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, + "pattern": pattern_enum(), "whole_pages": { "type": "boolean" }, "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), "overlay": { "type": "string" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6f493b824d..5ed32360fd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -1715,6 +1715,25 @@ fn mcp_default_lists_every_tool_and_has_no_meta_tools() { assert_eq!(rpc(&mut s, 3, "tools/call", json!({ "name": "tool_search" }))["error"]["code"], -32602); } +#[cfg(feature = "mcp")] +#[test] +/// `tools/list` advertises exactly the Search & Redact patterns the registry supports, in `pattern` +/// and by name in the description, so a schema-obeying client can select every one of them. +fn the_advertised_redact_mark_schema_offers_every_pattern() { + use pdfcraft_engine::REDACT_PATTERNS; + let registry: Vec<&str> = REDACT_PATTERNS.iter().map(|p| p.id()).collect(); + let mut s = McpServer::new(Automation::new()); + let list = rpc(&mut s, 1, "tools/list", json!({})); + let mark = list["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "redact_mark").expect("redact_mark is listed"); + let advertised: Vec<&str> = + mark["inputSchema"]["properties"]["pattern"]["enum"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(advertised, registry, "the advertised pattern enum must be the registry's ids"); + let description = mark["description"].as_str().unwrap(); + for id in ®istry { + assert!(description.contains(id), "the description must name {id}: {description}"); + } +} + #[cfg(feature = "mcp")] #[test] fn mcp_compact_lists_the_core_tools_and_two_meta_tools() { @@ -4809,6 +4828,35 @@ fn redacting_italian_international_slash_numbers_through_tools() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +/// A client obeying the advertised schema can select iban, and the mark made through the tool +/// covers the spaced number and nothing else. +fn redacting_an_iban_through_tools() { + let table = tools(); + let advertised: Vec<&str> = table.iter().find(|t| t.name == "redact_mark").unwrap().input_schema["properties"]["pattern"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(advertised.contains(&"iban"), "the advertised redact_mark schema must offer iban: {advertised:?}"); + let dir = workdir("redact-iban"); + std::fs::write(dir.join("invoice.txt"), "Pay DE89 3704 0044 0532 0130 00 today\nAccount 12345678 is ours\nTotal due").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "invoice.txt" }))["doc"].as_u64().unwrap(); + let marked = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "iban" })); + assert_eq!(marked["marked"], 1, "the spaced German sample IBAN needs one mark"); + assert!(page_text(&mut a, doc)[0].contains("DE89 3704 0044 0532 0130 00"), "marks alone remove nothing"); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": doc }))["applied"], 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "redacted.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap(); + let text = &page_text(&mut a, reopened)[0]; + assert!(!text.contains("DE89") && !text.contains("0532"), "{text}"); + assert!(text.lines().any(|line| line.trim() == "Pay"), "only the label before the number remains: {text}"); + assert!(text.contains("Account 12345678") && text.contains("Total due"), "text without an IBAN remains: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + /// One page with the text "Secret 1234" and a Redact annotation that has neither a usable /// QuadPoints array nor a Rect, so there is no area to redact. fn redact_pdf_with_unreadable_mark() -> Vec { diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 59e623a7e7..9662602671 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -1,5 +1,5 @@ //! Search & Redact patterns (Acrobat's Find Text ▸ Patterns): phone numbers, email addresses, -//! credit card numbers, US Social Security numbers and dates. Each matcher works on a page's +//! credit card numbers, US Social Security numbers, dates and IBANs. Each matcher works on a page's //! text as characters and returns character ranges; callers map them to glyphs and areas. use std::ops::Range; @@ -11,9 +11,10 @@ pub enum Pattern { CreditCard, Ssn, Date, + Iban, } -pub const PATTERNS: [Pattern; 5] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date]; +pub const PATTERNS: [Pattern; 6] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date, Pattern::Iban]; impl Pattern { pub fn label(self) -> &'static str { @@ -23,6 +24,7 @@ impl Pattern { Pattern::CreditCard => "Credit Cards", Pattern::Ssn => "Social Security Numbers", Pattern::Date => "Dates", + Pattern::Iban => "IBAN Numbers", } } @@ -33,6 +35,7 @@ impl Pattern { Pattern::CreditCard => "credit-card", Pattern::Ssn => "ssn", Pattern::Date => "date", + Pattern::Iban => "iban", } } @@ -397,6 +400,173 @@ fn date(s: &[char], i: usize) -> Option { Some(k2 + y - i) } +/// An IBAN (ISO 13616): a two-letter country code, two check digits and the rest +/// of the country's exact length in letters and digits, either compact or in the +/// printed groups of four. A space is accepted only after a full group, so an +/// ordinary word after a code ("BA37 leaves at noon") ends the run. Valid iff the +/// number is one modulo 97 after moving the first four characters to the end. The +/// country code must be in the registry, and an alphanumeric character glued to +/// the end rejects the match. +fn iban(s: &[char], i: usize) -> Option { + if !(s.get(i).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 1).copied().is_some_and(|c| c.is_ascii_alphabetic()) + && s.get(i + 2).copied().is_some_and(|c| c.is_ascii_digit()) + && s.get(i + 3).copied().is_some_and(|c| c.is_ascii_digit())) + { + return None; + } + // Not the tail of a longer alphanumeric run. + if s.get(i.wrapping_sub(1)).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + let country: String = s.iter().skip(i).take(2).map(char::to_ascii_uppercase).collect(); + let len = iban_length(&country)?; + // The IBAN's characters uppercased, read up to the country's length. + let mut digits = Vec::with_capacity(len); + let mut j = i; + let mut after_space = false; + while digits.len() < len { + match s.get(j) { + Some(c) if c.is_ascii_alphanumeric() => { + digits.push(c.to_ascii_uppercase()); + after_space = false; + } + // A single space between two groups of four. + Some(&' ') if digits.len() % 4 == 0 && !after_space => after_space = true, + _ => return None, + } + j += 1; + } + // Reject an IBAN glued directly to trailing alphanumerics ("...00ABC"). + // A space-separated suffix ("...00 123") is not part of the IBAN. + if s.get(j).is_some_and(|c| c.is_alphanumeric()) { + return None; + } + iban_check(&digits).then_some(j - i) +} + +/// Whether `digits` is a valid IBAN: move the first four characters to the end, +/// replace letters by two digits (A = 10, ..., Z = 35), and the number must be +/// one modulo 97. +fn iban_check(digits: &[char]) -> bool { + let mut rem = 0u32; + for c in digits.iter().skip(4).chain(digits.iter().take(4)).copied() { + let value = iban_value(c); + // A letter stands for two decimal digits (A = 10, ..., Z = 35), + // a digit for one. + rem = if c.is_ascii_digit() { (rem * 10 + value) % 97 } else { (rem * 100 + value) % 97 }; + } + rem == 1 +} + +/// The value of an IBAN character: a digit is itself, a letter is 10 (A) to 35 (Z). +fn iban_value(c: char) -> u32 { + match c { + '0'..='9' => c as u32 - '0' as u32, + _ => c as u32 - 'A' as u32 + 10, + } +} + +/// The exact length of an IBAN for each country code of the SWIFT IBAN registry +/// (release 99, December 2024), counting letters and digits only. A country code +/// that is not listed has no IBANs, so nothing starting with it is matched. +const IBAN_LENGTHS: [(&str, usize); 89] = [ + ("AD", 24), + ("AE", 23), + ("AL", 28), + ("AT", 20), + ("AZ", 28), + ("BA", 20), + ("BE", 16), + ("BG", 22), + ("BH", 22), + ("BI", 27), + ("BR", 29), + ("BY", 28), + ("CH", 21), + ("CR", 22), + ("CY", 28), + ("CZ", 24), + ("DE", 22), + ("DJ", 27), + ("DK", 18), + ("DO", 28), + ("EE", 20), + ("EG", 29), + ("ES", 24), + ("FI", 18), + ("FK", 18), + ("FO", 18), + ("FR", 27), + ("GB", 22), + ("GE", 22), + ("GI", 23), + ("GL", 18), + ("GR", 27), + ("GT", 28), + ("HN", 28), + ("HR", 21), + ("HU", 28), + ("IE", 22), + ("IL", 23), + ("IQ", 23), + ("IS", 26), + ("IT", 27), + ("JO", 30), + ("KW", 30), + ("KZ", 20), + ("LB", 28), + ("LC", 32), + ("LI", 21), + ("LT", 20), + ("LU", 20), + ("LV", 21), + ("LY", 25), + ("MC", 27), + ("MD", 24), + ("ME", 22), + ("MK", 19), + ("MN", 20), + ("MR", 27), + ("MT", 31), + ("MU", 30), + ("NI", 28), + ("NL", 18), + ("NO", 15), + ("OM", 23), + ("PK", 24), + ("PL", 28), + ("PS", 29), + ("PT", 25), + ("QA", 29), + ("RO", 24), + ("RS", 22), + ("RU", 33), + ("SA", 24), + ("SC", 31), + ("SD", 18), + ("SE", 24), + ("SI", 19), + ("SK", 24), + ("SM", 27), + ("SO", 23), + ("ST", 25), + ("SV", 28), + ("TL", 23), + ("TN", 24), + ("TR", 26), + ("UA", 29), + ("VA", 22), + ("VG", 24), + ("XK", 20), + ("YE", 30), +]; + +/// The length of an IBAN of `country`, if the registry lists it. +fn iban_length(country: &str) -> Option { + IBAN_LENGTHS.iter().find(|(code, _)| *code == country).map(|&(_, len)| len) +} + /// Character ranges of `text` matching `pattern`. pub fn find(pattern: Pattern, text: &[char]) -> Vec> { let f: fn(&[char], usize) -> Option = match pattern { @@ -405,6 +575,7 @@ pub fn find(pattern: Pattern, text: &[char]) -> Vec> { Pattern::CreditCard => credit_card, Pattern::Ssn => ssn, Pattern::Date => date, + Pattern::Iban => iban, }; let mut out = Vec::new(); let mut i = 0; @@ -665,4 +836,187 @@ mod tests { let s: Vec = "\u{130}\u{131}I\u{15E}".chars().collect(); assert_eq!(s.iter().copied().map(fold).collect::(), "iii\u{15F}"); } + + #[test] + fn iban_matches_compact_spaced_and_rejects_near_matches() { + assert_eq!(Pattern::from_id("iban").map(Pattern::id), Some("iban")); + + // Spaced and compact IBANs match, a wrong check digit is rejected ("DE89 + // 3704 0044 0532 0130 00" and "DE89370400440532013000" match, "...01" + // does not). + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Spaced IBAN (valid German IBAN: DE89 3704 0044 0532 0130 00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00"), vec!["DE89 3704 0044 0532 0130 00"]); + // Compact IBAN (same number, no spaces) + assert_eq!(found(iban_pattern, "DE89370400440532013000"), vec!["DE89370400440532013000"]); + // Wrong check digit should not match (DE89...01 instead of DE89...00) + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 01"), Vec::::new()); + // Near match: IBAN followed by extra digits should match only the IBAN + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_rejects_unknown_country_code_wrong_length_and_glued_tail() { + if let Some(iban_pattern) = Pattern::from_id("iban") { + // Unknown country code: the check digits pass mod-97, but ISO 13616 defines no length for XX. + assert_eq!(found(iban_pattern, "XX65000000000000000000"), Vec::::new()); + // DE is 22 characters long. This 15-character number passes mod-97 but has + // the wrong national length, so it must be rejected. + assert_eq!(found(iban_pattern, "DE8300000000000"), Vec::::new()); + // A valid IBAN glued directly to trailing alphanumerics is not an IBAN. + assert_eq!(found(iban_pattern, "DE89370400440532013000ABC"), Vec::::new()); + assert_eq!(found(iban_pattern, "ABCDE89370400440532013000"), Vec::::new()); + // Letters in place of the two check digits. + assert_eq!(found(iban_pattern, "DEAB 3704 0044 0532 0130 00"), Vec::::new()); + // A space-separated suffix does not glue to the IBAN. + assert_eq!(found(iban_pattern, "DE89 3704 0044 0532 0130 00 123"), vec!["DE89 3704 0044 0532 0130 00"]); + } + } + + #[test] + fn iban_accepts_spaces_only_between_groups_of_four() { + // A code followed by ordinary words: the words end the run, so nothing is marked. + for s in [ + "Flight BA37 leaves at noon from gate four", + "Flight BA30 leaves at noon", + "Gate BA45 leaves at noon", + "BA72 have water are word", + "GB00 their an her make that", + "SI30 write can all more", + ] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // A valid IBAN with a space in the wrong place, or two spaces, is not marked. + for s in ["DE89 3704 0044 0532 013 000", "DE89 3704 0044 0532 0 13000", "DE8 9370 4004 4053 2013 000", "DE89 3704 0044 0532 0130 00"] { + assert_eq!(found(Pattern::Iban, s), Vec::::new(), "{s}"); + } + // Any group boundary may be a space or not, and the IBAN inside a sentence is marked alone. + assert_eq!(found(Pattern::Iban, "DE893704 0044 0532013000"), vec!["DE893704 0044 0532013000"]); + assert_eq!(found(Pattern::Iban, "Pay to DE89 3704 0044 0532 0130 00 today"), vec!["DE89 3704 0044 0532 0130 00"]); + } + + /// One example IBAN per country of the SWIFT IBAN registry (release 99). + const IBAN_REGISTRY_EXAMPLES: [&str; IBAN_LENGTHS.len()] = [ + "AD1200012030200359100100", + "AE070331234567890123456", + "AL47212110090000000235698741", + "AT611904300234573201", + "AZ21NABZ00000000137010001944", + "BA391290079401028494", + "BE68539007547034", + "BG80BNBG96611020345678", + "BH67BMAG00001299123456", + "BI4210000100010000332045181", + "BR1800360305000010009795493C1", + "BY13NBRB3600900000002Z00AB00", + "CH9300762011623852957", + "CR05015202001026284066", + "CY17002001280000001200527600", + "CZ6508000000192000145399", + "DE89370400440532013000", + "DJ2100010000000154000100186", + "DK5000400440116243", + "DO28BAGR00000001212453611324", + "EE382200221020145685", + "EG380019000500000000263180002", + "ES9121000418450200051332", + "FI2112345600000785", + "FK88SC123456789012", + "FO6264600001631634", + "FR1420041010050500013M02606", + "GB29NWBK60161331926819", + "GE29NB0000000101904917", + "GI75NWBK000000007099453", + "GL8964710001000206", + "GR1601101250000000012300695", + "GT82TRAJ01020000001210029690", + "HN88CABF00000000000250005469", + "HR1210010051863000160", + "HU42117730161111101800000000", + "IE29AIBK93115212345678", + "IL620108000000099999999", + "IQ98NBIQ850123456789012", + "IS140159260076545510730339", + "IT60X0542811101000000123456", + "JO94CBJO0010000000000131000302", + "KW81CBKU0000000000001234560101", + "KZ86125KZT5004100100", + "LB62099900000001001901229114", + "LC55HEMM000100010012001200023015", + "LI21088100002324013AA", + "LT121000011101001000", + "LU280019400644750000", + "LV80BANK0000435195001", + "LY83002048000020100120361", + "MC5811222000010123456789030", + "MD24AG000225100013104168", + "ME25505000012345678951", + "MK07250120000058984", + "MN121234123456789123", + "MR1300020001010000123456753", + "MT84MALT011000012345MTLCAST001S", + "MU17BOMM0101101030300200000MUR", + "NI45BAPR00000013000003558124", + "NL91ABNA0417164300", + "NO9386011117947", + "OM810180000001299123456", + "PK36SCBL0000001123456702", + "PL61109010140000071219812874", + "PS92PALS000000000400123456702", + "PT50000201231234567890154", + "QA58DOHB00001234567890ABCDEFG", + "RO49AAAA1B31007593840000", + "RS35260005601001611379", + "RU0304452522540817810538091310419", + "SA0380000000608010167519", + "SC18SSCB11010000000000001497USD", + "SD2129010501234001", + "SE4550000000058398257466", + "SI56263300012039086", + "SK3112000000198742637541", + "SM86U0322509800000000270100", + "SO211000001001000100141", + "ST68000100010051845310112", + "SV62CENR00000000000000700025", + "TL380080012345678910157", + "TN5910006035183598478831", + "TR330006100519786457841326", + "UA213223130000026007233566001", + "VA59001123000012345678", + "VG96VPVG0000012345678901", + "XK051212012345678906", + "YE15CBYE0001018861234567891234", + ]; + + /// `iban` in the printed form: groups of four separated by single spaces. + fn printed(iban: &str) -> String { + iban.chars().collect::>().chunks(4).map(|group| group.iter().collect::()).collect::>().join(" ") + } + + #[test] + fn iban_registry_examples_cover_every_country_and_length() { + let from_examples: Vec<(&str, usize)> = IBAN_REGISTRY_EXAMPLES.iter().map(|e| (&e[..2], e.len())).collect(); + assert_eq!(from_examples.as_slice(), IBAN_LENGTHS.as_slice()); + } + + #[test] + fn iban_matches_the_registry_example_of_every_country() { + for example in IBAN_REGISTRY_EXAMPLES { + assert_eq!(found(Pattern::Iban, example), vec![example], "compact {example}"); + let spaced = printed(example); + assert_eq!(found(Pattern::Iban, &spaced), vec![spaced.clone()], "printed {spaced}"); + } + } + + #[test] + fn iban_rejects_a_registry_example_with_one_digit_changed() { + for example in IBAN_REGISTRY_EXAMPLES { + // The last digit becomes the next digit, so the country, the length and the layout stay the same. + let Some(at) = example.rfind(|c: char| c.is_ascii_digit()) else { continue }; + let digit = example[at..=at].parse::().unwrap_or(0); + let changed = format!("{}{}{}", &example[..at], (digit + 1) % 10, &example[at + 1..]); + assert_eq!(found(Pattern::Iban, &changed), Vec::::new(), "{changed}"); + } + } } From b3a90f07f3986f63cc2b19a986f7eb81de86958f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:34 +0200 Subject: [PATCH 39/59] Parity: titles and the PDF/X row say only what is implemented (#6300) * Parity: titles and the PDF/X row say only what is implemented * Parity: the PDF/X row says removal matches the pdfxid: prefix only Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/pdf-spec-parity.md | 5 +++-- parity/acrobat-features.toml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index aefc959698..e25c30067e 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -2,7 +2,7 @@ > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -121,7 +121,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | PDF/A-1a/b (ISO 19005-1) | verify, convert | none | | | PDF/A-2a/b/u, PDF/A-3a/b/u | verify, convert | **partial**: 2b and 3b structural rules and conversion (sRGB intent; not CMYK) | | | PDF/A-4, 4e, 4f | verify, convert | none | | -| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | none | | +| PDF/X-1a, X-3, X-4 (ISO 15930) | verify, convert | **partial**: PDF/X declarations are carried through Combine and can be removed (`standards_remove_id`, which matches the `pdfxid:` prefix only); no verifier, no converter | | | PDF/UA-1, PDF/UA-2 (ISO 14289) | verify (Preflight), checker | Accessibility Checker (32 rules, Acrobat's own), no PDF/UA verification | | | PDF/E, PDF/VT | verify, convert | none | | | Preflight (profiles, fixups, reports) | hundreds of profiles | none | | @@ -132,6 +132,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Subset standards: the PDF/A rows name 2b and 3b only, and the PDF/X row says what is there — the identification is carried through Combine and can be removed; no verifier, no converter. No feature status changed | | 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | | 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | | 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e82a0c430..f71546b731 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7996,7 +7996,7 @@ notes = "" [[feature]] id = "optimize.pdfa-validate" -title = "Verify PDF/A compliance (1-4)" +title = "Verify PDF/A compliance (2b and 3b so far; 1, 2a, 2u, 3a, 3u, 4 not yet)" area = "K" tier = "P1" milestone = "M11" @@ -8008,7 +8008,7 @@ notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent [[feature]] id = "optimize.pdfa-convert" -title = "Save as PDF/A (1b, 2b, 3b, 4)" +title = "Save as PDF/A (2b and 3b so far)" area = "K" tier = "P1" milestone = "M11" From bb9da1c1c2b65c8cc80fbb3f1ebc282e33da5412 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:39 +0200 Subject: [PATCH 40/59] Convert replaces a removed forbidden annotation, so it is not reported again (#6306) Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/preflight/src/lib.rs | 3 + crates/preflight/tests/convert_annotations.rs | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 crates/preflight/tests/convert_annotations.rs diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1fb7272b33..5fdec67fac 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -877,6 +877,9 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Document { + 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 {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(std::sync::Arc::new(out)).unwrap() +} + +/// One page whose `/Annots` lists a Sound annotation (forbidden by clause 6.3.1) and a link. +fn page_with_a_forbidden_sound_annotation() -> Document { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Sound /Rect [10 10 30 30] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Link /Rect [40 40 60 60] /P 3 0 R /F 4 /Border [0 0 0] >>", + ], + "", + ) +} + +/// The forbidden annotation is reported before `convert` and by neither `convert` nor a later +/// `verify` afterwards: the object it took off the page is no longer an annotation. +#[test] +fn convert_removes_the_forbidden_annotation_clause_6_3_1() { + let mut doc = page_with_a_forbidden_sound_annotation(); + let before = verify(&doc, Level::A2b); + assert!(before.iter().any(|i| i.clause == "6.3.1"), "the fixture starts with the finding: {before:#?}"); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.remaining.iter().any(|i| i.clause == "6.3.1"), "6.3.1 remains after convert: {:#?}", report.remaining); + let after = verify(&doc, Level::A2b); + assert!(!after.iter().any(|i| i.clause == "6.3.1"), "6.3.1 is reported after convert: {after:#?}"); +} + +/// The page keeps its other annotation: only the forbidden one leaves `/Annots`. +#[test] +fn convert_keeps_the_other_annotations_on_the_page() { + let mut doc = page_with_a_forbidden_sound_annotation(); + convert(&mut doc, Level::A2b).unwrap(); + let page = doc.get(ObjRef::new(3, 0)); + let dict = page.as_dict().expect("the page is a dictionary"); + let annots = doc.resolve(dict.get(b"Annots").expect("the page has /Annots")); + let kept: Vec = annots.as_array().expect("the page's /Annots is an array").iter().filter_map(Object::as_ref).collect(); + assert_eq!(kept, vec![ObjRef::new(5, 0)]); +} From ee39c7e2d01787c70c521c2a7392790498606cf0 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:32:44 +0200 Subject: [PATCH 41/59] Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision (#6309) * Issue #5081: assembly at revision 3 and later needs bit 11; high-quality print, accessibility and form fill follow the revision * Permissions: at revision 2, bits 11 and 12 mean nothing Table 22 defines them for revision 3 and later. A revision 2 file with bit 4 clear and bit 11 set was given page changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/crypt/src/lib.rs | 26 +++- crates/crypt/src/tests.rs | 6 +- crates/crypt/tests/it/main.rs | 1 + crates/crypt/tests/it/permissions_revision.rs | 126 ++++++++++++++++++ crates/engine/src/document.rs | 2 +- crates/engine/src/tests.rs | 4 +- 6 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 crates/crypt/tests/it/permissions_revision.rs diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index ff92d9af66..e697bc8553 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -82,17 +82,24 @@ pub enum Auth { User, } -/// Permission bits from `/P` (§7.6.4.2, Table 22). Owners have every permission. +/// Permission flags from `/P`, read the way the handler's revision reads them (ISO 32000-1:2008, +/// Table 22). Owners have every permission. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions { pub bits: i32, pub owner: bool, + /// The `/R` value of the handler that read `bits`. + pub revision: i64, } impl Permissions { fn bit(&self, n: u32) -> bool { self.owner || (self.bits >> (n - 1)) & 1 == 1 } + /// Revision 3 and later read the full flag list of Table 22; revision 2 has its own. + fn is_revision_3_or_later(&self) -> bool { + self.revision >= 3 + } pub fn print(&self) -> bool { self.bit(3) } @@ -107,17 +114,22 @@ impl Permissions { self.bit(6) } pub fn fill_forms(&self) -> bool { - self.bit(9) || self.bit(6) + if self.is_revision_3_or_later() { self.bit(9) || self.bit(6) } else { self.bit(6) } } + /// Revision 2 covers accessibility with the copy flag (bit 5); revision 3 and later with bit 10. pub fn extract_for_accessibility(&self) -> bool { - self.bit(10) + if self.is_revision_3_or_later() { self.bit(10) } else { self.bit(5) } } - /// Insert, rotate or delete pages; create bookmarks and thumbnails. + /// Insert, rotate or delete pages; create bookmarks and thumbnails. Revision 3 and later grant + /// it by bit 11 alone. Table 22 defines bit 11 for those revisions only, so at revision 2 the + /// modify flag (bit 4) covers it. pub fn assemble(&self) -> bool { - self.bit(11) || self.bit(4) + if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those + /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { - self.bit(12) + if self.is_revision_3_or_later() { self.print() && self.bit(12) } else { self.print() } } } @@ -250,7 +262,7 @@ impl SecurityHandler { /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); - Permissions { bits, owner: self.auth == Auth::Owner } + Permissions { bits, owner: self.auth == Auth::Owner, revision: self.dict.r } } /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 6064176d78..09a88ed129 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -240,11 +240,11 @@ fn unsupported_handlers_are_reported() { #[test] fn permission_bits_follow_table_22() { - let p = Permissions { bits: -3904, owner: false }; // 0xFFFFF0C0: only reserved bits set + let p = Permissions { bits: -3904, owner: false, revision: 3 }; // 0xFFFFF0C0: only reserved bits set assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble()); - let p = Permissions { bits: -1, owner: false }; + let p = Permissions { bits: -1, owner: false, revision: 3 }; assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms()); - let p = Permissions { bits: 0, owner: true }; + let p = Permissions { bits: 0, owner: true, revision: 3 }; assert!(p.modify(), "owners ignore /P"); } diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs index 8b39bf1956..e7742bbd75 100644 --- a/crates/crypt/tests/it/main.rs +++ b/crates/crypt/tests/it/main.rs @@ -1,4 +1,5 @@ //! The crate's integration tests, linked into one test binary. +mod permissions_revision; mod proptests; mod qpdf_literals; diff --git a/crates/crypt/tests/it/permissions_revision.rs b/crates/crypt/tests/it/permissions_revision.rs new file mode 100644 index 0000000000..bad709cd51 --- /dev/null +++ b/crates/crypt/tests/it/permissions_revision.rs @@ -0,0 +1,126 @@ +//! The permission accessors of `pdfcraft_crypt::Permissions`, checked against the flag table of +//! ISO 32000-1:2008 (Table 22). Expected values come from that table only. +//! +//! Bit n has the mask `1 << (n - 1)`. Readers use bits 3 to 6 and 9 to 12 and ignore the rest. + +use pdfcraft_crypt::{Algorithm, Auth, NewEncryption, Permissions, SecurityHandler, create}; + +const ID: &[u8] = b"0123456789abcdef"; +const T: bool = true; +const F: bool = false; + +/// The eight accessors in this order: print, high-quality print, modify, copy, accessibility, +/// annotate, fill forms, assemble. +fn cells(p: &Permissions) -> [bool; 8] { + [p.print(), p.print_high_quality(), p.modify(), p.copy(), p.extract_for_accessibility(), p.annotate(), p.fill_forms(), p.assemble()] +} + +/// Creates a file with `flags` at revision `revision`, opens it with the user password, and +/// returns the user's permissions. +fn user_view(alg: Algorithm, revision: i64, flags: i32) -> Permissions { + let made = create( + &NewEncryption { algorithm: alg, user_password: "user", owner_password: "owner", permissions: flags, encrypt_metadata: true, seed: [42; 32] }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.dict().r, revision, "the algorithm writes revision {revision}"); + let opened = SecurityHandler::open(made.dict().clone(), ID, Some("user")).expect("the user password opens the file"); + assert_eq!(opened.auth(), Auth::User, "the user password gives user access"); + opened.permissions() +} + +/// Revision 3 and later: each row is a flag word and the expected eight cells. +const REVISION_3_ROWS: &[(&str, i32, [bool; 8])] = &[ + ("every meaningful flag set, ignored flags clear", -196, [T, T, T, T, T, T, T, T]), + ("every flag set, ignored flags set too", -1, [T, T, T, T, T, T, T, T]), + ("reserved flags only", -4096, [F, F, F, F, F, F, F, F]), + ("ignored flags set, meaningful flags clear", -3901, [F, F, F, F, F, F, F, F]), + ("modify flag clear", -9, [T, T, F, T, T, T, T, T]), + ("copy flag clear", -17, [T, T, T, F, T, T, T, T]), + ("annotate flag clear", -33, [T, T, T, T, T, F, T, T]), + ("annotate and fill-existing flags clear", -289, [T, T, T, T, T, F, F, T]), + ("annotate flag set, fill-existing flag clear", -257, [T, T, T, T, T, T, T, T]), + ("accessibility flag clear", -513, [T, T, T, T, F, T, T, T]), + ("high-quality flag clear, print flag set", -2049, [T, F, T, T, T, T, T, T]), + ("bits 3 to 12 set, reserved bits clear", 4092, [T, T, T, T, T, T, T, T]), + ("positive flag word, bit 32 clear", 2_147_483_647, [T, T, T, T, T, T, T, T]), + ("revision 2 example read at revision 3", -44, [T, T, F, T, T, F, T, T]), +]; + +#[test] +fn revision_three_rows_hold_through_a_created_and_reopened_file() { + for (name, bits, want) in REVISION_3_ROWS { + assert_eq!(cells(&user_view(Algorithm::Rc4_128, 3, *bits)), *want, "{name}: flag word {bits:#010x}"); + } +} + +#[test] +fn owner_access_gives_every_flag() { + let made = create( + &NewEncryption { + algorithm: Algorithm::Rc4_128, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [42; 32], + }, + ID, + ) + .expect("the handler is created"); + assert_eq!(made.auth(), Auth::Owner, "the owner password gives owner access"); + assert_eq!(cells(&made.permissions()), [T; 8]); +} + +#[test] +fn revision_two_print_modify_copy_and_annotate_follow_the_table() { + for (bits, want) in [(-4, [T, T, T, T]), (-44, [T, F, T, F]), (-17, [T, T, F, T])] { + let p = user_view(Algorithm::Rc4_40, 2, bits); + assert_eq!([p.print(), p.modify(), p.copy(), p.annotate()], want, "revision 2, flag word {bits}"); + } +} + +#[test] +fn revision_two_with_every_flag_set_grants_accessibility_and_form_filling() { + let p = user_view(Algorithm::Rc4_40, 2, -4); + assert!(p.extract_for_accessibility(), "bit 5 is set, and at revision 2 it covers accessibility"); + assert!(p.fill_forms(), "bit 6 is set"); +} + +#[test] +fn revision_three_assembly_needs_its_own_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -1025); + assert!(p.modify(), "bit 4 is set"); + assert!(!p.assemble(), "assembly needs bit 11; bit 4 alone does not grant it"); +} + +#[test] +fn revision_three_high_quality_print_needs_the_print_flag() { + let p = user_view(Algorithm::Rc4_128, 3, -5); + assert!(!p.print(), "bit 3 is clear"); + assert!(!p.print_high_quality(), "high-quality print needs bit 3 and bit 12"); +} + +#[test] +fn revision_two_accessibility_follows_the_copy_flag() { + let p = user_view(Algorithm::Rc4_40, 2, -17); + assert!(!p.copy(), "bit 5 is clear"); + assert!(!p.extract_for_accessibility(), "at revision 2 accessibility is bit 5"); +} + +#[test] +fn revision_two_form_filling_ignores_bit_nine() { + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.annotate(), "bit 6 is clear"); + assert!(!p.fill_forms(), "at revision 2 form filling is bit 6 alone"); +} + +#[test] +fn revision_two_ignores_the_flags_only_later_revisions_define() { + // Bit 4 is clear and bit 11 set: bit 11 means nothing at revision 2, so assembly stays refused. + let p = user_view(Algorithm::Rc4_40, 2, -44); + assert!(!p.assemble(), "at revision 2 assembly is bit 4 alone"); + // Bit 3 is set and bit 12 clear: printing at revision 2 has one quality. + let p = user_view(Algorithm::Rc4_40, 2, -2049); + assert!(p.print_high_quality(), "at revision 2 high-quality print is bit 3 alone"); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index fa98a18417..279dc8d563 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -539,7 +539,7 @@ impl Document { _ => "Attachments only", }; let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + let permissions = if pending { pdfcraft_cos::Permissions { owner: false, ..h.permissions() } } else { h.permissions() }; Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a20314c2e0..86fa6495ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -623,7 +623,7 @@ fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { activation: Activation3d::default(), name: String::new(), }; - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); } @@ -6048,7 +6048,7 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty()); session.redo(id).unwrap(); assert_eq!(session.get(id).unwrap().bytes, before); - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 }; assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments"))); assert_eq!( check_permission( From f16b5d9331da5583fa9abdb973010f358aa5c60f Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 21:52:06 +0200 Subject: [PATCH 42/59] M13 batch: test that results hold no planted secret, and record network-crate reachability (#6297) * M13 batch: test that results hold no planted secret, and record network-crate reachability * netcheck: list socket2, report a walk past the cap, test by the gate rule socket2 can open sockets, so it belongs in the list. A walk past the cap now fails instead of reading as unreachable. The baseline test follows check_rows, so a dependency update that moves a path no longer fails cargo test. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/it/batch_secrets.rs | 84 +++++ crates/automation/tests/it/main.rs | 1 + xtask/baselines/network-crates.json | 74 +++++ xtask/src/main.rs | 6 + xtask/src/netcheck.rs | 344 ++++++++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 crates/automation/tests/it/batch_secrets.rs create mode 100644 xtask/baselines/network-crates.json create mode 100644 xtask/src/netcheck.rs diff --git a/crates/automation/tests/it/batch_secrets.rs b/crates/automation/tests/it/batch_secrets.rs new file mode 100644 index 0000000000..08223f0955 --- /dev/null +++ b/crates/automation/tests/it/batch_secrets.rs @@ -0,0 +1,84 @@ +//! Tool results and errors from the folder tools carry no known test password. +//! +//! The passwords are set on the document and passed to the tool, and must never come back out: a +//! result that echoes one has leaked it into logs, transcripts and MCP callers. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{Changes, Edit, Protection, Session}; +use serde_json::{Value, json}; + +/// The known test passwords; neither may appear in a result or an error. +const OPEN_PASSWORD: &str = "PLANT-OPEN-5f2c9a"; +const OWNER_PASSWORD: &str = "PLANT-OWNER-71de0b"; + +/// A one-page PDF locked with the known test passwords. +/// +/// The open password is the only way in. The batch bates tool rewrites page content, and +/// `Changes::None` forbids that, so this fixture permits every change except extracting pages; +/// the restrictions stay real, since the open password never lifts them. +fn protected_pdf() -> Vec { + let mut session = Session::new(); + let bytes = session.create_from_text("Batch secrets", "Planted password test").unwrap(); + let id = session.open("locked.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some(OPEN_PASSWORD.into()), + permissions_password: Some(OWNER_PASSWORD.into()), + changes: Changes::AnyExceptExtract, + ..Default::default() + }), + ) + .unwrap(); + session.save_bytes(id).unwrap().as_ref().clone() +} + +fn json_result(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut content) => match content.remove(0) { + Content::Json(value) => value, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(error) => panic!("{tool} {args}: {error}"), + } +} + +#[test] +fn results_and_errors_hold_no_planted_password_for_bates_or_batch() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("locked.pdf"), protected_pdf()).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + + // The right open password: numbering succeeds and reports the numbers only. + let numbered = + json_result(&mut a, "doc_bates_multi", json!({"files":[{"input":"locked.pdf","output":"locked-numbered.pdf","password":OPEN_PASSWORD}]})); + assert_eq!(numbered["completed"], 1, "{numbered}"); + assert_eq!(numbered["files"][0]["status"], "ok", "{numbered}"); + // The report carries the per-file entry the Bates tool builds, so a field added to that entry + // lands in the text checked below. + assert_eq!(numbered["files"][0]["first"], 1, "{numbered}"); + assert!(numbered["files"][0]["output"].as_str().is_some_and(|path| path.ends_with("locked-numbered.pdf")), "{numbered}"); + assert!(dir.path().join("locked-numbered.pdf").exists(), "the numbered copy is written"); + + // The wrong open password: the tool refuses the file with an error. + let refused = match a + .call("doc_bates_multi", &json!({"files":[{"input":"locked.pdf","output":"locked-refused.pdf","password":"not-the-open-password"}]})) + { + Err(error) => error.to_string(), + Ok(value) => panic!("a wrong open password must be refused: {value:?}"), + }; + + // The folder job refuses the encrypted file without a password, in its per-file report. + let batch = json_result(&mut a, "doc_batch", json!({"paths":["locked.pdf"],"steps":["optimize"],"out_dir":"out"})); + assert_eq!(batch["failed"], 1, "{batch}"); + assert_eq!(batch["files"][0]["status"], "error", "{batch}"); + assert!(batch["files"][0]["error"].as_str().is_some_and(|text| !text.is_empty()), "{batch}"); + assert!(!dir.path().join("out/locked.pdf").exists(), "a refused file publishes nothing"); + + for report in [serde_json::to_string(&numbered).unwrap(), refused, serde_json::to_string(&batch).unwrap()] { + for secret in [OPEN_PASSWORD, OWNER_PASSWORD] { + assert!(!report.contains(secret), "a result or error echoed a planted password: {report}"); + } + } +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index a3cd55623c..13f24013cd 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -7,6 +7,7 @@ mod automation_roots; mod backdrop; mod batch; mod batch_failures; +mod batch_secrets; mod button_caption; mod form_bulk; mod hostile_args; diff --git a/xtask/baselines/network-crates.json b/xtask/baselines/network-crates.json new file mode 100644 index 0000000000..13f24c43a7 --- /dev/null +++ b/xtask/baselines/network-crates.json @@ -0,0 +1,74 @@ +[ + { + "crate": "pdfcraft-cli", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-cli", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-cli", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-cli", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-cli -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-automation", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-automation", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-automation", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-automation", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-automation -> pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + }, + { + "crate": "pdfcraft-engine", + "name": "hyper", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper" + }, + { + "crate": "pdfcraft-engine", + "name": "reqwest", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest" + }, + { + "crate": "pdfcraft-engine", + "name": "socket2", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> hyper-util -> socket2" + }, + { + "crate": "pdfcraft-engine", + "name": "tokio", + "reachable": true, + "path": "pdfcraft-engine -> pdfcraft-print -> craft-print -> winprint -> reqwest -> tokio" + } +] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8e3255d5e6..01beb95ecf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,6 +10,7 @@ mod gates; mod layers; mod license_check; mod macos_integration; +mod netcheck; mod parity; mod screenshots; mod version; @@ -35,6 +36,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), ("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run), ("parity", "Validate parity/acrobat-features.toml against the registry, tools and tests; report progress (--json, --partial)", parity::run), + ( + "netcheck", + "Record which network-capable crates the batch crates can reach in Cargo.lock, and fail on any reachable pair not in xtask/baselines/network-crates.json", + netcheck::run, + ), ( "macos-integration", "Report macOS bundle, Quick Look, Spotlight and PDF integration diagnostics without mutating system state", diff --git a/xtask/src/netcheck.rs b/xtask/src/netcheck.rs new file mode 100644 index 0000000000..0d29a461f1 --- /dev/null +++ b/xtask/src/netcheck.rs @@ -0,0 +1,344 @@ +//! `cargo xtask netcheck`: which network-capable crates the batch path can reach. +//! +//! The batch folder job runs `pdfcraft-cli`, which drives `pdfcraft-automation` over +//! `pdfcraft-engine`; none of them may open a socket. That promise is only as strong as the +//! dependency graph behind those three packages, so this gate walks the resolved graph recorded +//! in `Cargo.lock` and asks, for each of them and each crate that can open a socket, whether that +//! crate is reachable from it. The answer is compared with a reviewed baseline in +//! `xtask/baselines/network-crates.json`: a reachable pair the baseline does not list fails. +//! +//! A lockfile records the resolved dependencies of a package without their kind and without a +//! target: normal, build and development edges look the same, and a dependency only one platform +//! uses is listed beside the rest. The answer here is therefore a superset of what a normal-level, +//! per-target walk would give. It may name a crate that is only a build or a development +//! dependency, or one that only another platform uses; it does not miss one, so it errs toward +//! failing and never toward passing. `--update-baseline` rewrites the baseline; review that diff +//! line by line. +//! +//! The walk is in process, over the lockfile text: it starts no other program and reads no +//! network. This gate is local on purpose, not part of `cargo xtask ci`, which would be a +//! workflow change. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::Path; + +use anyhow::{Context, bail}; +use serde::{Deserialize, Serialize}; + +use crate::gates::root; + +/// The packages the batch path is built from. +const PACKAGES: &[&str] = &["pdfcraft-cli", "pdfcraft-automation", "pdfcraft-engine"]; + +/// Crates that can open a socket: HTTP clients, TLS stacks, async runtimes, socket wrappers and +/// libcurl bindings. +const NETWORK_CRATES: &[&str] = &["curl", "hyper", "isahc", "minreq", "native-tls", "reqwest", "socket2", "tokio", "ureq"]; + +/// The resolved dependency graph of the workspace, relative to the repository root. +const LOCKFILE: &str = "Cargo.lock"; + +/// The reviewed baseline, relative to the repository root. +const BASELINE: &str = "xtask/baselines/network-crates.json"; + +/// The most packages one walk will visit. A lock file has a few hundred packages; the cap stops a +/// damaged file from turning the walk into a long loop. +const MAX_VISITED: usize = 500_000; + +/// What the lock file says about one package and one network-capable crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Row { + /// The package whose dependencies were walked. + #[serde(rename = "crate")] + package: String, + /// The network-capable crate asked about. + name: String, + /// Whether the package reaches the crate in the lock file graph. + reachable: bool, + /// The chain from the package to the crate, ` -> ` separated; empty when unreachable. + #[serde(default)] + path: String, +} + +/// The first word of a dependency entry: `"name"`, `"name 1.2.3"` and `"name 1.2.3 (source)"` +/// all name the same package. +fn entry_name(entry: &str) -> &str { + entry.split_whitespace().next().unwrap_or_default() +} + +/// The package a dependency line names, when the line is one. +fn dependency_name(line: &str) -> Option<&str> { + let quoted = line.strip_prefix('"')?; + let end = quoted.find('"')?; + let name = entry_name("ed[..end]); + (!name.is_empty()).then_some(name) +} + +/// The names in a `dependencies = ["a", "b"]` line, that is, the text between its brackets. +fn inline_names(value: &str) -> Vec<&str> { + value.split(',').filter_map(|item| dependency_name(item.trim())).collect() +} + +/// One edge of the graph: `package` depends on `dependency`. +fn add_edge(graph: &mut BTreeMap>, package: &str, dependency: &str) { + graph.entry(package.to_owned()).or_default().insert(dependency.to_owned()); +} + +/// The dependency graph recorded by a lock file, keyed by package name. +/// +/// Edges are by name: a lock file can hold two versions of one crate, and the walk only asks +/// whether a name can be reached at all. A file that names no package is an error rather than an +/// empty graph, so a format change fails loudly instead of reporting nothing reachable. +fn parse_lockfile(text: &str) -> anyhow::Result>> { + let mut graph: BTreeMap> = BTreeMap::new(); + let mut current: Option = None; + let mut in_list = false; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if in_list { + if line.starts_with(']') { + in_list = false; + } else if let Some(name) = dependency_name(line) + && let Some(package) = current.as_deref() + { + add_edge(&mut graph, package, name); + } + continue; + } + if line.starts_with("[[") { + current = None; + continue; + } + if let Some(name) = line.strip_prefix("name = \"").and_then(|rest| rest.strip_suffix('"')) { + graph.entry(name.to_owned()).or_default(); + current = Some(name.to_owned()); + continue; + } + if let Some(value) = line.strip_prefix("dependencies = [") { + match value.strip_suffix(']') { + Some(inner) => { + for name in inline_names(inner) { + if let Some(package) = current.as_deref() { + add_edge(&mut graph, package, name); + } + } + } + None => in_list = true, + } + } + } + if graph.is_empty() { + bail!("{LOCKFILE} names no package; the lock file format changed or the file is empty"); + } + Ok(graph) +} + +/// The shortest chain from `from` to `to` in the graph, both ends included. A walk past +/// [`MAX_VISITED`] is an error: it has no answer, and "unreachable" would pass the gate. +fn shortest_path(graph: &BTreeMap>, from: &str, to: &str) -> anyhow::Result>> { + let mut parents: BTreeMap = BTreeMap::new(); + let mut queue: VecDeque = VecDeque::from([from.to_owned()]); + let mut visited = 0usize; + while let Some(node) = queue.pop_front() { + visited = visited.saturating_add(1); + if visited > MAX_VISITED { + bail!("walking from {from} to {to} visited more than {MAX_VISITED} packages; is {LOCKFILE} damaged?"); + } + if node == to { + return Ok(Some(walk_back(&parents, node))); + } + let Some(next) = graph.get(&node) else { continue }; + for dependency in next { + if dependency != from && !parents.contains_key(dependency.as_str()) { + parents.insert(dependency.clone(), node.clone()); + queue.push_back(dependency.clone()); + } + } + } + Ok(None) +} + +/// The chain from the start of the walk to `node`, following the discovery parents. +fn walk_back(parents: &BTreeMap, node: String) -> Vec { + let mut chain = vec![node.clone()]; + let mut current = node; + while let Some(parent) = parents.get(¤t) { + chain.push(parent.clone()); + current = parent.clone(); + } + chain.reverse(); + chain +} + +/// One row per package and network-capable crate. +fn rows(graph: &BTreeMap>) -> anyhow::Result> { + let mut rows = Vec::with_capacity(PACKAGES.len().saturating_mul(NETWORK_CRATES.len())); + for package in PACKAGES { + for name in NETWORK_CRATES { + let path = shortest_path(graph, package, name)?; + let path = path.map(|chain| chain.join(" -> ")); + let reachable = path.is_some(); + let row = Row { package: (*package).to_owned(), name: (*name).to_owned(), reachable, path: path.unwrap_or_default() }; + rows.push(row); + } + } + Ok(rows) +} + +/// Write the reviewed baseline: the reachable pairs only, so a reader sees exactly the accepted +/// answers and nothing else. +fn write_baseline(path: &Path, rows: &[Row]) -> anyhow::Result<()> { + let reachable: Vec<&Row> = rows.iter().filter(|r| r.reachable).collect(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&reachable)? + "\n"; + std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; + println!("baseline updated: {} rows in {BASELINE}", reachable.len()); + Ok(()) +} + +fn read_baseline(path: &Path) -> anyhow::Result> { + let text = + std::fs::read_to_string(path).with_context(|| format!("{} is missing: run `cargo xtask netcheck --update-baseline`", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +/// The reachable pairs of a row list, for set comparison. +fn pairs(rows: &[Row]) -> BTreeSet<(&str, &str)> { + rows.iter().filter(|r| r.reachable).map(|r| (r.package.as_str(), r.name.as_str())).collect() +} + +/// Compare the current answers with the reviewed baseline: a reachable pair the baseline does not +/// list fails, and a pair that stopped being reachable or moved to another path is reported for +/// the next baseline update. +fn check_rows(rows: &[Row], reviewed: &[Row]) -> anyhow::Result<()> { + let listed = pairs(reviewed); + let found = pairs(rows); + for row in rows.iter().filter(|r| r.reachable) { + if let Some(before) = reviewed.iter().find(|b| b.reachable && b.package == row.package && b.name == row.name) + && before.path != row.path + { + println!("path changed for {} -> {}: {} (was {})", row.package, row.name, row.path, before.path); + } + } + let gone: Vec = listed.difference(&found).map(|(package, name)| format!("{package} -> {name}")).collect(); + if !gone.is_empty() { + println!("no longer reachable (run --update-baseline to record it): {}", gone.join("; ")); + } + let new: Vec = found.difference(&listed).map(|(package, name)| format!("{package} -> {name}")).collect(); + if new.is_empty() { + println!("OK: all {} reachable pairs are in the reviewed baseline", found.len()); + return Ok(()); + } + for pair in &new { + println!(" - {pair}"); + } + bail!("{} reachable network crate pair(s) are not in {BASELINE}; review them, then run `cargo xtask netcheck --update-baseline`", new.len()) +} + +pub fn run(args: &[String]) -> anyhow::Result<()> { + let update = args.iter().any(|a| a == "--update-baseline"); + let lockfile = root().join(LOCKFILE); + let text = std::fs::read_to_string(&lockfile).with_context(|| format!("reading {}", lockfile.display()))?; + let graph = parse_lockfile(&text)?; + if let Some(package) = PACKAGES.iter().find(|name| !graph.contains_key(**name)) { + bail!("{package} is not in {LOCKFILE}; is it still a workspace member?"); + } + let rows = rows(&graph)?; + let reachable = rows.iter().filter(|r| r.reachable).count(); + println!("netcheck: {} packages x {} network-capable crates; {reachable} reachable", PACKAGES.len(), NETWORK_CRATES.len()); + for row in rows.iter().filter(|r| r.reachable) { + println!(" {} -> {}: {}", row.package, row.name, row.path); + } + let path = root().join(BASELINE); + let reviewed = if update { + write_baseline(&path, &rows)?; + rows.clone() + } else { + read_baseline(&path)? + }; + check_rows(&rows, &reviewed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lock file with a block list, an inline list, a versioned entry and a patch block. + const SAMPLE: &str = r#" +version = 4 + +[[package]] +name = "root" +version = "0.1.0" +dependencies = [ + "middle", + "serde 1.0.0", +] + +[[package]] +name = "middle" +version = "0.1.0" +source = "registry+https://example.invalid/index" +dependencies = [ + "leaf", +] + +[[package]] +name = "leaf" +version = "0.1.0" + +[[package]] +name = "empty" +version = "0.1.0" +dependencies = [] + +[[patch.unused]] +name = "spare" +version = "0.2.0" + +[[package]] +name = "tail" +version = "0.1.0" +dependencies = ["leaf"] +"#; + + #[test] + fn lockfile_dependency_lists_become_edges() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(graph.get("root"), Some(&BTreeSet::from(["middle".to_string(), "serde".to_string()]))); + assert_eq!(graph.get("middle"), Some(&BTreeSet::from(["leaf".to_string()]))); + assert_eq!(graph.get("tail"), Some(&BTreeSet::from(["leaf".to_string()])), "an inline list is read too"); + assert_eq!(graph.get("empty"), Some(&BTreeSet::new())); + assert!(graph.contains_key("leaf"), "a package with no dependency list is still a node"); + assert!(graph.contains_key("spare"), "a patch block names a package too"); + } + + #[test] + fn shortest_path_names_the_chain_and_follows_dependencies_only() { + let graph = parse_lockfile(SAMPLE).expect("the sample parses"); + assert_eq!(shortest_path(&graph, "root", "leaf").unwrap(), Some(vec!["root".to_string(), "middle".to_string(), "leaf".to_string()])); + assert_eq!(shortest_path(&graph, "leaf", "root").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "nowhere").unwrap(), None); + assert_eq!(shortest_path(&graph, "root", "root").unwrap(), Some(vec!["root".to_string()])); + } + + #[test] + fn a_lockfile_without_packages_is_an_error() { + let error = parse_lockfile("version = 4\n").expect_err("an empty graph must not be read as an answer"); + assert!(error.to_string().contains("names no package"), "{error}"); + } + + #[test] + fn committed_baseline_accepts_the_reachable_pairs_of_the_lockfile_graph() { + let text = std::fs::read_to_string(root().join(LOCKFILE)).expect("Cargo.lock is committed"); + let graph = parse_lockfile(&text).expect("Cargo.lock parses"); + let found = rows(&graph).expect("the walk finishes"); + let reviewed = read_baseline(&root().join(BASELINE)).expect("the baseline is committed"); + // The gate's own rule: a pair that went away or moved is reported, not a failure. + check_rows(&found, &reviewed).expect("every reachable pair is in the baseline"); + } +} From 60f60fc0a2c3fd58f6679483cfae1d2478e673dd Mon Sep 17 00:00:00 2001 From: eliets2 Date: Thu, 5 Nov 2026 22:25:30 +0200 Subject: [PATCH 43/59] Give a redacted or sanitized copy a new file identifier (#3734) * cos: give a redacted or sanitized full save a new file identifier A full save that the document asked for (Document::require_full_save_with_new_id) now writes a fresh /ID pair, so a redacted or sanitized copy does not claim to be the file it came from (ISO 32000-2 section 14.4). Ordinary saves are unchanged. An encrypted file keeps the first string of /ID, because the file key is derived from it, and gets a new second string. An encrypted file whose /ID is not a pair of strings is left as it was, as before. SaveOptions::id_entropy lets a caller or a test supply entropy; with it the new identifier depends only on that entropy and the written bytes. Without it the identifier mixes the standard library's random hash keys and a process-wide counter, the same facility the engine uses for new encryption keys. Checked: the new tests in crates/cos/tests/file_identifier.rs fail on the clean base (compile errors for the missing API), fail on assertions when only the API is in place, and fail again when each pinned line is reverted (the regeneration call, the first-string keep, the flag, the entropy input, and the ordinary-save guard). cargo test -p pdfcraft-cos passes, and clippy with warnings denied is clean. A probe that wrote 16 ordinary full and incremental saves gave byte-identical files with and without this change. * redact: give a redacted or sanitized copy a new file identifier Applying redactions, sanitizing and removing hidden information now ask the cos layer for a new file identifier (Document::require_full_save_with_new_id), so the saved copy no longer carries the /ID of its source. Before this change the previous revision was dropped but the identifier still linked the copy to the original. The request sits in remove_hidden itself, because its per-category callers (the remove-hidden edit and the optimizer's discard option) call it directly, not through sanitize(). sanitize() asks only on its nothing-hidden path, where remove_hidden returned before asking: the rewrite is a new file either way. Checked: the new tests a_redacted_or_sanitized_copy_gets_a_new_identifier and a_remove_hidden_copy_gets_a_new_identifier fail before this change (the request is missing after apply, and after remove_hidden) and fail again when the apply call, the nothing-hidden branch or the remove_hidden request is reverted. cargo test -p pdfcraft-redact passes with the tests included (152 passed, 1 ignored); cargo clippy with warnings denied and cargo fmt --check are clean. * docs(cos): say when a full save regenerates the file identifier The writing section of the crate README described full saves without saying what happens to /ID. It now says that an ordinary save keeps the identifier, that a document which asks for a new one (redaction and sanitizing) gets both strings regenerated, that SaveOptions::id_entropy supplies entropy for reproducible output, and that an encrypted file keeps its first string because the key depends on it. Checked: the text matches the code added in e1f761cc (regenerate_id, new_id_bytes, require_full_save_with_new_id) and the tests in crates/cos/tests/file_identifier.rs. No code changed in this commit. * cos: hash the whole rewritten file into the new identifier The identifier of a requested rewrite hashed only the first 64 KiB of the written file. With caller-supplied id_entropy the identifier is deterministic, so two rewrites of documents with the same length that differ only after byte 65,536 received the same /ID, and a later edit of a large document could keep its previous /ID. Every written byte is now hashed. The output is read once: one loop over 4 KiB chunks feeds both halves of the 16-byte identifier, so the extra cost is one linear pass over the output. Measured with a probe in the workspace dev profile (opt-level 1): hashing 64 MiB took 0.024 s, about 24 ms per 64 MiB of output. A release build was not measured. Ordinary saves never reach this code, and their output is unchanged. Checked: crates/cos/tests/file_identifier.rs gains the_identifier_depends_on_the_whole_rewritten_file. It fails on the branch head before this change ("content past 64 KiB must change the identifier", both identifiers equal) and passes after it; reverting only the chunk loop to the 64 KiB prefix makes it fail again. The six existing tests in that file pass, and so do the full cos and redact test suites. * cos: keep the key valid when an encrypted rewrite has no usable /ID A requested rewrite of an encrypted file whose /ID was absent, had one entry, or had a first entry that is not a string wrote no identifier at all, although the document had asked for a new one. The reader takes the empty string as the key input for these shapes (Document::first_id), so the rewrite now writes the empty string (<>) as the first entry and a new second entry. The key stays valid. A file whose first entry is a string keeps that string, as before. Ordinary saves and unencrypted rewrites are unchanged. Checked: an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one in crates/cos/tests/file_identifier.rs covers the three shapes for RC4-128, AES-128 and AES-256. After the rewrite each file opens with its password, the first string is empty and the second is new. On the branch head the test fails with "the rewrite writes a pair" (left 0, right 2). Reverting only the guard to the head's early return makes it fail again. Isolated, the one-entry shape fails with left 1, right 2, and the non-string shape with left Some(0), right Some(16). The other seven tests in the file pass. * cos: derive the identifier from caller entropy with a fixed hash With SaveOptions::id_entropy the identifier was built with std's DefaultHasher, whose algorithm the standard library does not specify, and with write_usize (4 bytes on wasm32, 8 on 64-bit targets) and native-endian integers. The same entropy and the same file could therefore give different identifiers on different platforms or toolchains, although the documentation promised the bytes depend only on the entropy and the written file. The identifier is now FNV-1a (64 bit, the published constants) over fixed-width big-endian fields and the whole written file: the salt, the lane, id_seed and the length, then the bytes. With caller entropy the salt is the FNV-1a hash of that entropy. Without entropy it is still the standard library's random hash of a process counter, so only the salt differs between the two paths. Every written byte is hashed, in one pass that feeds both lanes. Cost: this pass is slower than the SipHash it replaces. A temporary probe in the workspace dev profile (opt-level 1) hashed 64 MiB through both lanes in 0.129 s (about 500 MiB/s), against 0.024 s before. A release build was not measured. The cost is paid once per requested rewrite; ordinary saves do not reach this code. Checked: the golden test fixed_entropy_rewrite in crates/cos/tests/file_identifier.rs pins the 16 bytes for a fixed entropy and the standard fixture. On the previous hash it fails (left [64, 145, ...], right [50, 125, ...]). An independent re-implementation of the same formula in Python, run over the bytes the writer hashed (everything before startxref), gives the same 16 bytes. The unit test fnv1a_is_the_published_64_bit_algorithm checks the constants against the published vectors. FNV is not collision-resistant against chosen content; that matters only if someone could choose a file's identifier, and the identifier only has to be unique. * Repair review branch integration * cos: take fresh identifier entropy from the browser on wasm Without SaveOptions::id_entropy the salt of a rewritten file's identifier was RandomState and a process-wide counter. On wasm32-unknown-unknown those hash keys are fixed at compile time, so two fresh web apps that followed the same allocation and call sequence reproduced the same salt and the same identifier for identical output, and a redacted copy stayed linkable to its source. The default salt now comes from the browser first: crypto.getRandomValues (Web Crypto, through js_sys, sixteen bytes folded with the same FNV-1a the identifier uses), or Math.random where that API is missing, and only when neither is reachable the previous RandomState salt. js-sys and wasm-bindgen join pdfcraft-cos as wasm32-only dependencies; both are already in the dependency tree of the web shell, and native builds compile neither. Caller entropy is untouched: with id_entropy the identifier is still a pure function of that entropy, the seed and the written bytes. Checked: separate_fresh_processes_get_different_identifiers re-runs this test binary as two fresh child processes over the same fixture and fails when the default salt is made constant (so does the in-process divergence test); stand_in_for_a_fresh_process is its no-op child half. Both pass with the real salt. On WebAssembly no browser test harness exists, so the browser path is covered by the wasm32 cargo check and by construction, not by a test. cargo check --target wasm32-unknown-unknown -p pdfcraft-cos passes, cargo test -p pdfcraft-cos and -p pdfcraft-redact pass, and clippy with warnings denied is clean on both. * Extract full-save trailer assembly Assisted-by: OpenAI Codex --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 2 + crates/cos/Cargo.toml | 7 + crates/cos/README.md | 8 + crates/cos/src/document.rs | 3 + crates/cos/src/document/open.rs | 15 ++ crates/cos/src/writer.rs | 229 ++++++++++++++++++++--- crates/cos/tests/file_identifier.rs | 279 ++++++++++++++++++++++++++++ crates/redact/src/lib.rs | 5 +- crates/redact/src/sanitize.rs | 11 +- crates/redact/src/tests.rs | 81 ++++++++ 10 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 crates/cos/tests/file_identifier.rs diff --git a/Cargo.lock b/Cargo.lock index 1bf064f5d2..e68f422a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4333,6 +4333,7 @@ version = "0.18.1" dependencies = [ "chrono", "hayro-syntax", + "js-sys", "pdfcraft-crypt", "pdfcraft-filters", "pdfcraft-oracle", @@ -4340,6 +4341,7 @@ dependencies = [ "proptest", "serde_json", "thiserror 2.0.21", + "wasm-bindgen", ] [[package]] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index a2fb3f4c4e..0bf2e894d2 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -15,6 +15,13 @@ thiserror = { workspace = true } # PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). chrono = { workspace = true } +# On the web, a rewrite's fresh file identifier takes its entropy from the browser +# (`crypto.getRandomValues`). Both crates are already in the dependency tree of the web shell +# (`js-sys` 0.3 / `wasm-bindgen` 0.2); native builds compile neither. +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +wasm-bindgen = "0.2" + [dev-dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index 398b161625..774f2c4367 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -89,6 +89,14 @@ into compressed object streams with a cross-reference stream (PDF 1.5+); turning classic table any reader accepts. Both forms map accepted xref/header aliases to one output object, retaining shared identity such as page-tree parent links; unrelated generations stay null. +A full save keeps the file's `/ID` as it is, unless the document asked for a new identifier with +`Document::require_full_save_with_new_id` (redaction and sanitizing do). Then both strings are +regenerated, and `SaveOptions::id_entropy` can supply entropy for reproducible output (tests pin +output this way). Without it the identifier mixes in fresh entropy from the host — the browser's +`crypto.getRandomValues` on the web, the operating system's random hash seed on the desktop — so a +fresh copy of the program cannot reproduce it. An encrypted file keeps the first string, because its +key is derived from it. + Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e52aba2ce3..e5ce531f67 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -71,6 +71,9 @@ pub struct Document { encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). full_save: bool, + /// The next full save also gives the file a new identifier (`/ID`), see + /// [`Document::require_full_save_with_new_id`]. + new_id: bool, /// The handler and `/Encrypt` object number that saves use, when encryption changed. out_security: Option>, out_encrypt_num: Option, diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 7a4337c8da..3bf6677e99 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -35,6 +35,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -121,6 +122,7 @@ impl Document { unfound: Arc::default(), encryption_changed: false, full_save: false, + new_id: false, out_security: None, out_encrypt_num: None, }; @@ -290,12 +292,25 @@ impl Document { self.full_save = true; } + /// Like [`Document::require_full_save`], and the rewritten file also gets a new file identifier + /// (`/ID`), so that a redacted or sanitized copy never claims to be its source (ISO 32000-2 + /// §14.4). An encrypted file keeps the first string of its identifier, which its key depends on. + pub fn require_full_save_with_new_id(&mut self) { + self.full_save = true; + self.new_id = true; + } + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } + /// The next full save gives the file a new identifier (see [`Document::require_full_save_with_new_id`]). + pub fn new_id_required(&self) -> bool { + self.new_id + } + /// Security was set or removed since the document was opened (the next save applies it). pub fn encryption_changed(&self) -> bool { self.encryption_changed diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 6c4c71580a..d5be18d96c 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -167,11 +167,17 @@ pub struct SaveOptions { /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave /// these out. pub drop_unlinked: bool, + /// Entropy for the file identifier of a rewrite that asks for a new one (see + /// [`Document::require_full_save_with_new_id`]). Given, the identifier depends only on this and + /// on the written bytes, so the same save gives the same file; tests use it to pin output. + /// Without it, fresh entropy from the host (the operating system on native targets, the + /// browser on WebAssembly) is mixed in. + pub id_entropy: Option<[u8; 16]>, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false, id_entropy: None } } } @@ -383,26 +389,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro } } batch.flush(&reader.document, &mut next, &mut rows, &mut out)?; - let mut trailer = Dict::new(); - for key in [&b"Root"[..], b"Info", b"Encrypt"] { - if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { - trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); - } - } - if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); - } - if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); - } - for (key, value) in trailer_in.iter() { - if !trailer_key_rewritten(key) { - trailer.set(key.clone(), renumber(value, &map)); - } - } - if reader.document.output_security().0.is_none() { - ensure_id(&mut trailer, opts, reader.document.bytes()); - } + let mut trailer = full_save_trailer(&reader.document, &trailer_in, &map, opts, &out); if opts.object_streams { write_xref_stream(&mut out, 0, &mut trailer, rows, next); } else { @@ -421,6 +408,34 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro Ok(out) } +/// Assemble the full rewrite's trailer and apply its identifier policy. +fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap, opts: &SaveOptions, out: &[u8]) -> Dict { + let mut trailer = Dict::new(); + for key in [&b"Root"[..], b"Info", b"Encrypt"] { + if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) { + trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); + } + } + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, map)); + } + if let Some(id) = trailer_in.get(b"ID") { + trailer.set(b"ID".to_vec(), direct_id(doc, id)); + } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, map)); + } + } + if doc.output_security().0.is_none() { + ensure_id(&mut trailer, opts, doc.bytes()); + } + if doc.new_id_required() { + regenerate_id(&mut trailer, opts, out, doc.output_security().0.is_some()); + } + trailer +} + /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. @@ -669,6 +684,136 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); } +/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not +/// claim to be its source. An encrypted file keeps the first string, which its key is derived from, +/// and gets a new second one. When there is no such string (no `/ID`, one entry, or a first entry +/// that is not a string) the reader takes the empty string as the key input (`Document::first_id`), +/// so the empty string is written first and the key stays valid. +fn regenerate_id(trailer: &mut Dict, opts: &SaveOptions, written: &[u8], encrypted: bool) { + let fresh = Object::String(PdfString { bytes: new_id_bytes(opts, written), hex: true }); + let pair = if encrypted { + let first = match trailer.get(b"ID") { + Some(Object::Array(a)) => match a.first() { + Some(Object::String(s)) => Object::String(s.clone()), + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }, + _ => Object::String(PdfString { bytes: Vec::new(), hex: true }), + }; + vec![first, fresh] + } else { + vec![fresh.clone(), fresh] + }; + trailer.set(b"ID".to_vec(), Object::Array(pair)); +} + +/// FNV-1a over 64 bits, with its published offset basis and prime. The identifier is built from it +/// rather than from `std`'s `DefaultHasher`, whose algorithm the standard library does not specify. +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(state: u64, bytes: &[u8]) -> u64 { + bytes.iter().fold(state, |h, b| (h ^ u64::from(*b)).wrapping_mul(FNV_PRIME)) +} + +/// Sixteen bytes for a file identifier, one 8-byte half per lane. The bytes are a pure function of +/// `SaveOptions::id_entropy`, `id_seed` and the written file: the hash is FNV-1a (above) and every +/// integer is written as a fixed-width big-endian value, so the same input gives the same identifier +/// on every platform and toolchain. Without entropy only the salt changes (see `id_salt`). FNV is +/// not collision-resistant against chosen content; the identifier only has to be unique, not secret. +/// Every written byte is hashed, once, in a single traversal of the output. +fn new_id_bytes(opts: &SaveOptions, written: &[u8]) -> Vec { + let salt = id_salt(opts); + let length = u64::try_from(written.len()).unwrap_or(u64::MAX); + let mut halves = [0u8, 1].map(|lane| { + let mut h = fnv1a(FNV_OFFSET, &salt.to_be_bytes()); + h = fnv1a(h, &[lane]); + h = fnv1a(h, &opts.id_seed.to_be_bytes()); + fnv1a(h, &length.to_be_bytes()) + }); + for chunk in written.chunks(4096) { + for h in &mut halves { + *h = fnv1a(*h, chunk); + } + } + halves.iter().flat_map(|h| h.to_be_bytes()).collect() +} + +/// The salt of an identifier. With caller entropy it is a fixed hash of that entropy (deterministic, +/// for tests and callers who pin output). Without it the salt is fresh: on WebAssembly from the +/// browser ([`browser_salt`], so two fresh web apps that write the same bytes cannot share an +/// identifier), and elsewhere from the standard library's random hash keys — seeded by the operating +/// system, so separate processes differ — mixed with a process-wide counter, which separates +/// successive calls in one process. +fn id_salt(opts: &SaveOptions) -> u64 { + use std::hash::BuildHasher; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + match &opts.id_entropy { + Some(entropy) => fnv1a(FNV_OFFSET, entropy), + None => { + #[cfg(target_arch = "wasm32")] + if let Some(salt) = browser_salt() { + return salt; + } + std::collections::hash_map::RandomState::new().hash_one(COUNTER.fetch_add(1, Ordering::Relaxed)) + } + } +} + +/// One byte from one `Math.random` draw: the draw (in [0, 1)) scaled to [0, 256), integer part +/// taken. The quotient is in [0, 256), so a saturating cast never wraps; a NaN or infinite draw +/// (which a conforming engine never returns) folds in as 0. No input can panic here. +#[cfg(any(target_arch = "wasm32", test))] +fn draw_byte(random: f64) -> u8 { + (random * 256.0).floor().rem_euclid(256.0) as u8 +} + +/// The `Math.random` fallback's mixing: one salt byte per draw (see [`draw_byte`]), folded through +/// FNV-1a exactly like the Web Crypto bytes, so all sixteen bytes of the fold carry entropy from +/// the draws. Pure and platform-independent, so the native test target can exercise it; on wasm +/// each draw comes from `Math.random`. +#[cfg(any(target_arch = "wasm32", test))] +fn salt_from_draws(draws: &[f64; 16]) -> u64 { + let mut bytes = [0u8; 16]; + for (byte, draw) in bytes.iter_mut().zip(draws) { + *byte = draw_byte(*draw); + } + fnv1a(FNV_OFFSET, &bytes) +} + +/// Fresh salt entropy from the browser, on WebAssembly. `crypto.getRandomValues` (Web Crypto, the +/// browser's secure generator, available in windows and workers alike) gives sixteen bytes, folded +/// into one salt. When that API is missing or refuses the call, `Math.random` is mixed instead +/// ([`salt_from_draws`]: sixteen draws, so the salt's full width carries entropy) — browsers seed +/// it afresh for every document, so a fresh instance still cannot reproduce another's salt. Only +/// where neither is reachable does the caller fall back to its `RandomState` salt, whose keys are +/// fixed at compile time on `wasm32-unknown-unknown` — the state before browser entropy was wired +/// in. +#[cfg(target_arch = "wasm32")] +fn browser_salt() -> Option { + use wasm_bindgen::JsCast; + let crypto = js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("crypto")) + .ok() + .filter(|crypto| !crypto.is_undefined() && !crypto.is_null()); + if let Some(crypto) = crypto { + let fill = js_sys::Reflect::get(&crypto, &wasm_bindgen::JsValue::from_str("getRandomValues")) + .ok() + .and_then(|fill| fill.dyn_into::().ok()); + if let Some(fill) = fill { + let bytes = js_sys::Uint8Array::new_with_length(16); + if fill.call1(&crypto, &bytes).is_ok() { + return Some(fnv1a(FNV_OFFSET, &bytes.to_vec())); + } + } + } + // Web Crypto is missing or refused the call: degrade to `Math.random` rather than giving up. + let mut draws = [0.0; 16]; + for draw in &mut draws { + *draw = js_sys::Math::random(); + } + Some(salt_from_draws(&draws)) +} + /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); @@ -711,6 +856,48 @@ mod tests { use proptest::prelude::*; + #[test] + fn fnv1a_is_the_published_64_bit_algorithm() { + // Published FNV-1a (64 bit) test vectors, so the identifier can be reproduced outside this crate. + assert_eq!(super::fnv1a(super::FNV_OFFSET, b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(super::fnv1a(super::FNV_OFFSET, b"foobar"), 0x8594_4171_f739_67e8); + } + + #[test] + fn math_random_draws_fill_the_whole_salt_width() { + // Draw i = (i + 1/2)/256 scales to the integer i, so the fold's byte i proves draw i landed + // there: all sixteen bytes carry their own draw's entropy (the fallback once shifted four + // draws into one word, leaving the other four bytes of the fold constant). + let draws: [f64; 16] = std::array::from_fn(|i| (i as f64 + 0.5) / 256.0); + let bytes: Vec = (0..16).collect(); + assert_eq!(super::salt_from_draws(&draws), super::fnv1a(super::FNV_OFFSET, &bytes)); + } + + #[test] + fn every_math_random_draw_reaches_the_salt() { + let base = [0.5; 16]; + let baseline = super::salt_from_draws(&base); + for i in 0..16 { + let mut draws = base; + draws[i] = 0.75; + assert_ne!(super::salt_from_draws(&draws), baseline, "draw {i} did not reach the salt"); + } + } + + #[test] + fn a_math_random_draw_outside_its_range_still_mixes_without_panicking() { + // A conforming `Math.random` stays in [0, 1), but the mixing must not panic outside it. + assert_eq!(super::draw_byte(0.0), 0); + assert_eq!(super::draw_byte(0.5), 128); + assert_eq!(super::draw_byte(255.5 / 256.0), 255); + assert_eq!(super::draw_byte(-0.5 / 256.0), 255); + assert_eq!(super::draw_byte(-1.0), 0); + assert_eq!(super::draw_byte(2.0), 0); + assert_eq!(super::draw_byte(f64::NAN), 0); + assert_eq!(super::draw_byte(f64::INFINITY), 0); + } + use super::*; use crate::Lexer; diff --git a/crates/cos/tests/file_identifier.rs b/crates/cos/tests/file_identifier.rs new file mode 100644 index 0000000000..4612df54f0 --- /dev/null +++ b/crates/cos/tests/file_identifier.rs @@ -0,0 +1,279 @@ +//! The file identifier (`/ID`, ISO 32000-2 §14.4) of a full save. An ordinary save keeps the +//! identifier the file has and writes the same bytes twice. A rewrite the document asked for (after +//! redaction or sanitizing) gives the file a new pair. An encrypted file keeps its first string, +//! because its file key is derived from it. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, Document, NewEncryption, Object, PdfString, SaveOptions, write_full, write_incremental}; +use pdfcraft_testkit::Pdf; + +const ID_HEX: &str = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; +const ID_BYTES: [u8; 16] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + +/// One page whose content holds a secret, with `entry` added to the trailer (for example an `/ID`). +fn source_with(entry: Option<&str>) -> Vec { + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", "BT /F1 12 Tf 20 100 Td (TOPSECRET) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + match entry { + Some(entry) => pdf.trailer_entry(entry).build(), + None => pdf.build(), + } +} + +/// [`source_with`] with the standard identifier, or none. +fn source(with_id: bool) -> Vec { + source_with(with_id.then_some(ID_HEX)) +} + +/// A one-page file whose 70,000-byte content stream ends in `last`. Two of these built with +/// different last bytes have the same length and differ only past the first 64 KiB. +fn large_source(last: u8) -> Vec { + let mut content = vec![b' '; 70_000]; + content.push(last); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .build() +} + +fn reopen(bytes: Vec) -> Document { + Document::open(Arc::new(bytes)).unwrap() +} + +/// The strings of the trailer's `/ID` (empty when there is none). +fn id_strings(doc: &Document) -> Vec> { + match doc.trailer().get(b"ID") { + Some(Object::Array(a)) => a.iter().map(|s| s.as_string().map(|s| s.bytes.clone()).unwrap_or_default()).collect(), + _ => Vec::new(), + } +} + +#[test] +fn an_ordinary_save_keeps_the_identifier_and_writes_the_same_bytes() { + let doc = reopen(source(true)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + assert_eq!(id_strings(&reopen(saved)), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); + let appended = reopen(write_incremental(&doc, &opts).unwrap()); + assert_eq!(id_strings(&appended), vec![ID_BYTES.to_vec(), ID_BYTES.to_vec()]); +} + +#[test] +fn an_ordinary_save_of_a_file_without_an_identifier_creates_the_same_one_every_time() { + let doc = reopen(source(false)); + let opts = SaveOptions::default(); + let saved = write_full(&doc, &opts).unwrap(); + assert_eq!(saved, write_full(&doc, &opts).unwrap(), "unchanged inputs produce identical bytes"); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn a_rewrite_the_document_asked_for_gets_a_new_identifier() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + assert!(doc.full_save_required() && doc.new_id_required()); + let opts = SaveOptions::default(); + for saved in [write_incremental(&doc, &opts).unwrap(), write_full(&doc, &opts).unwrap()] { + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2); + for id in &ids { + assert_eq!(id.len(), 16); + assert_ne!(id.as_slice(), ID_BYTES.as_slice(), "the copy must not claim the source's identifier"); + } + } + // Every save is a new identity, not the same one again. + let first = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + let second = id_strings(&reopen(write_full(&doc, &opts).unwrap())); + assert_ne!(first, second); +} + +#[test] +fn a_rewrite_without_an_identifier_still_gets_one() { + let mut doc = reopen(source(false)); + doc.require_full_save_with_new_id(); + let ids = id_strings(&reopen(write_full(&doc, &SaveOptions::default()).unwrap())); + assert_eq!(ids.len(), 2); + assert!(ids.iter().all(|s| s.len() == 16), "{ids:?}"); +} + +#[test] +fn caller_entropy_makes_the_new_identifier_reproducible() { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + let with = |entropy: u8| SaveOptions { id_entropy: Some([entropy; 16]), ..SaveOptions::default() }; + let first = write_full(&doc, &with(1)).unwrap(); + assert_eq!(first, write_full(&doc, &with(1)).unwrap(), "the same entropy gives the same bytes"); + let other = write_full(&doc, &with(2)).unwrap(); + assert_ne!(id_strings(&reopen(first)), id_strings(&reopen(other)), "different entropy gives a different identifier"); +} + +#[test] +fn an_encrypted_rewrite_keeps_the_first_identifier_string_its_key_depends_on() { + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let mut doc = reopen(source(true)); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let protected = write_full(&doc, &SaveOptions::default()).unwrap(); + let before = Document::open_with_password(Arc::new(protected), Some("user")).unwrap(); + + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + // The output opens with the same password only if its first identifier string is unchanged. + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let (old, new) = (id_strings(&before), id_strings(&back)); + assert_eq!(new.len(), 2, "{algorithm:?}"); + assert_eq!(new.first(), old.first(), "{algorithm:?}: the key depends on the first string"); + assert_ne!(new.get(1), old.get(1), "{algorithm:?}: the second string is new"); + } +} + +/// The `/ID` shapes whose key the reader takes as the empty string: none at all, one entry, and a +/// first entry that is not a string. +fn unusable_ids() -> Vec<(&'static str, Option)> { + let empty = Object::String(PdfString::literal(Vec::new())); + vec![ + ("absent", None), + ("one entry", Some(Object::Array(vec![empty.clone()]))), + ("first entry not a string", Some(Object::Array(vec![Object::Int(7), empty]))), + ] +} + +/// An encrypted file whose key is derived from the empty string, with its trailer `/ID` replaced by `id`. +fn protected_with_id(algorithm: Algorithm, id: Option) -> Vec { + let mut doc = reopen(source_with(Some("/ID [<> <>]"))); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + match id { + Some(id) => doc.trailer_mut().set(b"ID".to_vec(), id), + None => { + doc.trailer_mut().remove(b"ID"); + } + } + write_full(&doc, &SaveOptions::default()).unwrap() +} + +#[test] +fn an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one() { + // The key is derived from the first string of /ID, and a missing /ID, a one-entry /ID and a + // first entry that is not a string all count as the empty string. The rewrite keeps that + // (empty) first string, so the file still opens with its password, and writes a new second one. + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + for (shape, id) in unusable_ids() { + let before = Document::open_with_password(Arc::new(protected_with_id(algorithm, id)), Some("user")).unwrap(); + let mut doc = before.clone(); + doc.require_full_save_with_new_id(); + let out = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open_with_password(Arc::new(out), Some("user")).unwrap(); + let ids = id_strings(&back); + let previous = id_strings(&before); + assert_eq!(ids.len(), 2, "{algorithm:?}, {shape}: the rewrite writes a pair"); + assert!(ids.first().is_some_and(|s| s.is_empty()), "{algorithm:?}, {shape}: the first string is the one the key uses"); + assert_eq!(ids.get(1).map(Vec::len), Some(16), "{algorithm:?}, {shape}"); + assert_ne!(ids.get(1), previous.get(1), "{algorithm:?}, {shape}: the second string is new"); + } + } +} + +/// The identifier of [`fixed_entropy_rewrite`]. It was computed once with the FNV-1a formula in the +/// writer; it must not change with the platform or the toolchain. +const GOLDEN_FIXED_ENTROPY_ID: [u8; 16] = [0x32, 0x7d, 0x91, 0x46, 0xb8, 0x6d, 0xb3, 0xe1, 0x97, 0xc3, 0x5e, 0xdc, 0x58, 0x48, 0x3f, 0xba]; + +/// The standard fixture rewritten for a new identifier, with `id_entropy` of 0x42. +fn fixed_entropy_rewrite() -> Vec { + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + write_full(&doc, &SaveOptions { id_entropy: Some([0x42; 16]), ..SaveOptions::default() }).unwrap() +} + +#[test] +fn a_fixed_entropy_gives_the_same_identifier_on_every_platform() { + // The identifier is FNV-1a over fixed-width big-endian fields and the written file, so the same + // entropy and bytes give the same 16 bytes on every platform and toolchain. The value also pins + // this writer's output for the fixture. + let golden = GOLDEN_FIXED_ENTROPY_ID.to_vec(); + assert_eq!(id_strings(&reopen(fixed_entropy_rewrite())), vec![golden.clone(), golden]); +} + +#[test] +fn the_identifier_depends_on_the_whole_rewritten_file() { + // Same caller entropy and same length; the two rewrites differ only in one byte of a stream + // that lies past the first 64 KiB of the output. Their identifiers must still differ. + let opts = SaveOptions { id_entropy: Some([9; 16]), ..SaveOptions::default() }; + let rewrite = |last: u8| { + let mut doc = reopen(large_source(last)); + doc.require_full_save_with_new_id(); + write_full(&doc, &opts).unwrap() + }; + let (a, b) = (rewrite(b'A'), rewrite(b'B')); + assert_eq!(a.len(), b.len(), "the two rewrites have the same length"); + let first_difference = a.iter().zip(&b).position(|(x, y)| x != y).unwrap_or(usize::MAX); + assert!(first_difference > 64 * 1024, "the rewrites first differ at byte {first_difference}, inside the first 64 KiB"); + assert_ne!(id_strings(&reopen(a)), id_strings(&reopen(b)), "content past 64 KiB must change the identifier"); +} + +/// Where [`stand_in_for_a_fresh_process`] writes its rewrite. +const ID_PROBE: &str = "PDFCRAFT_TEST_ID_PROBE"; + +/// The child half of [`separate_fresh_processes_get_different_identifiers`]: it runs alone, in its +/// own process, and writes a default-path rewrite of the standard fixture to the file the parent +/// named. Without the variable it is a plain no-op. +#[test] +fn stand_in_for_a_fresh_process() { + let Some(path) = std::env::var_os(ID_PROBE) else { return }; + let mut doc = reopen(source(true)); + doc.require_full_save_with_new_id(); + std::fs::write(path, write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); +} + +#[test] +fn separate_fresh_processes_get_different_identifiers() { + // The default identifier is meant to differ between fresh instances of the whole save pipeline, + // not only between two calls in one process (which the counter already separates). On the native + // test target a fresh instance is a fresh process, whose hash keys the operating system seeds; + // the fix under test runs on WebAssembly, where no browser test harness exists — there the + // browser's `crypto.getRandomValues` stands in for this seeding, and this test cannot see it. + let exe = std::env::current_exe().unwrap(); + let probe = |name: &str| { + let path = std::env::temp_dir().join(format!("pdfcraft-id-probe-{}-{name}.pdf", std::process::id())); + let out = std::process::Command::new(&exe) + .args(["--exact", "stand_in_for_a_fresh_process", "--nocapture", "--"]) + .env(ID_PROBE, &path) + .output() + .expect("run this test's own binary as a child"); + assert!(out.status.success(), "{name}: the child failed: {}", String::from_utf8_lossy(&out.stderr)); + let saved = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: the child wrote its file: {e}")); + let ids = id_strings(&reopen(saved)); + assert_eq!(ids.len(), 2, "{name}"); + assert!(ids.iter().all(|s| s.len() == 16), "{name}: {ids:?}"); + let _ = std::fs::remove_file(&path); + ids + }; + let (first, second) = (probe("a"), probe("b")); + assert_ne!(first, second, "two fresh processes must not share an identifier"); +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 41ac47357e..b6a8885c03 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -554,8 +554,9 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co if residue > 0 { return Err(RedactError::Residue(residue)); } - // The previous revision still holds the removed content: the next save must rewrite. - doc.require_full_save(); + // The previous revision still holds the removed content: the next save must rewrite, and the + // rewritten file is a different file from its source, so it gets a new identifier. + doc.require_full_save_with_new_id(); Ok(report) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 6fd4354300..f8088e0bba 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -674,16 +674,21 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result