diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56..a37cec4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. - **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. diff --git a/Cargo.toml b/Cargo.toml index 99f9fa251..24313021b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 1821f8582..62993ad6d 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -1,6 +1,8 @@ use super::commands::{parse_dpi, read}; use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; /// Audit a document for faux redactions. Prints the findings as JSON; fails when any are /// found so scripts and CI can gate on it, and when the audit itself cannot run. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 5efec8331..9d0b2057f 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -2,7 +2,7 @@ use super::options::{ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, validate_options, }; -use super::*; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs index ab1a3db34..1ddbfabcd 100644 --- a/apps/pdfcraft-cli/src/options.rs +++ b/apps/pdfcraft-cli/src/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::CliError; pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs index 19eed2187..0726087d9 100644 --- a/apps/pdfcraft-cli/src/run.rs +++ b/apps/pdfcraft-cli/src/run.rs @@ -1,7 +1,9 @@ #[cfg(feature = "mcp")] use super::options::MCP_OPTIONS; use super::options::{RUN_OPTIONS, flag, positional, validate_options}; -use super::*; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; // ---- automation -------------------------------------------------------------------------------- diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index 9d1223352..dc419171b 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -1,5 +1,5 @@ use super::options::{UI_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{CliError, Duration, stdout_line}; // ---- UI control channel client ----------------------------------------------------------------- diff --git a/clippy.toml b/clippy.toml index e908d1358..31e54da30 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,6 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, diff --git a/crates/annot/README.md b/crates/annot/README.md index 810820183..999baec20 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 508d2bee4..abc746c0d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -373,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -390,520 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); - } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - 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"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) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - 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 - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - 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))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - 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(text_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); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); } } - Some(form(rect, &out, res)) + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } + } + } + Some(()) +} + +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + Some(()) +} + +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); + } + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); + } + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { + return None; + } + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; + } + } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; + } + Some(names) + } + } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); + } + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + 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"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) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + 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 + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + 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))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + 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(text_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); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); + let mut out = c.into_bytes(); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); + out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; + } + out.extend_from_slice(b"ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + Some(stream) +} + +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index d49224cd9..3b3366252 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Object, 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 diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index bedd1c6f1..a6db5b940 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,8 +1,10 @@ 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::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style}; -use super::*; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; pub(crate) fn num_array(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) @@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { let page = page_ref(doc, new.page)?; let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } + validate_style(style)?; 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); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } + set_non_opaque_style(&mut d, opacity); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), Shape::Rectangle { .. } | Shape::Oval { .. } => { d.set(b"C".to_vec(), rgb(style.color)); if let Some(f) = style.fill { @@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let mut list = annots(doc, page); let index = list.len(); list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } + add_popup(doc, r, popup, &mut list)?; set_annots(doc, page, list)?; Ok(index) } +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +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(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 189845441..5e698332d 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -2,7 +2,7 @@ use super::create::{finite, num_array}; use super::lookup::{annot_dict, annots, page_refs}; use super::modify::refuse_foreign_text; use super::types::NOTE_SIZE; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index bea14d9ee..b56e0de20 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -3,7 +3,7 @@ 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::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 1d85d0bd6..e9e0359ce 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,7 +41,7 @@ pub use modify::{ }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, rect_quad, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 00ce23c37..c81ec78ef 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,6 +1,6 @@ use super::info::text_value; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; // ── pages and /Annots ─────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 992b3d0c5..fc8134807 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -6,8 +6,9 @@ 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, }; -use super::*; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; /// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would /// replace those characters with `?`, so the edit is refused before the dictionary changes. @@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 { } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { +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)?; unlocked(doc, r)?; let d = annot_dict(doc, r); @@ -521,7 +513,7 @@ pub fn set_style( if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { return Err(AnnotError::Invalid("invalid style".into())); } - if let Some(ends) = endings { + if let Some(ends) = &endings { let callout = subtype == "FreeText" && d.contains(b"CL"); let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { @@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that /// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id /// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( doc, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb..eb8d1a0f1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { 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_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { 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 i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 632e990fe..bd3409b5a 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::*; +use super::{ObjRef, appearance}; pub type Rgb = [f64; 3]; @@ -563,6 +563,33 @@ pub struct Style { pub fill: Option, } +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + impl Default for Style { fn default() -> Self { Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f623ef714..f2c9f6b77 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5fb14f136..7272a8d66 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; /// A script's request to the viewer as the JSON that running a script reports; a side panel /// goes by its fixed id. diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index afc671a8c..82e521ddd 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 40ae65ea8..deb2efa18 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based, summary}; use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; -use super::*; +use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json}; impl Automation { pub(crate) fn doc_create(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 26e1f9536..87f47e8eb 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; use super::paths::{read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; /// One piece of a tool's result. #[derive(Clone, Debug, PartialEq)] @@ -742,7 +742,7 @@ impl Automation { let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) .map_err(failed)? .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) })) .collect(); #[cfg(not(target_os = "macos"))] let ids: Vec = Vec::new(); @@ -755,7 +755,7 @@ impl Automation { let ids = listing .ids .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) })) .collect(); let unusable = listing .unusable diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 3ce312f39..476c45102 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c8710aa50..6f0884226 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -1,5 +1,5 @@ use super::error::{Result, ToolError}; -use super::*; +use super::{Document, Value, comments, json}; /// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at /// most 14 400 across). diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index d9ea9e5c4..647a72e1d 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,7 +1,7 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based}; -use super::*; +use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. pub(crate) const DEFAULT_DPI: f64 = 96.0; diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs index fb514df69..e13d0a145 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::*; +use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9a..78038d8eb 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2030,21 +2030,33 @@ fn comments_through_tools() { let dir = workdir("comments"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = add_comment_examples(&mut a, doc); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = verify_comment_listing(&list, &id); + edit_rectangle_comment(&mut a, doc, &rect); + edit_text_comment(&mut a, doc, &list); + verify_comment_errors(&mut a, doc, &id); + save_and_reopen_comments(&dir, &mut a, doc); +} + +fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. - let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); + let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); assert_eq!(hl["comment"]["lines"], 1); let id = hl["comment"]["id"].as_str().unwrap().to_string(); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); - ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); - ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); + ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); + ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); + ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + id +} - let list = ok(&mut a, "comment_list", json!({ "doc": doc })); +fn verify_comment_listing(list: &Value, id: &str) -> Value { assert_eq!(list["count"], 6, "{list}"); - let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone(); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check"))); assert_eq!(c["status"], "Accepted"); assert_eq!(c["replies"].as_array().unwrap().len(), 1); @@ -2057,33 +2069,38 @@ fn comments_through_tools() { assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0])); let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone(); assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point"); + rect +} +fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) { // Edit by page + index; several changes are one undo step. - let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap()); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap()); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone(); assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved"))); assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0])); - let undo = ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undo = ok(a, "edit_undo", json!({ "doc": doc })); assert_eq!(undo["undone"], "Edit comment"); - ok(&mut a, "edit_redo", json!({ "doc": doc })); + ok(a, "edit_redo", json!({ "doc": doc })); // Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none. - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_)))); - let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); + let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); assert!(matches!( a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })), Err(ToolError::Failed(_)) )); +} +fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) { // Editing a text box re-fits its rectangle to the new text: the wrap width and top edge // stay, the height follows the wrapped lines. - let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); + let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let long = "the quick brown fox jumps over the lazy dog ".repeat(3); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}"); @@ -2092,9 +2109,11 @@ fn comments_through_tools() { // The rendered page shows the rectangle's border. let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); +} - ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5); +fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) { + ok(a, "comment_delete", json!({ "doc": doc, "id": id })); + assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5); assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_)))); @@ -2103,10 +2122,11 @@ fn comments_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_)))); +} - // Comments survive a save and reopen. - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); - let mut b = auto(&dir); +fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) { + ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); + let mut b = auto(dir); let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } @@ -2809,6 +2829,12 @@ fn accessibility_check_report_and_fixes_through_tools() { let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap(); assert!(manual_html.contains("

Needs manual check: 1

")); assert!(!manual_html.contains("no problems")); +} + +#[test] +fn accessibility_figure_tags_and_alternative_text_through_tools() { + let dir = workdir("a11y-figures"); + let mut a = auto(&dir); // Figures: list, describe, mark decorative. std::fs::write( dir.join("figures.pdf"), @@ -5260,6 +5286,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { + let root = local_gotor_fixtures(); + let mut a = auto(&root); + assert_relative_gotor_destination(&mut a); + assert_absolute_gotor_policy(&mut a); + assert_unapplied_gotor_destinations(&mut a); + assert_encrypted_gotor_passwords(&mut a); + assert_unsupported_gotor_targets(&mut a); + assert_gotor_root_boundaries(&mut a); + assert_url_links_cannot_open_as_gotor(&mut a); +} + +fn local_gotor_fixtures() -> PathBuf { let (base, root) = sandbox("gotor-open"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("target.pdf"), fixture(3)).unwrap(); @@ -5277,12 +5315,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap(); link_dir(&root, "outside-link", &base.join("outside")); std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap(); + root +} - let mut a = auto(&root); - let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); - let listed = ok(&mut a, "link_list", json!({ "doc": source })); +fn assert_relative_gotor_destination(a: &mut Automation) { + let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(a, "link_list", json!({ "doc": source })); assert_eq!(listed["links"][0]["other"], "GoToR"); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination_applied"], true); assert_eq!(opened["destination"]["page"], 3); @@ -5290,51 +5330,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(opened["document"]["pages"], 3); let target_id = opened["document"]["doc"].as_u64().unwrap(); assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); +} - let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); +fn assert_absolute_gotor_policy(a: &mut Automation) { + let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); let before = a.session().docs().len(); for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { - assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); assert_eq!(a.session().docs().len(), before); } - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); + let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); +} +fn assert_unapplied_gotor_destinations(a: &mut Automation) { for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["destination_applied"], false); assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}"); } +} - let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); +fn assert_encrypted_gotor_passwords(a: &mut Automation) { + let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); + let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); assert_eq!(opened["document"]["encrypted"], true); +} +fn assert_unsupported_gotor_targets(a: &mut Automation) { for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}"); } +} +fn assert_gotor_root_boundaries(a: &mut Automation) { for source_path in ["sub/escape.pdf", "symlink.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened"); } +} - let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); +fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) { + let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); assert!( - matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) + matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) ); } @@ -5887,8 +5939,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); let mut a = auto(&dir); + create_signing_identity(&mut a); + let doc = sign_document_and_verify_trust(&mut a, &dir); + verify_signature_revisions_and_bundle(&mut a, doc, &dir); +} + +fn create_signing_identity(a: &mut Automation) { let id = ok( - &mut a, + a, "sign_id_create", json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); @@ -5897,13 +5955,15 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); // Eight characters at least (#1847): seven is refused. assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); +} - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); +fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { + let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0); // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); - let store = ok(&mut a, "sign_windows_ids", json!({})); + let store = ok(a, "sign_windows_ids", json!({})); let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); @@ -5917,7 +5977,7 @@ fn digital_ids_signing_and_validation_through_tools() { Err(ToolError::InvalidArgs(_)) )); let r = ok( - &mut a, + a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); @@ -5929,33 +5989,37 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); + let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); - let list = ok(&mut a, "sign_list", json!({ "doc": doc })); + let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); - let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]; + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); + let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); + doc +} + +fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) { // A full rewrite is refused for a signed document. assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_)))); - ok(&mut a, "doc_save", json!({ "doc": doc })); + ok(a, "doc_save", json!({ "doc": doc })); // Three revisions: the original, the signature, the comment; the middle one is signed. - let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); + let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); assert_eq!(revs.len(), 3, "{revs:?}"); assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}"); - let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1); + let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); + assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1); assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); - assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. use pdfcraft_engine::sign::{cms, der, pkcs12}; let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); - assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); + assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ff729f1f..8744a3ea3 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::*; +use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index c90d5c090..d41d564e3 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,7 @@ use super::reader::{find, generated_id}; use super::state::Credentials; use super::xref::XrefIndex; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index ead09c36e..66129c142 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -3,7 +3,7 @@ use super::state::SECTION_READS; use super::xref::{ MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, }; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared}; impl Document { #[allow(clippy::type_complexity)] diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 30f193e4e..b4323f982 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -1,5 +1,5 @@ use super::xref::XrefEntry; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 96a782c1a..02390ff7d 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Lexer}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum XrefEntry { diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 0fc7aa510..61297bcf2 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -2,7 +2,10 @@ use super::layout::{ Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped, }; use super::types::{AddedText, Align, Family}; -use super::*; +use super::{ + Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, + literal, matrix_number, n, shaped_glyph, win_ansi, +}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 0e971b92c..67d80be40 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,5 +1,8 @@ use super::types::{AddedImage, AddedText}; -use super::*; +use super::{ + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, + shape_thai, +}; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index b89d42900..5ce5e15d1 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -1,7 +1,7 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; -use super::*; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged}; fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 0a9f92f92..28b61d586 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -1,5 +1,5 @@ use super::layout::norm; -use super::*; +use super::{Content, MarkKind, ObjRef, Std14}; pub(super) const TAG: &str = "Added"; diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 1461dc78e..427f51f94 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::*; +use super::{Document, EditError, Object, added, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 507e2afa8..00c53ec27 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EditError, Object, Rgb, added}; /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index b6d50a646..2f2c24c86 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ use super::marks::{MarkKind, matrix_number, n}; -use super::*; +use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0f8adc651..7c78d31fc 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6664,12 +6664,124 @@ fn actual_text_pairs_past_the_marked_content_cap() { } } -/// #2992: the colour a page is painted with before anything else is its background, for exports -/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes -/// behind; a page that paints anything else first, a fill that doesn't cover the page, a -/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap() +} + +fn assert_page_background_fill_cases(gs: &str) { + assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "one of the rectangles covers it" + ); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), + Some([1.0, 0.0, 0.0]), + "nonzero, same winding: no hole" + ); + assert_eq!( + page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), + None, + "reversed by a mirroring cm" + ); + assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), + None, + "a page fill with a hole cut in it" + ); +} + +fn assert_page_background_transform_and_clip_cases() { + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), + None, + "a turned fill short of the corners" + ); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned fill over them" + ); + // The same for a clip. + assert_eq!( + page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + None, + "a turned clip" + ); + assert_eq!( + page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "two covering clips" + ); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + None, + "the second clip doesn't cover" + ); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +/// #2992: page background detection follows the first full-page fill under text and ignores +/// partial, translucent, clipped, or unsupported fills. #[test] fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; let mut doc = fixture(); assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); @@ -6680,77 +6792,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); assert!(page_background(&doc, 9).is_err()); - let page = |content: &str, resources: &str| { - let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); - page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() - }; - let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; - assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); - assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); - assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); - assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); - // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the - // other way under nonzero. - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); - assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); - assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); - assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); - assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); - assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); - assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); - assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); - assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); - // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop - // box's corners are inside it; its bounding box covering the crop box isn't enough. This one - // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but - // 354 along the diagonals, short of the corners. One reaching 707 covers them. - let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; - assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); - assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); - // The same for a clip. - assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); - assert_eq!( - page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), - Some([1.0, 0.0, 0.0]), - "a turned clip over the corners" - ); - // Clips add up: two that each cover the page leave it covered, one that doesn't among them - // doesn't. - assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); - assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); - // Past 64 clipping rectangles the clip counts as unknown. - let clips = "0 0 600 800 re W n ".repeat(65); - assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); - // A rectangle drawn the other way round (negative width) covers the page all the same. - assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); - // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the - // edge of that included. - assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); - assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); - assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); + assert_page_background_fill_cases(gs); + assert_page_background_transform_and_clip_cases(); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 8d3d224da..615e7699a 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,7 +1,7 @@ use super::interp::{Carry, interpret}; use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; -use super::*; +use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; /// An editable text region: adjacent runs on visual rows, followed by aligned rows. /// A region may contain several source fonts, sizes, colours and weights. diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index b2bd2b9f9..f7aa1f47b 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::*; +use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; /// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and /// are never installed, copied into assets, or used as a font for another document. diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 0c19a6680..b8f45a221 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,6 +1,8 @@ use super::interp::Ts; use super::lines::page_dict; -use super::*; +use super::{ + CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, +}; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. pub(super) type Encoder = Box Option)>>>; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index ca0d63cef..e4248aede 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -1,6 +1,6 @@ use super::fallback::is_type3_fallback; use super::rewrite::vertical_advance; -use super::*; +use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc}; /// Text and graphics state captured for rewriting. #[derive(Clone, Debug, PartialEq)] @@ -472,6 +472,280 @@ type CachedFont = (Rc, Option, Result, String>); /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); +#[derive(Default)] +struct MarkedContent { + regions: Vec>, + layers: Vec>>, + actual_text: Vec>, +} + +impl MarkedContent { + fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) { + // Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and + // inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + self.layers + .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value { + Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()), + _ => None, + }); + self.actual_text.push(logical.map(|text| (shown_count, index, text))); + } + + fn end(&mut self, index: usize, shown: &mut Vec) { + self.regions.pop(); + self.layers.pop(); + if let Some(Some((start, open, logical))) = self.actual_text.pop() { + let tail = shown.split_off(start.min(shown.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); + } + } + } +} + +struct TextRunState<'a> { + ts: &'a mut Ts, + tm: &'a mut Matrix, + tlm: &'a mut Matrix, + at_bt: &'a (usize, Ts), + bt: usize, + marked: &'a MarkedContent, + carry: &'a mut Carry, + out: &'a mut Vec, +} + +fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { + let ts = &mut *state.ts; + let tm = &mut *state.tm; + let tlm = &mut *state.tlm; + let at_bt = state.at_bt; + let bt = state.bt; + let marked = state.marked; + let carry = &mut *state.carry; + let out = &mut *state.out; + if matches!(op.op.as_slice(), b"'" | b"\"") { + if op.is("\"") { + ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); + ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); + } + *tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm); + *tm = *tlm; + } + let Some((name, m)) = ts.font.clone() else { return Ok(()) }; + // Pieces: strings, and TJ number adjustments. + let pieces: Vec = match op.op.as_slice() { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; + let trm0 = tm.then(&ts.ctm); + let start = trm0.apply(0.0, ts.rise); + let mut text = String::new(); + let mut decodable = true; + let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; + let mut glyph_count = 0_usize; + for p in &pieces { + match p { + Object::String(s) => { + for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); + let shown = m.text_of(code); + let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } + None => decodable = false, + } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + x_text += w * ts.scale; + } + } + other => { + if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } + let dx = -n / 1000.0 * ts.size * ts.scale; + // A large gap inside TJ reads as a space. + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } + x_text += dx; + } + } + } + } + *tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm); + let end = tm.then(&ts.ctm).apply(0.0, ts.rise); + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)], + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); + let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); + let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + return Ok(()); + } + out.push(Shown { + op: i, + extra_ops: Vec::new(), + glyph_count, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, + tracking: 0.0, + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), + state: ts.clone(), + tlm: *tlm, + bt_op: at_bt.0, + bt_state: at_bt.1.clone(), + text, + rect, + frame, + ink, + baseline: start.1, + start_x: start.0, + end_x: end.0, + end_y: end.1, + bt, + font: name, + base_font: m.base_font.clone(), + face: ts.face.clone(), + size: if size_user > 0.0 { size_user } else { ts.size }, + bold: m.bold, + italic: m.italic, + visual_order: m.visual_order, + decodable, + scope: marked.regions.last().copied().flatten(), + paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, + logical_order: false, + actual_text: false, + }); + Ok(()) +} + pub(super) fn interpret( doc: &Document, ops: &[Op], @@ -490,66 +764,17 @@ pub(super) fn interpret( let mut stack: Vec = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; - let mut marked = std::mem::take(&mut carry.marked); - let mut layers = std::mem::take(&mut carry.layers); - let mut actual: Vec> = Vec::new(); + let mut marked = + MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - // A layer named in the resources is told apart by its name, an inline one by where it opens. - let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); - layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); - // The property list is inline or named in the resources' /Properties (§14.6.2). - let list = logical_text.and_then(|properties| match op.operands.get(1) { - Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), - other => other.and_then(Object::as_dict).cloned(), - }); - let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(logical.map(|s| (out.len(), i, s))); + marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { - marked.pop(); - layers.pop(); - if let Some(Some((start, open, logical))) = actual.pop() { - let tail = out.split_off(start.min(out.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for shown in items { - first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); - first.extra_ops.push(shown.op); - first.extra_ops.extend(shown.extra_ops); - first.rect = [ - first.rect[0].min(shown.rect[0]), - first.rect[1].min(shown.rect[1]), - first.rect[2].max(shown.rect[2]), - first.rect[3].max(shown.rect[3]), - ]; - first.frame = union(first.frame, shown.frame); - first.ink.extend(shown.ink); - } - first.extra_ops.extend([open, i]); - // Only right-to-left ActualText is already in typing order and must stay - // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures - // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - out.push(first); - } - } + marked.end(i, &mut out); } b"q" => stack.push(ts.clone()), b"Q" => { @@ -650,207 +875,16 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - if matches!(op.op.as_slice(), b"'" | b"\"") { - if op.is("\"") { - ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); - ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - let Some((name, m)) = ts.font.clone() else { continue }; - // Pieces: strings, and TJ number adjustments. - let pieces: Vec = match op.op.as_slice() { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569) -- not the first, which a reader counting operands - // from the front of a surplus list would otherwise take. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Tc is already included in the pen advance; TJ may encode tracking instead. - let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; - let trm0 = tm.then(&ts.ctm); - let start = trm0.apply(0.0, ts.rise); - let mut text = String::new(); - let mut decodable = true; - let mut x_text = 0.0; - // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred - // on it horizontally; `half` is half the widest glyph. - let vertical = ts.vertical; - let (mut y_text, mut half) = (0.0, 0.0_f64); - // Where each word's glyphs start and end along the run (text space), and whether - // the next glyph continues the last word. - let mut words: Vec<(f64, f64)> = Vec::new(); - let mut in_word = false; - let mut glyph_count = 0_usize; - for p in &pieces { - match p { - Object::String(s) => { - for (code, len) in m.codes(&s.bytes) { - glyph_count = glyph_count.saturating_add(1); - let shown = m.text_of(code); - let blank = - m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); - match shown { - Some(t) => { - carry.text_left = carry - .text_left - .checked_sub(t.len()) - .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); - } - None => decodable = false, - } - let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - if let Some(w1) = vertical { - half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); - if !blank { - let ink_end = y_text + w1 * ts.size; - let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - y_text += w1 * ts.size + spacing; - continue; - } - if !blank { - let ink_end = x_text + m.width(code) * ts.size * ts.scale; - // A negative font size or `Tz` runs the glyphs backwards: - // the word is the union of its glyphs, whichever way they go. - let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - x_text += w * ts.scale; - } - } - other => { - if let Some(n) = other.as_f64() { - if vertical.is_some() { - // Down by n/1000 em, not scaled by `Tz`. - let dy = -n / 1000.0 * ts.size; - if -dy > ts.size * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - if dy.abs() > ts.size.abs() * 0.2 { - in_word = false; - } - y_text += dy; - continue; - } - let dx = -n / 1000.0 * ts.size * ts.scale; - // A large gap inside TJ reads as a space. - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - // Tracking and kerning keep a word together; a larger jump - // ends it (whatever the sign of the size or `Tz`). - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { - in_word = false; - } - x_text += dx; - } - } - } - } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm); - let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box from `a` to `b` along the run: from descent to ascent across it, or for - // vertical text, the widest glyph's width centred on the pen. - let span = |a: f64, b: f64| match vertical { - Some(_) => { - [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)] - } - None => [ - trm0.apply(a, ts.rise + m.descent * ts.size), - trm0.apply(b, ts.rise + m.descent * ts.size), - trm0.apply(a, ts.rise + m.ascent * ts.size), - trm0.apply(b, ts.rise + m.ascent * ts.size), - ], - }; - let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); - let rect = corners - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - // The same corners along and across the baseline: the run's own box, not its - // upright bounding box turned (which is wider by the box's height on a slant). - let dir = direction(&trm0); - let frame = corners - .iter() - .map(|&p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - let ink = words - .iter() - .map(|&(a, b)| { - span(a, b) - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) - }) - .collect(); - let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; - // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { - continue; - } - out.push(Shown { - op: i, - extra_ops: Vec::new(), - glyph_count, - // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the - // preview divides by size × `Tz`, so `Tz` is put back here. - advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, - tracking: 0.0, - tm: Matrix([ - tm.0[0], - tm.0[1], - tm.0[2], - tm.0[3], - tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], - tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], - ]), - state: ts.clone(), - tlm, - bt_op: at_bt.0, - bt_state: at_bt.1.clone(), - text, - rect, - frame, - ink, - baseline: start.1, - start_x: start.0, - end_x: end.0, - end_y: end.1, - bt, - font: name, - base_font: m.base_font.clone(), - face: ts.face.clone(), - size: if size_user > 0.0 { size_user } else { ts.size }, - bold: m.bold, - italic: m.italic, - visual_order: m.visual_order, - decodable, - scope: marked.last().copied().flatten(), - paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, - logical_order: false, - actual_text: false, - }); + let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + show_text(op, i, &mut state)?; } _ => {} } } carry.ts = ts; carry.stack = stack; - carry.marked = marked; - carry.layers = layers; + carry.marked = marked.regions; + carry.layers = marked.layers; positioned_tracking(&mut out); Ok(out) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 7543adeb3..c48522984 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; -use super::*; +use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { /// The baseline's height in user space. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index dd58c7289..513077a02 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,11 +1,14 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, - source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, + source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; -use super::interp::{Carry, interpret}; +use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; -use super::*; +use super::{ + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse, + rewrite_styled_block, +}; /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] @@ -206,6 +209,532 @@ pub(crate) fn rewrite_block_with_limit( TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } +struct RewriteDispatch { + upright: bool, + use_styled_path: bool, +} + +fn choose_rewrite_path( + doc: &Document, + fonts: &Dict, + block: &TextBlock, + members: &[&TextLine], + first: &TextLine, + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let registered_face = fonts.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order by the single-style path. Styled regions do not + // reorder right-to-left runs. + let hebrew = text.unwrap_or(&block.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + // Turned text is rewritten in its own text space by the single-style path. Styled layout uses + // page x-coordinates and cannot safely apply those operations to rotated runs. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } + let visual_indic = members.iter().any(|line| line.visual_order); + let has_special_text = text.is_some_and(|draft| { + draft.contains(['\n', '\r']) + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() + }); + let has_multiple_runs = block.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color); + let source_requires_styled_path = + has_special_text || registered_face || text.is_some_and(|draft| source_program_completes(doc, fonts, first, draft)) || has_multiple_runs; + let explicit_styled_text = !style.ranges.is_empty() || style.resource.is_some(); + Ok(RewriteDispatch { + upright, + use_styled_path: upright + && !hebrew + && !visual_indic + && (explicit_styled_text || (source_requires_styled_path && style.family.is_none() && style.bold.is_none())), + }) +} + +/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed +/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are +/// breaks too; other white space is written as one space. +fn paragraph_text(text: &str) -> String { + text.replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect() +} + +/// Where a paragraph sits on its page, for choosing the width it is laid out in. +struct ParaGeometry { + rect: [f64; 4], + frame: [f64; 4], + crop: [f64; 4], + upright: bool, + /// The text-to-user scale. + k: f64, + /// The size in text space. + size: f64, + /// The requested horizontal move, in points. + dx: f64, + single_line: bool, + anchored_right: bool, + centred: bool, +} + +impl ParaGeometry { + /// A paragraph keeps its width (or takes the one asked for); a single line grows to the right + /// (to the left when right-to-left), up to the page's margin. + fn width(&self, requested: Option) -> Result { + let [left, _, right, _] = self.rect; + let own = (right - left).max(self.size * self.k); + let width = match requested { + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((self.size * self.k).min(14_400.0), 14_400.0), + Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !self.upright => (self.frame[2] - self.frame[0]).max(self.size * self.k), + None if self.anchored_right => own.max(right + self.dx - (self.crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if self.single_line && !self.centred => own.max(self.crop[2] - 36.0 - (left + self.dx)), + None => own, + }; + if width.is_finite() { Ok(width) } else { Err(EditError::Invalid("the paragraph's width must be a number".into())) } + } +} + +/// How far a baseline starting at `origin` and running along `direction` goes before it is 36 pt +/// from the page edge `low` or `high`. +fn margin_reach(origin: f64, direction: f64, low: f64, high: f64) -> f64 { + if direction > 1e-6 { + (high - 36.0 - origin) / direction + } else if direction < -1e-6 { + (low + 36.0 - origin) / direction + } else { + f64::INFINITY + } +} + +/// A turned single line grows to fit, up to where its baseline reaches the page's margin. +fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: f64, text: &str, advance: &Advance<'_>) -> f64 { + let (x0, y0) = (first.origin.x + offset[0], first.origin.baseline + offset[1]); + let (ux, uy) = first.direction(); + let tx = margin_reach(x0, ux, crop[0], crop[2]); + let ty = margin_reach(y0, uy, crop[1], crop[3]); + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(|line| advance.of(line)).fold(width, f64::max).min(cap) +} + +/// The widths of the paragraph's text in the font it will be written in, in user space. +struct Advance<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + family: crate::added::Family, + bold: bool, + italic: bool, + size: f64, + space: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + k: f64, +} + +impl Advance<'_> { + fn of(&self, s: &str) -> f64 { + let extra = s.chars().count() as f64 * self.char_spacing; + let t = match (self.source, self.type3) { + (Some(m), _) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, self.size, self.space, self.char_spacing, self.word_spacing) + } + (None, Some(fallback)) => { + // Each character's count in `s`, from one pass over `s`. + let mut counts: HashMap = HashMap::new(); + for c in s.chars() { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + } + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + } + (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, + }; + t * self.scale * self.k + } +} + +/// The font a rewritten paragraph is shown in, and how its text is encoded. +struct FontChoice { + show_font: String, + encode: Encoder, + /// The stand-in face to tell the caller about. + substituted: Option, +} + +/// What [`choose_font`] needs to know about the paragraph. +struct FontInputs<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + font_name: &'a str, + flat: &'a str, + source_base_font: &'a str, + family: crate::added::Family, + bold: bool, + italic: bool, + family_requested: bool, +} + +fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result { + if let Some(m) = inputs.source.cloned() { + let name = inputs.font_name.to_owned(); + return Ok(FontChoice { + show_font: name.clone(), + encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])), + substituted: None, + }); + } + if let Some(fallback) = inputs.type3.cloned() { + let substituted = Some(format!("{} Type3", fallback.family)); + let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + return Ok(FontChoice { show_font: first, encode: Box::new(move |s: &str| type3_encode(&fallback, s)), substituted }); + } + let flat = inputs.flat; + let win = pdfcraft_fonts::win_ansi(flat); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + let base = inputs.family.base_font(inputs.bold, inputs.italic); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", inputs.source_base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + // Named after its face, as the line and region rewrites name theirs: one name for every + // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). + let name = format!("PCEd{}", base.replace('-', "")); + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + let substituted = (!inputs.family_requested).then(|| base.to_string()); + let shown = name.clone(); + Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted }) +} + +/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken +/// back through the CTM's linear part. Checked before the fallback fonts are written: a refused +/// paragraph leaves the document as it was. +fn drawn_movement(ctm: [f64; 6], dx: f64, dy: f64) -> Result, EditError> { + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + let back = Matrix([ctm[0], ctm[1], ctm[2], ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; + Ok(Some(back.apply(dx, dy))) +} + +/// The paragraph's first text matrix, taken from where its text starts to where it is written. +fn emit_text_matrix(first: &Origin, emit: &Origin) -> Result<[f64; 6], EditError> { + if first.ctm == emit.ctm { + return Ok(first.tm); + } + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Ok(Matrix(first.tm).then(&Matrix(first.ctm)).then(&emit_inv).0) +} + +/// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. +struct LeadScale { + k: f64, + size: f64, + old_size: f64, + leading: f64, +} + +fn line_lead(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, scale: &LeadScale) -> f64 { + if let Some(m) = style.line_spacing { + return scale.size * m.clamp(0.5, 5.0); + } + // From the first two rows (a row may hold several runs). + let rows = block.rows.first().zip(block.rows.get(1)).and_then(|(upper, lower)| upper.first().zip(lower.first())); + if let Some((upper, lower)) = rows.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) { + return (upper.frame_origin().1 - lower.frame_origin().1) / scale.k * scale.size / scale.old_size; + } + if scale.leading > 0.0 { scale.leading } else { scale.size * 1.2 } +} + +/// The stroking twin of a fill colour operator. +fn stroke_twin(fill: &Op) -> Op { + let name = match fill.op.as_slice() { + b"g" => "G", + b"rg" => "RG", + b"k" => "K", + b"cs" => "CS", + b"sc" => "SC", + _ => "SCN", + }; + Op::new(name, fill.operands.clone()) +} + +/// Underlines in text space (the paragraph's text matrix), in its fill colour. +fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, f64)]) -> Vec { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![]), Op::new("cm", tm.iter().map(|v| n(*v)).collect())]; + ops.extend(fill.iter().map(stroke_twin)); + ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + ops.push(Op::new("m", vec![n(*x0), n(*y)])); + ops.push(Op::new("l", vec![n(*x1), n(*y)])); + } + ops.push(Op::new("S", vec![])); + ops.push(Op::new("Q", vec![])); + ops +} + +/// Alignment: each line's offset from the left edge. +struct Alignment { + align: Option, + width: f64, + k: f64, + anchored_right: bool, + right_edge: f64, + rtl: bool, +} + +impl Alignment { + /// `visible` is the width the line is aligned on, in user space; the offset is in text space. + fn offset(&self, visible: f64) -> f64 { + let free = (self.width - visible).max(0.0) / self.k; + match self.align { + Some(crate::added::Align::Center) => free / 2.0, + // A right-to-left line anchored at its right edge may start left of the old box. + Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, + Some(crate::added::Align::Right) => free, + // A justified right-to-left paragraph ends its last line at the right edge. + Some(crate::added::Align::Justify) if self.rtl => free, + _ => 0.0, + } + } +} + +/// Everything the wrapped lines of one paragraph are written with. +struct LineWriter<'a> { + advance: &'a Advance<'a>, + alignment: Alignment, + encode: &'a Encoder, + source: Option<&'a Metrics>, + justify: bool, + /// Whether `Tw` reaches every shown space. + word_spacing_reaches_spaces: bool, + width: f64, + scale: f64, + k: f64, + size: f64, + space: f64, + char_spacing: f64, + lead: f64, +} + +/// What carries over from one line to the next. +struct LineCursor { + x: f64, + tw_set: f64, + shown_font: String, +} + +impl LineWriter<'_> { + /// Justify: each line but the last fills the width. A stretched line shows only its words, as + /// before #4199: word spacing would reach the spaces before its first word and after its + /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't + /// justified. + fn stretch(&self, body: &str, last: bool) -> f64 { + let interior = body.matches(' ').count(); + if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) + } else { + 0.0 + } + } + + /// The text to show for a line and the width it is aligned on. Other lines show every space + /// typed. A left-to-right one is aligned on its width up to its last word; a right-to-left + /// one on its whole width, since the spaces typed after its last word are drawn on its left + /// and the ones before its first on its right. + fn shown<'t>(&self, line: &'t str, body: &'t str, tw: f64) -> (&'t str, f64) { + if tw > 0.0 { + (body, self.advance.of(body)) + } else if crate::bidi::rtl_base(line) { + (line, self.advance.of(line)) + } else { + (line, self.advance.of(line.trim_end_matches(' '))) + } + } + + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + let n = pdfcraft_content::num; + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = self.stretch(body, last); + let (shown, visible) = self.shown(line, body, tw); + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + if index > 0 || dx != 0.0 { + ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); + } + cursor.x = dx; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3), so where a space is + // shown with another code (a composite font, a generated Type 3 font, a subset without a + // space) the extra space follows each space as a TJ adjustment instead. + let stretch = if self.word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; + if tw != cursor.tw_set { + ops.push(Op::new("Tw", vec![n(tw)])); + cursor.tw_set = tw; + } + // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). + let visual = crate::bidi::to_visual(shown); + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } + } + let tw = tw + stretch; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * self.scale * self.k) / self.k; + Ok((dx, dx + w, -(index as f64) * self.lead - self.size * 0.12)) + } + + fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { + let n = pdfcraft_content::num; + if let Some(metrics) = self.source { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); + return Ok(()); + } + for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); + cursor.shown_font = font; + } + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + Ok(()) + } + + /// Writes every wrapped line, returning their underlines. + fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + } +} + +/// The operators that write a paragraph in its own graphics state, so a new colour (or anything +/// else) stops at the paragraph. +struct BlockPlan<'a> { + block: &'a TextBlock, + lines: &'a [TextLine], + style: &'a BlockStyle, + emit: &'a Origin, + after: &'a TextState, + movement: Option<(f64, f64)>, + state: TextState, + tm: [f64; 6], + size: f64, + underline: bool, +} + +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![])]; + ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); + // Moved: a translation inside that state. + if let Some((ax, ay)) = plan.movement { + ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + } + // Painted as the paragraph was, including what its text object set before the first show. + ops.extend(plan.state.paint_ops(plan.after)); + ops.push(Op::new("BT", vec![])); + let mut state = plan.state.clone(); + state.word_spacing = 0.0; + // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. + let mut cursor = LineCursor { x: 0.0, tw_set: 0.0, shown_font: show_font.clone() }; + state.font = Some((show_font, plan.size)); + if let Some([r, g, bl]) = plan.style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } + ops.extend(state.ops()); + ops.push(Op::new("Tm", plan.tm.iter().map(|v| n(*v)).collect())); + let underlines = writer.write_all(wrapped, &mut cursor, &mut ops)?; + ops.push(Op::new("ET", vec![])); + if plan.underline { + ops.extend(underline_ops(&state.fill, &plan.tm, plan.size, &underlines)); + } + ops.push(Op::new("Q", vec![])); + Ok(ops) +} + +/// Whether text shown earlier in the same text object stays first: the paragraph then goes where +/// its last row was, after any backgrounds between the rows, or between two halves of the text +/// object. The paragraph's own operators (and another drawing on the same baseline: a fake-bold +/// second copy, an invisible text layer) go, so the new text is all that may show. +fn shows_before_paragraph(ops: &[Op], from: usize, to: usize, dropped: &std::collections::HashSet) -> bool { + ops.get(from..to) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !dropped.contains(&(from + i))) +} + +/// A paragraph's single style, with the text-space values read from where it starts. +struct ParaStyle { + state: TextState, + size: f64, + old_size: f64, + k: f64, +} + +/// Spacing and scaling (text state), with the chosen values. +fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { + let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let mut state = o.state.clone(); + if let Some(c) = style.char_spacing { + state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + Ok((font_name, ParaStyle { state, size, old_size, k })) +} + +/// The font the paragraph's existing runs use, for reusing its glyphs. +struct SourceFont { + metrics: Option, + reuse: bool, + space: f64, +} + +fn source_font(doc: &Document, fonts_res: &Dict, font_name: &str, first: &TextLine, flat: &str, style: &BlockStyle) -> SourceFont { + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(flat) } else { flat.to_owned() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, fonts_res, m)); + SourceFont { metrics, reuse, space } +} + /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. pub(crate) fn rewrite_block_from( doc: &mut Document, @@ -228,86 +757,26 @@ pub(crate) fn rewrite_block_from( let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); - // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the - // single-style path below; the styled region path does not reorder right-to-left runs. - let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); - if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid( - "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), - )); - } - // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its - // own text space below; the styled region path lays rows out along the page's x axis. - let upright = members.iter().all(|line| line.upright()); - if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); - } - let visual_indic = members.iter().any(|line| line.visual_order); - if upright - && !hebrew - && !visual_indic - && (!style.ranges.is_empty() - || style.resource.is_some() - || (text.is_some_and(|draft| { - // A row break, `\n` or a carriage return, takes the path that keeps untouched rows. - draft.contains(['\n', '\r']) - || pdfcraft_fonts::source_shaping::required(draft) - || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() - }) || registered_face - || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) - || b.rows.iter().any(|row| row.len() > 1) - || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) - && style.family.is_none() - && style.bold.is_none()) - { + let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path { return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } + let upright = dispatch.upright; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is - // not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white - // space is written as one space. - let text: String = text - .unwrap_or(&b.text) - .replace("\r\n", "\n") - .chars() - .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', - c => c, - }) - .collect(); + let text = paragraph_text(text.unwrap_or(&b.text)); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; let last = members.last().copied().unwrap_or(first); let emit = &last.origin; - let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; - let k = o.k.max(1e-6); - // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); - // Spacing and scaling (text state), with the chosen values. - let mut ts_state = o.state.clone(); - if let Some(c) = style.char_spacing { - ts_state.char_spacing = c / k; - } - if let Some(sc) = style.scale { - ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); - } - let o_state = ts_state.clone(); - let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); - let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); + let reuse = source.reuse; // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. - let face = family.std14(bold, italic); - let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); @@ -327,287 +796,79 @@ pub(crate) fn rewrite_block_from( // Turned text is left alone: its centre lives along its own baseline, not in page space. let crop = p.crop(doc); let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); - // A paragraph keeps its width (or takes the one asked for); a single line grows to the right - // (to the left when right-to-left), up to the page's margin. - let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that - // (`clamp` panics when its minimum is the larger, or NaN). - Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), - Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), - // Turned text: the page's margins are along another axis. A single line grows (below). - None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (crop[0] + 36.0)), - // A centred line is not stretched to the margin: its own box is the frame it is centred in. - None if members.len() == 1 && !centred => (b.rect[2] - b.rect[0]).max(size * k).max(crop[2] - 36.0 - (b.rect[0] + dx)), - None => (b.rect[2] - b.rect[0]).max(size * k), - }; - if !width.is_finite() { - return Err(EditError::Invalid("the paragraph's width must be a number".into())); - } + let geometry = ParaGeometry { rect: b.rect, frame, crop, upright, k, size, dx, single_line: members.len() == 1, anchored_right, centred }; + let width = geometry.width(style.width)?; let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; // Where a right-aligned line ends, from the paragraph's left edge (user space). let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; - // The move and the width are checked here, before the fallback fonts are written: a refused - // paragraph leaves the document as it was. The move is in page space, so it is taken back through - // the CTM's linear part into the space the paragraph is drawn in. - let movement = if dx != 0.0 || dy != 0.0 { - let c = emit.ctm; - let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; - Some(back.apply(dx, dy)) - } else { - None - }; - let tm = if o.ctm == emit.ctm { - o.tm - } else { - let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 - }; + let movement = drawn_movement(emit.ctm, dx, dy)?; + let tm = emit_text_matrix(o, emit)?; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; - let advance = |s: &str| -> f64 { - let t = match (&metrics, reuse) { - (Some(m), true) => { - let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; - source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) - } - _ if let Some(fallback) = &type3 => { - // Each character's count in `s`, from one pass over `s`. - let mut counts: HashMap = HashMap::new(); - for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); - } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::() - + s.chars().count() as f64 * o_state.char_spacing - } - _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, - }; - t * o_state.scale * k + let source_metrics = source.metrics.as_ref().filter(|_| reuse); + let advance = Advance { + source: source_metrics, + type3: type3.as_ref(), + family, + bold, + italic, + size, + space: source.space, + char_spacing: o_state.char_spacing, + word_spacing: o_state.word_spacing, + scale: o_state.scale, + k, }; - // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - let crop = p.crop(doc); - let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); - let (ux, uy) = first.direction(); - let tx = if ux > 1e-6 { - (crop[2] - 36.0 - x0) / ux - } else if ux < -1e-6 { - (crop[0] + 36.0 - x0) / ux - } else { - f64::INFINITY - }; - let ty = if uy > 1e-6 { - (crop[3] - 36.0 - y0) / uy - } else if uy < -1e-6 { - (crop[1] + 36.0 - y0) / uy - } else { - f64::INFINITY - }; - let cap = width.max(tx.min(ty)).min(14_400.0); - text.split('\n').map(&advance).fold(width, f64::max).min(cap) + turned_line_width(first, crop, [dx, dy], width, &text, &advance) } else { width }; let right_edge = if anchored_right { right_edge } else { width }; - let wrapped = wrap(&text, width + 0.5, advance); - let mut substituted = None; - let new_font = !reuse; - let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) { - let name = font_name.clone(); - (font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)]))) - } else if let Some(fallback) = type3.clone() { - substituted = Some(format!("{} Type3", fallback.family)); - let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - (first, Box::new(move |s: &str| type3_encode(&fallback, s))) - } else { - let win = pdfcraft_fonts::win_ansi(&flat); - let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); - let base = family.base_font(bold, italic); - if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - // Named after its face, as the line and region rewrites name theirs: one name for every - // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). - let name = format!("PCEd{}", base.replace('-', "")); - fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); - if style.family.is_none() { - substituted = Some(base.to_string()); - } - let shown = name.clone(); - (name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))]))) - }; - // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = match style.line_spacing { - Some(m) => size * m.clamp(0.5, 5.0), - // From the first two rows (a row may hold several runs). - None if let Some((upper, lower)) = b - .rows - .first() - .zip(b.rows.get(1)) - .and_then(|(upper, lower)| upper.first().zip(lower.first())) - .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => - { - (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size - } - None if o_state.leading > 0.0 => o_state.leading, - None => size * 1.2, - }; - let n = pdfcraft_content::num; - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let drop = dropped; - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. + let wrapped = wrap(&text, width + 0.5, |s| advance.of(s)); + let font = choose_font( + &FontInputs { + source: source_metrics, + type3: type3.as_ref(), + font_name: &font_name, + flat: &flat, + source_base_font: &b.base_font, + family, + bold, + italic, + family_requested: style.family.is_some(), + }, + &mut fonts_res, + )?; + let lead = line_lead(&b, &lines, style, &LeadScale { k, size, old_size, leading: o_state.leading }); let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); + let split = shows_before_paragraph(&ops, emit.bt_op, start, &dropped); // What the text after the paragraph expects, and what is in force where the paragraph is // drawn: the state at its BT (or where it was split). let after = if split { &emit.state } else { &emit.bt_state }; - // In its own graphics state, so a new colour (or anything else) stops at the paragraph. - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); - // Moved: a translation inside that state. - if let Some((ax, ay)) = movement { - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); - } - // Painted as the paragraph was, including what its text object set before the first show. - block_ops.extend(o_state.paint_ops(after)); - block_ops.push(Op::new("BT", vec![])); - let mut state = o_state.clone(); - state.word_spacing = 0.0; - // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. - let mut shown_font = show_font.clone(); - state.font = Some((show_font, size)); - if let Some([r, g, bl]) = style.color { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; - } - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // Alignment: each line's offset from the left edge, in text space. - // `visible` is the width the line is aligned on (see the loop below). - let offset = |visible: f64| -> f64 { - let free = (width - visible).max(0.0) / k; - match align { - Some(crate::added::Align::Center) => free / 2.0, - // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k, - Some(crate::added::Align::Right) => free, - // A justified right-to-left paragraph ends its last line at the right edge. - Some(crate::added::Align::Justify) if rtl => free, - _ => 0.0, - } - }; - // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 - // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a - // generated Type 3 font, a subset without a space) the extra space follows each space as a - // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); - let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3); see `write_line`. + let word_spacing_reaches_spaces = match source_metrics { Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), None => type3.is_none(), }; - let mut x = 0.0; - let mut tw_set = 0.0; - let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { - // A stretched (justified) line shows only its words, as before #4199: word spacing would - // reach the spaces before its first word and after its last, so they are left out, and - // the line fills the width from the left edge. Nothing to stretch over, or a paragraph - // drawn at `0 Tz`, isn't justified. - let body = line.trim_matches(' '); - let interior = body.matches(' ').count(); - let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 { - Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite()) - } else { - None - } - .unwrap_or(0.0); - // Other lines show every space typed. A left-to-right one is aligned on its width up to - // its last word; a right-to-left one on its whole width, since the spaces typed after its - // last word are drawn on its left and the ones before its first on its right. - let (shown, visible) = if tw > 0.0 { - (body, advance(body)) - } else if crate::bidi::rtl_base(line) { - (line.as_str(), advance(line)) - } else { - (line.as_str(), advance(line.trim_end_matches(' '))) - }; - let dx = if tw > 0.0 { 0.0 } else { offset(visible) }; - if i > 0 || dx != 0.0 { - block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); - } - x = dx; - let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; - let tw = tw - stretch; - if tw != tw_set { - block_ops.push(Op::new("Tw", vec![n(tw)])); - tw_set = tw; - } - // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(shown); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; - } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - if stretch > 0.0 && piece.ends_with(' ') { - block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); - } - } - let tw = tw + stretch; - // Underlined to the line's last word, over its stretched gaps. - let w = (visible + tw * interior as f64 * o_state.scale * k) / k; - underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); - } - block_ops.push(Op::new("ET", vec![])); - if style.underline == Some(true) { - // In text space (the paragraph's text matrix), in its fill colour. - let colour: Vec = state - .fill - .iter() - .map(|f| match f.op.as_slice() { - b"g" => Op::new("G", f.operands.clone()), - b"rg" => Op::new("RG", f.operands.clone()), - b"k" => Op::new("K", f.operands.clone()), - b"cs" => Op::new("CS", f.operands.clone()), - b"sc" => Op::new("SC", f.operands.clone()), - _ => Op::new("SCN", f.operands.clone()), - }) - .collect(); - block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(colour); - block_ops.push(Op::new("w", vec![n(size * 0.06)])); - for (x0, x1, y) in underlines { - block_ops.push(Op::new("m", vec![n(x0), n(y)])); - block_ops.push(Op::new("l", vec![n(x1), n(y)])); - } - block_ops.push(Op::new("S", vec![])); - block_ops.push(Op::new("Q", vec![])); - } - block_ops.push(Op::new("Q", vec![])); + let writer = LineWriter { + advance: &advance, + alignment: Alignment { align, width, k, anchored_right, right_edge, rtl }, + encode: &font.encode, + source: source_metrics, + justify: align == Some(crate::added::Align::Justify), + word_spacing_reaches_spaces, + width, + scale: o_state.scale, + k, + size, + space: source.space, + char_spacing: o_state.char_spacing, + lead, + }; + let plan = + BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); @@ -617,9 +878,10 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !drop.contains(&i)) + (insert, !dropped.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); + let new_font = !reuse; if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } @@ -630,7 +892,7 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; - Ok(LineEdit { substituted }) + Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 7516cb793..be5b4dffa 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -1,6 +1,11 @@ //! Geometry-based regions and mixed-style rewriting of existing text. -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, + source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, +}; #[path = "text_regions/grouping.rs"] mod grouping; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 29004b8e3..b8a5abca1 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 09baf1ac7..e8b3b683d 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,5 +1,10 @@ use super::grouping::{across, is_centred, row_rect}; -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, + splice, type3_faces, type3_glyph, write_type3_glyphs, +}; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. // A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). @@ -13,6 +18,526 @@ struct FallbackGroup { positions: Vec, } +struct FallbackBuilder { + groups: Vec, + names: std::collections::BTreeMap<(String, bool, char), usize>, + glyphs: usize, + per_font: usize, + budget: usize, +} + +struct FontResolver<'a> { + doc: &'a mut Document, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + full_faces: HashMap, + programs: HashMap>>>, +} + +impl FontResolver<'_> { + fn encodable_font(&mut self, source: &TextLine, text: &str, requested: Option<&str>) -> Result, EditError> { + if let Some(resource) = requested { + if !self.metrics.get(resource).is_some_and(|metrics| source_encodes(metrics, text)) { + return Err(EditError::Invalid(format!( + "the selected font resource {resource} cannot show U+{:04X}", + text.chars().next().map_or(0, u32::from) + ))); + } + return Ok(Some(resource.to_owned())); + } + if self.metrics.get(&source.font).is_some_and(|metrics| source_encodes(metrics, text)) { + return Ok(Some(source.font.clone())); + } + if let Some((name, _)) = self.metrics.iter().find(|(_, metrics)| { + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) + && source_encodes(metrics, text) + }) { + return Ok(Some(name.clone())); + } + if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|face| pdfcraft_fonts::embedding::covers(face.bytes, text)) + { + return self.embed_face(face.name(), face.bytes, face.family, "the full source font could not be loaded").map(Some); + } + let Some(bytes) = self + .programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(self.doc, self.fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, text)) + else { + return Ok(None); + }; + let key = format!("source {}", source.font); + if let Some(name) = self.full_faces.get(&key) { + return Ok(Some(name.clone())); + } + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + self.embed_face(key, &bytes, &family, "the source font could not be loaded").map(Some) + } + + fn embed_face(&mut self, key: String, bytes: &[u8], family: &str, missing_message: &str) -> Result { + if let Some(name) = self.full_faces.get(&key) { + return Ok(name.clone()); + } + let name = pdfcraft_fonts::embedding::embed(self.doc, self.fonts, bytes, family).map_err(EditError::Invalid)?; + let font = + self.fonts.get(name.as_bytes()).and_then(|resource| self.doc.dict(resource)).ok_or_else(|| EditError::Invalid(missing_message.into()))?; + self.metrics.insert(name.clone(), Metrics::from_dict(self.doc, &font)); + self.full_faces.insert(key, name.clone()); + Ok(name) + } + + fn substitute_font(&mut self, source: &TextLine) -> String { + let family = source_family(&source.base_font); + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + self.metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(self.doc, &font)); + self.fonts.set(name.clone().into_bytes(), Object::Dict(font)); + name + } +} + +struct PreparedCharacters { + values: Vec, + substituted: Option, +} + +struct CharacterPreparation<'a> { + doc: &'a mut Document, + first: &'a TextLine, + lines: &'a [TextLine], + draft: &'a str, + spans: Vec<(std::ops::Range, usize)>, + style: &'a BlockStyle, + overrides: &'a HashMap, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + max_glyphs: usize, +} + +impl CharacterPreparation<'_> { + fn prepare(self) -> Result { + let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback = FallbackBuilder::new(max_glyphs); + let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; + for (range, index) in spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let text = ch.to_string(); + let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); + let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + name + } else if needs_type3(&text) { + fallback.prepare(source, ch, &text, chars.len())? + } else { + let name = resolver.substitute_font(source); + substituted = Some(source_family(&source.base_font).base_font(source.bold, source.italic).to_owned()); + name + }; + chars.push((ch, index, name)); + } + } + drop(resolver); + for group in fallback.groups { + let written = write_type3_glyphs(doc, fonts, group.face, false, group.glyphs, fallback.per_font); + for name in &written.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|resource| doc.dict(resource)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = written.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = written.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(written.family.to_owned()); + } + Ok(PreparedCharacters { values: chars, substituted }) + } +} + +impl FallbackBuilder { + fn new(max_glyphs: usize) -> Self { + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + Self { + groups: Vec::new(), + names: std::collections::BTreeMap::new(), + glyphs: 0, + per_font, + budget: per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES), + } + } + + fn prepare(&mut self, source: &TextLine, ch: char, text: &str, position: usize) -> Result { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = self.names.get(&key) { + *index + } else { + let (faces, _) = type3_faces(text, source_family(&source.base_font), source.bold)?; + let face = fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(text))?; + let index = self.groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = self.groups.len(); + self.groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + self.names.insert(key, index); + index + }; + let group = self.groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if self.glyphs >= self.budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {} fallback glyphs; edit a smaller paragraph or load a complete font", + self.budget + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % self.per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + self.glyphs = self.glyphs.saturating_add(1); + } + group.positions.push(position); + Ok(String::new()) + } +} + +struct StyledLines<'a> { + lines: Vec, + spans: Vec<(std::ops::Range, usize)>, + underlines: HashMap, + overrides: HashMap, +} + +type StyledChar = (char, usize, String); +type StyledRows = Vec>; +type RowRanges = Vec>; +type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; + +struct ShapedRows { + runs: ShapedRunCache, + advances: HashMap<(usize, usize), f64>, +} + +fn prepare_shaped_rows( + doc: &Document, + fonts: &Dict, + rows: &[Vec], + kept: &[bool], + advance: impl Fn(&StyledChar) -> f64, +) -> Result { + let mut runs = HashMap::new(); + let mut advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { + end += 1; + } + let segment = row.get(at..end).unwrap_or_default(); + advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let text: String = segment.iter().map(|character| character.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { + runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + Ok(ShapedRows { runs, advances }) +} + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { + let mut resources = HashMap::new(); + for (key, (bytes, shaped, family)) in runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + resources.insert(*key, resource); + } + Ok(resources) +} + +fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { + for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { + let text: String = row.iter().map(|character| character.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) + && row.first().is_some_and(|first| row.iter().any(|character| character.1 != first.1 || character.2 != first.2)) + { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + Ok(()) +} + +fn calibration_factors( + lines: &[TextLine], + metrics: &std::collections::BTreeMap, + style: &BlockStyle, + overrides: &HashMap, + space_of: impl Fn(&str, &Metrics) -> f64, +) -> HashMap { + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(metrics) = metrics.get(&source.font) { + let nominal = source_width( + metrics, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, size)| *size), + space_of(&source.font, metrics), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = + style.resource.is_some() || overrides.get(&index).is_some_and(|range| range.family.is_some() || range.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + calibration +} + +fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|range| range.range.start); + let invalid_range = ranges.iter().any(|range| { + range.range.start >= range.range.end + || draft.get(range.range.clone()).is_none() + || [range.size, range.char_spacing, range.scale].iter().flatten().any(|value| !value.is_finite()) + || range.size.is_some_and(|value| value <= 0.0 || value > 14_400.0) + || range.color.is_some_and(|color| color.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) + }); + if ranges.len() > 1024 || invalid_range || ranges.windows(2).any(|pair| pair[0].range.end > pair[1].range.start) { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + Ok(ranges) +} + +fn replacement_text<'a>(block: &'a TextBlock, text: Option<&'a str>) -> Result<&'a str, EditError> { + let draft = match text { + Some(text) if text != block.text => text, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + Ok(draft) +} + +fn has_no_effective_style_change(block: &TextBlock, draft: &str, style: &BlockStyle) -> bool { + draft == block.editor_text && *style == BlockStyle::default() +} + +fn page_font_metrics( + doc: &Document, + resource_dict: &Dict, + block: &TextBlock, + lines: &[TextLine], +) -> (Dict, std::collections::BTreeMap) { + let mut fonts = resource_dict.get(b"Font").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(font)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|resource| doc.resolve(resource).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + (fonts, metrics) +} + +fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextBlock, style: &BlockStyle) -> Result<(f64, f64, f64), EditError> { + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|value| !value.is_finite()) + || style.color.is_some_and(|color| color.iter().any(|value| !value.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|width| !width.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let natural = if block.invisible || block.rows.len() == 1 { + (page.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) +} + +fn wrap_styled_rows( + chars: &[StyledChar], + width: f64, + measure: impl Fn(&[StyledChar]) -> Result, +) -> Result<(StyledRows, RowRanges), EditError> { + let mut rows: Vec> = Vec::new(); + let mut row_ranges = Vec::new(); + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) + && !current.0.is_whitespace() + && !crate::linebreak::between(before.0, current.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let word_width = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { + row.pop(); + } + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += word_width; + at = end; + } + if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); + rows.push(row); + } + Ok((rows, row_ranges)) +} + +fn apply_range_styles<'a>( + first: &TextLine, + block: &TextBlock, + lines: &[TextLine], + draft: &str, + ranges: &[&'a RangeStyle], + style: &BlockStyle, +) -> StyledLines<'a> { + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for range in ranges { + for edge in [range.range.start, range.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let selected = ranges.iter().find(|range| range.range.contains(&start)).copied(); + let mut index = source; + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let range = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + range.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &range.resource { + line.font = resource.clone(); + } + if let Some(size) = range.size { + line.size = size; + } + if let Some(color) = range.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = range.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = range.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(range) = selected { + overrides.insert(index, range); + } + if let Some(underline) = range.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, @@ -84,6 +609,48 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +struct RewriteAnchor<'a> { + lead: &'a TextLine, + drop: std::collections::HashSet, + start: usize, + split: bool, +} + +fn rewrite_anchor<'a>( + block: &TextBlock, + lines: &'a [TextLine], + first: &'a TextLine, + ops: &[Op], + kept_lines: &std::collections::HashSet, + kept: &mut [bool], +) -> RewriteAnchor<'a> { + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|index| !kept_lines.contains(index)).filter_map(|index| lines.get(*index)).collect(); + let lead = rewritten + .last() + .copied() + .filter(|_| !rewritten.iter().flat_map(|line| &line.ops).any(|index| ops.get(*index).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|value| *value = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|index| lines.get(*index)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) + } + }; + let origin = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|line| line.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(origin.bt_op); + let split = ops + .get(origin.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(index, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(origin.bt_op + index))); + RewriteAnchor { lead, drop, start, split } +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -96,291 +663,36 @@ pub(crate) fn rewrite_styled_block( max_glyphs: usize, ) -> Result { let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let draft = match text { - Some(t) if t != block.text => t, - _ => &block.editor_text, - }; - if draft.len() > 4_000_000 { - return Err(EditError::Invalid("the replacement text is too large".into())); - } - if draft == block.editor_text && *style == BlockStyle::default() { + let draft = replacement_text(block, text)?; + if has_no_effective_style_change(block, draft, style) { return Ok(LineEdit { substituted: None }); } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) - { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); - } + let ranges = validated_ranges(draft, style)?; let source_count = lines.len(); - let mut styled_lines = lines.to_vec(); - let mut styled_spans = Vec::new(); - let mut underlines = HashMap::new(); - let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { - let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { - if span.contains(&edge) { - edges.push(edge); - } - } - } - edges.sort_unstable(); - edges.dedup(); - for bounds in edges.windows(2) { - let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; - let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); - if selected.is_some() || style.family.is_some() || style.bold.is_some() { - let default_range = RangeStyle { - range: start..end, - family: None, - resource: None, - size: None, - color: None, - underline: None, - char_spacing: None, - scale: None, - }; - let r = selected.unwrap_or(&default_range); - let mut line = lines.get(source).unwrap_or(first).clone(); - if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) - { - let base = family.base_font(bold, italic); - line.font = format!("PCRange{}", base.replace('-', "")); - line.base_font = base.to_owned(); - line.bold = bold; - line.italic = italic; - } - if let Some(resource) = &r.resource { - line.font = resource.clone(); - } - if let Some(size) = r.size { - line.size = size; - } - if let Some(color) = r.color { - line.color = color; - line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; - } - if let Some(spacing) = r.char_spacing { - line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); - } - if let Some(scale) = r.scale { - line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); - } - index = styled_lines.len(); - styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); - } - if let Some(underline) = r.underline { - underlines.insert(index, underline); - } - } - styled_spans.push((start..end, index)); - } - } - let lines = styled_lines.as_slice(); + let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let lines = styled.lines.as_slice(); + let styled_spans = styled.spans; + let underlines = styled.underlines; + let overrides = styled.overrides; let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } - } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) - { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); - } - } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] - }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_groups: Vec = Vec::new(); - let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); - let mut fallback_glyphs = 0_usize; - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - let start = range.start; - for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - // A carriage return breaks the row too. Before `\n` the pair is one break, so the - // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's - // characters, which rows are counted in. - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); - chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.clone(), source.bold, ch); - let group_index = if let Some(index) = fallback_names.get(&key) { - *index - } else { - // Keep the old per-character face choice: a serif Japanese character - // and a Cyrillic character may need different faces even in one style. - // Hebrew uses the separate RTL path before reaching this writer. - let (faces, _) = type3_faces(&s, family, source.bold)?; - let face = - fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; - let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { - let index = fallback_groups.len(); - fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); - index - }); - fallback_names.insert(key, index); - index - }; - let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; - if group.seen.insert(ch) { - if fallback_glyphs >= fallback_budget { - return Err(EditError::Invalid(format!( - "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" - ))); - } - let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; - let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - group.glyphs.push(Type3Glyph { code, ch, path, width }); - fallback_glyphs = fallback_glyphs.saturating_add(1); - } - group.positions.push(chars.len()); - // Filled from the prepared font/code map before measuring or drawing. - String::new() - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); - } - } - // All fallback outlines have now succeeded. Batch by actual face, preserving source - // style indices; the existing writer then coalesces adjacent equal-font/style characters. - for group in fallback_groups { - let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); - for name in &fallback.names { - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - } - for position in group.positions { - let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; - let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; - item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; - } - substituted = Some(fallback.family.to_owned()); + let mut res = p.dict.get(b"Resources").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + let (mut fonts, mut metrics) = page_font_metrics(doc, &res, block, lines); + let (dx, dy, width) = paragraph_geometry(doc, &p, block, style)?; + let prepared = CharacterPreparation { + doc, + first, + lines, + draft, + spans: styled_spans, + style, + overrides: &overrides, + fonts: &mut fonts, + metrics: &mut metrics, + max_glyphs, } + .prepare()?; + let chars = prepared.values; + let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; @@ -400,23 +712,7 @@ pub(crate) fn rewrite_styled_block( spaces.borrow_mut().insert(name.to_owned(), w); w }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } + let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); let advance = |item: &(char, usize, String)| -> f64 { let source = lines.get(item.1).unwrap_or(first); let Some(m) = metrics.get(&item.2) else { return 0.0 }; @@ -466,58 +762,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - // The CR of a CRLF: the `\n` after it breaks the row. - if item.0 == '\r' { - at += 1; - continue; - } - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } + let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -531,65 +776,18 @@ pub(crate) fn rewrite_styled_block( }; let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; + // The rewrite is placed after the last rewritten row unless a text-show operator there + // would make a kept following row depend on that position. + let anchor = rewrite_anchor(block, lines, first, &ops, &kept_lines, &mut kept); + let RewriteAnchor { lead, drop, start, split } = anchor; let o = &lead.origin; - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } + validate_contextual_rows(&rows, &kept)?; + let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; + let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs index 893a0e4d2..fe1aa7868 100644 --- a/crates/edit/src/text_session.rs +++ b/crates/edit/src/text_session.rs @@ -3,7 +3,10 @@ //! another one's output; the commit writes into the document it was opened on, and refuses if //! that document has changed since. -use super::*; +use super::{ + BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from, + text_lines, +}; /// One paragraph being edited: the document as it was opened, and the page's lines then. pub struct TextSession { diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d25cc6503..d28429de6 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5e25ae465..ea2589806 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table}; /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7901a3b7d..49f19fa63 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { @@ -328,7 +331,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + pdfcraft_annot::add_text_replacement( + doc, + pdfcraft_annot::TextReplacement { + page: *page, + quads, + replacement: text, + author, + strike, + caret, + strike_meta: &strike_meta, + caret_meta: &caret_meta, + }, + )?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -338,7 +353,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + pdfcraft_annot::set_style( + doc, + *page, + *index, + pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() }, + &cx.meta(), + )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index d8950d1d6..58bc8327e 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -25,6 +25,17 @@ pub struct PreparedDocument { document: Document, } +struct DocumentOpenData<'a> { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + impl OpenContext { /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); // A script that ran away on open turns the form's scripts off for this document. document.xfa_template = if ran_away { None } else { xfa_template }; document.js_output.append(script_output); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 9b1a8340a..f8d5648a2 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -1,4 +1,11 @@ -use super::*; +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings, + render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, + write_incremental, xfa, xfa_sync_datasets, +}; impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 6d4e11d39..1e8cd9b08 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 23994aee3..d4156a2eb 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard, + render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index cef539189..055437f56 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; impl Session { pub fn new() -> Self { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e7e68a758..14a61b263 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -8106,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; @@ -8124,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8720,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index d8fb84f7d..ab7451dc0 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ +use super::Zip; use super::as_html::esc; use super::layout::{Image, Item, Page, Table, body_size, items}; -use super::*; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs index d7495df13..61585d82f 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -1,7 +1,7 @@ use super::as_docx::rgb8; use super::as_html::allowed; use super::layout::{Block, Item, Page, count_lookup_steps, items}; -use super::*; +use std::collections::HashMap; fn rtf_text(s: &str) -> String { let mut o = String::new(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 8a0605089..d1d074ac6 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use std::collections::HashMap; /// A paragraph of a page. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index cf32ea793..5275b0243 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -10,8 +10,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; - mod pptx; mod zip; diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1..5ebe3269c 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index c807f2d7d..9aed297b4 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,6 +1,6 @@ use super::calc::{js_tokens, notation_js, notation_range}; use super::format::{DATE_PRESETS, TIME_PRESETS}; -use super::*; +use super::{CalcOp, Calculate, Format, Validate}; // ── parsing scripts ───────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index d035feb4e..64569fa3c 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -1,6 +1,6 @@ use super::read::{acroform, fields}; use super::values::merged_appearance; -use super::*; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4f..d61cfe28f 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,6 +1,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; -use super::*; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; /// Swap width and height, keeping the center. `None` when that is not a usable widget. fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { @@ -70,59 +70,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } + let new_name = rename_field(doc, &all, &f, name, props)?; // The new default appearance when the size or the look changes. let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); let new_da = match &props.look { @@ -248,51 +196,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } + apply_widget_style_props(doc, &f, name, props)?; if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); @@ -341,6 +245,127 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result { + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok(new_name) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -348,7 +373,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Dict { let mut d = Dict::new(); diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 6044bdc70..23661ed70 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e..d8ca67ffb 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. pub(crate) fn ensure_form(doc: &mut Document) -> Result { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index a962af91b..e689c0e81 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -1,5 +1,5 @@ use super::tab_order::rank_tabs; -use super::*; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 9c5c33b69..77234ee09 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -1,5 +1,5 @@ use super::read::{fields, page_refs}; -use super::*; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e..d8199f15d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a7..37df5502d 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,6 +1,9 @@ use super::appearances::{declares_pdfa, need_appearances}; use super::read::{acroform, field_flags, fields, page_refs}; -use super::*; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, + calc_order, flags, invalid, name_obj, scripting, +}; // ── writing ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 3d0fe194e..e977457e2 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -1,6 +1,6 @@ use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 5f2eed102..7f207b191 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -1,6 +1,6 @@ use super::host::{arg, error, s, text}; use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; -use super::*; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; /// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups /// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 3911a5223..bea902c75 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -1,6 +1,11 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::CLOCK_EVERY; use super::ast::{Expr, Idx}; use super::value::{Val, num_text, parse_num}; -use super::*; +use super::{ + HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances, + resolve_som, +}; impl Interp<'_> { pub(super) fn step(&mut self) -> R<()> { diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs index f9a88be79..ab453a806 100644 --- a/crates/js/src/formcalc/ast.rs +++ b/crates/js/src/formcalc/ast.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Func, Rc}; // ── syntax ────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index 1e7bb9424..c3b7c7f1f 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; use super::words::word_num; -use super::*; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { /// A built-in function: its result is never a string past [`MAX_STRING`], and work on @@ -22,6 +22,15 @@ impl Interp<'_> { fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { let lower = name.to_ascii_lowercase(); let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } Ok(match lower.as_str() { // ── arithmetic "abs" => return n(self.arg_num(args, 0)?.abs()), @@ -271,7 +280,24 @@ impl Interp<'_> { let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); parse_num(&cleaned).map_or(Val::Null, Val::Num) } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "date" => { #[cfg(not(target_arch = "wasm32"))] { @@ -304,7 +330,7 @@ impl Interp<'_> { let days = self.arg_num(args, 0)?; let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let (y, m, d) = civil_from_days(days as i64); Val::Str(format_date(y, m, d, &pic)) @@ -326,7 +352,7 @@ impl Interp<'_> { "num2time" | "num2gmtime" => { let ms = self.arg_num(args, 0)?; if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let s = (ms / 1000.0) as u64; Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) @@ -344,7 +370,13 @@ impl Interp<'_> { parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) } - // ── financial + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "fv" => { let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) @@ -428,7 +460,13 @@ impl Interp<'_> { } Val::Num((lo + hi) / 2.0 * 12.0) } - // ── units and references + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "unittype" => { let s = self.arg_str(args, 0)?; let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); @@ -459,19 +497,8 @@ impl Interp<'_> { let target = if to.trim().is_empty() { unit.clone() } else { to }; Val::Num(v / per_in(&unit) * per_in(&target)) } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) + _ => return Ok(None), + }; + Ok(Some(value)) } } diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 23ce3ec0c..a3218a78f 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Stmt}; use super::value::{Val, is_numeric_text, num_text}; -use super::*; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip, + descendants_named, instances, reset_names, resolve_som, +}; impl Interp<'_> { // ── expressions diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index ba9ae4f16..46af19f76 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::MAX_STRING; // ── lexer ─────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index 84cf121cd..fceedadf5 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -1,6 +1,6 @@ use super::ast::{Expr, Idx, Stmt}; use super::lexer::{Tok, Token, lex}; -use super::*; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; struct Parser { toks: Vec, diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index dd487c248..a9ad4d2b8 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -2,7 +2,7 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie use super::format::{printd, printf, printx, scand}; use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; // ── app, util, console ────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 131b8851c..6e442d2a2 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -1,6 +1,9 @@ use super::model::{DocInfo, FieldState, FieldType, Request}; use super::run::MAX_ARRAY_LENGTH; -use super::*; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, + xfa, +}; pub(crate) struct Host { pub(crate) fields: Arc>, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 17b5c252f..07c0e984b 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -1,7 +1,7 @@ use super::globals::{event_object, install}; use super::host::{Host, Shared, text}; use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; -use super::*; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e..38cebf54c 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1017,6 +1017,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1042,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1061,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1081,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1092,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1147,6 +1167,13 @@ s"# #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1197,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1208,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1215,7 +1247,9 @@ table._row.count"#, XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#, #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 250794165..54508e6ca 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -2,7 +2,10 @@ use super::host::host; use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; -use super::*; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index 419d2141f..ea2d4d433 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -2,7 +2,7 @@ use super::model::{ MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, effect_key, payload, }; -use super::*; +use super::{Context, HashMap, JsResult, RefCell, error}; /// One node of the flattened form, shared by the JavaScript and FormCalc engines. pub(crate) struct HNode { diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs index b03e6aef0..c099c5abe 100644 --- a/crates/js/src/xfa/instances.rs +++ b/crates/js/src/xfa/instances.rs @@ -1,7 +1,7 @@ use super::host::{HNode, XHost, host}; use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; use super::nodes::{instances, node_object}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index b9c6cedaa..83b2d7d14 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashSet; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs index b9483f25d..75943352e 100644 --- a/crates/js/src/xfa/nodes.rs +++ b/crates/js/src/xfa/nodes.rs @@ -2,7 +2,10 @@ use super::globals::{recalculate, relayout}; use super::host::{HNode, XHost, host}; use super::instances::{instance_manager, list_object}; use super::model::{MAX_DEPTH, XfaEffect}; -use super::*; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; // ── node objects ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs index 2db77d706..f951e02a5 100644 --- a/crates/js/src/xfa/run.rs +++ b/crates/js/src/xfa/run.rs @@ -1,7 +1,7 @@ use super::globals::install; use super::host::{Shared, XHost}; use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; -use super::*; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; /// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf..5b277e554 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 21c21b9be..15a412e23 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -1,5 +1,5 @@ use super::copy::Copier; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 09a1b0725..06972ae10 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -1,5 +1,5 @@ use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; -use super::*; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f78411806..f4b549962 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 98f69c69e..44910d040 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -1,5 +1,5 @@ use super::copy::WIDGET_KEYS; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; /// Rename the top-level fields about to be registered in `dst` whose fully-qualified names /// (`parent.T.child`) are already taken, so each imported field stays its own field instead of diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e..1df39ebf0 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,99 +514,163 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { - let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } - } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } - } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } - } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } - } - } + check_objects(doc, level, &objs, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); out } +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { + let Some(d) = dict_of(o) else { continue }; + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + // A forbidden annotation stops every later check for its object, as the scan did before + // it was split into helpers: the rest of its own checks and the form field, font, stream + // and embedded file below all came after that `continue`. + return; + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); + } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) -> bool { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return true; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } + false +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); + } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); + } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); + } + Level::A3b => {} + } +} + fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { let sub = d.name(b"Subtype"); if sub == Some(b"Type3") { diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 1257bcf25..aa9a501f6 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::settings::{Content, Settings}; use super::sheets::{Sheet, layout}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; // Model-derived transforms and clipping bounds need their full PDF decimal precision. fn geometry_number(value: f64) -> String { diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 7c9554f25..0506eb5b1 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::spool; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ce80adc3e..061a6ed5c 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::imposition::decoded_contents; use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects @@ -162,32 +162,7 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } } Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); @@ -348,3 +323,35 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } Ok(sheets) } + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 71b4684bc..3baec29c2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -242,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -277,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -292,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); - } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; - } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; - } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; - } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; - } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; - } - } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } - } - _ => {} - } - ops.push(op); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), } - if !path.is_empty() { - ops.append(&mut path); + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; } - processed.push((ops, changed)); - } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); - } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) } - bytes - })); + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); + } + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + } + } + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; + } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; + } + } + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None + } + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs index 32d7afdf1..88d1c4e67 100644 --- a/crates/redact/src/interp/layers.rs +++ b/crates/redact/src/interp/layers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; impl<'a> Scope<'a> { /// Is this optional-content reference (an OCG or OCMD) hidden? diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2708a87a4..6ca5ea280 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, /// so a full-rewrite save stops reaching the original image or form it named. A name that some diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d8..de9dafe7d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab90..57971571a 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); - - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 07283c047..603aa8811 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,7 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; use super::navigation::text_string; -use super::*; +use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 875d87365..c190dc565 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,7 +1,10 @@ use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; -use super::*; +use super::{ + Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, + ObjectId, OnceCell, OpenError, Pdf, Structure, catch_unwind, +}; /// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is /// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index e5f09c924..490897b60 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashMap; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 173217c93..4000a7413 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -2,7 +2,10 @@ use super::core::inspect; use super::core::spend; use super::model::{DestView, OutlineItem, PageInfo}; -use super::*; +use super::{ + BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, + MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, +}; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document /// reads only this window; the rest are listed by page through `pdfcraft_organize`. diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0386c5781..0895c64f8 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -3,7 +3,7 @@ use super::core::inspect; use super::core::{Inspector, load_options}; use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; use super::navigation::NameTreeSeen; -use super::*; +use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to /// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. diff --git a/crates/render/src/raster/pool.rs b/crates/render/src/raster/pool.rs index 81447263d..6da0c14d4 100644 --- a/crates/render/src/raster/pool.rs +++ b/crates/render/src/raster/pool.rs @@ -1,4 +1,10 @@ -use super::*; +use super::{ + Arc, AssertUnwindSafe, AtomicRenderStats, InterpreterSettings, JoinHandle, Mutex, Ordering, PageRenderer, Pdf, Pixels, RenderCache, RenderConfig, + RenderRequest, RenderStats, RenderedPage, RequestFailureKey, RequestKind, ResultReceiver, Stopwatch, SyncSender, WorkQueue, catch_unwind, finish, + panic_message, parse, render_page, take_warnings, warning_settings, +}; +#[cfg(not(target_arch = "wasm32"))] +use super::{AtomicBool, Receiver, sync_channel}; /// How long one page may render before the pool gives up on it (the watchdog). pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20); diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index eba4d9c1c..79bf05f7d 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,5 +1,8 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; -use super::*; +use super::{ + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, + Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, +}; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be /// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 302184aeb..00b45e266 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,6 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; -use super::*; +use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. pub(super) struct Flow { diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 6892d3f2a..d8cb65f32 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ +use super::TextGlyph; use super::layout::{is_rtl, union}; -use super::*; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index b148ea4d1..4e232af61 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -376,7 +376,7 @@ pub fn sign_detached( alg: DigestAlg, content_digest: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, DATA, None, content_digest) + build(SignedDataInput { key, cert, chain, alg, content_type: DATA, content: None, content_digest }) } /// A CMS SignedData that encapsulates `content` under `content_type` (an RFC 3161 token wraps @@ -389,20 +389,23 @@ pub fn sign_encapsulated( content_type: &str, content: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, content_type, Some(content), &alg.digest(&[content])) + let content_digest = alg.digest(&[content]); + build(SignedDataInput { key, cert, chain, alg, content_type, content: Some(content), content_digest: &content_digest }) } -// The parameters mirror the SignedData fields being assembled. -#[allow(clippy::too_many_arguments)] -fn build( - key: &PrivateKey, - cert: &Certificate, - chain: &[Certificate], +/// The values needed to assemble a CMS SignedData object. +struct SignedDataInput<'a> { + key: &'a PrivateKey, + cert: &'a Certificate, + chain: &'a [Certificate], alg: DigestAlg, - content_type: &str, - content: Option<&[u8]>, - content_digest: &[u8], -) -> Result, SignError> { + content_type: &'a str, + content: Option<&'a [u8]>, + content_digest: &'a [u8], +} + +fn build(input: SignedDataInput<'_>) -> Result, SignError> { + let SignedDataInput { key, cert, chain, alg, content_type, content, content_digest } = input; let cert_hash = DigestAlg::Sha256.digest(&[&cert.raw]); let general_names = der::seq(&[&der::explicit(4, &cert.issuer.raw)]); let ess = der::seq(&[&der::seq(&[&der::seq(&[&der::octets(&cert_hash), &der::seq(&[&general_names, &der::uint(&cert.serial)])])])]); diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 2c66418a6..3a10a74e4 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -1,5 +1,5 @@ use super::types::{Modification, kind}; -use super::*; +use super::{Arc, Dict, Document, HashMap, HashSet, ObjRef, Object}; /// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none: /// an approval signature permits form fill, comments and further signatures). @@ -72,6 +72,18 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if doc.root() != old.root() { disallowed.push(kind::STRUCTURE); } + let classifier = ChangedObjectClassifier { + doc, + old: &old, + old_content: &old_content, + old_metadata, + old_xfa_data: &old_xfa_data, + signed_pages: &signed_pages, + signed_annots: &signed_annots, + signed_resources: &signed_resources, + old_info, + dss: &dss, + }; for num in doc.object_numbers() { if same_place(num) { continue; @@ -93,75 +105,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< // graphics state does not make that a form fill. A container (the resources dictionary, // a category dictionary) that kept every entry it had and only gained names changes // nothing the signed content draws, and is judged as before. - let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v))); - let resource_kind = match (signed_resources.get(&r), &*new) { - (Some(false), _) => Some(kind::PAGE_CONTENT), - (Some(true), Object::Dict(d)) if kept_entries(d) => None, - (Some(true), _) => Some(kind::PAGE_CONTENT), - (None, _) => None, - }; - let what = match (resource_kind, &*new) { - (Some(k), _) => k, - // The Document Security Store's own arrays and dictionaries, when they only grew. - (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it lost, then what it - // gained. Dropping a signed annotation is never hidden behind an added signature. - (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { - let was: HashSet = - before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); - let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); - if removed.is_empty() { - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) - } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { - // A form field or signature widget taken off the page: no permission covers it. - kind::OTHER - } else { - // Deleting comments is a comment change (permitted at P 3 only). - kind::COMMENTS - } - } - (_, Object::Dict(d)) => { - let was = before.as_deref().and_then(Object::as_dict); - if signed_pages.contains(&r) { - // A signed page is always judged by what the page shows, whatever it is now - // relabelled: a rewritten /Contents or /Resources is a page content change. - page_change_kind(doc, &old, d, was) - } else if was.is_some() && Some(r) == old_info { - // Only the signed revision's own /Info dictionary is a metadata change. - kind::METADATA - } else { - change_kind(doc, &old, d, was) - } - } - (_, Object::Stream(s)) => { - // File plumbing is what the stream was when signed: the /Type it has now is - // written by whoever changed it, and the renderer ignores /Type (a page's Form - // XObject relabelled /ObjStm still draws its new content). - let was_plumbing = matches!(before.as_deref(), Some(Object::Stream(b)) if matches!(b.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); - if old_content.contains(&r) { - kind::PAGE_CONTENT - } else if before.is_none() || was_plumbing { - continue; - } else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata { - // Only the signed catalog's XMP stream. A /Type /Metadata label on anything - // else, even one it already had when signed (a page's Form XObject: the - // renderer ignores /Type), does not make rewriting it a metadata change. - kind::METADATA - } else if old_xfa_data.contains(&r) { - // The form's XFA data (`datasets`) or state (`form`) as the signed revision - // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) - // save of a certified form does (#2759). The role comes from the signed - // revision, so a later one can't name page content as XFA data. - kind::FORM_FILL - } else { - kind::OTHER - } - } - _ if before.is_none() => continue, - _ => kind::OTHER, - }; + let Some(what) = classifier.classify(r, &new, before.as_deref()) else { continue }; let ok = match (what, p) { (kind::SIGNATURE | kind::SECURITY_STORE, _) => true, (kind::FORM_FILL, Some(2 | 3) | None) => true, @@ -195,6 +139,102 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +struct ChangedObjectClassifier<'a> { + doc: &'a Document, + old: &'a Document, + old_content: &'a HashSet, + old_metadata: Option, + old_xfa_data: &'a HashSet, + signed_pages: &'a HashSet, + signed_annots: &'a HashSet, + signed_resources: &'a HashMap, + old_info: Option, + dss: &'a HashSet, +} + +impl ChangedObjectClassifier<'_> { + fn classify(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + if let Some(kind) = self.resource_kind(r, new, before) { + return Some(kind); + } + if self.dss.contains(&r.num) { + return Some(kind::SECURITY_STORE); + } + match new { + Object::Array(now) if self.is_signed_annotation_array(r, before) => Some(self.annotation_array_change(now, before)), + Object::Dict(dict) => Some(self.dictionary_change(r, dict, before)), + Object::Stream(stream) => self.stream_change(r, stream, before), + _ if before.is_none() => None, + _ => Some(kind::OTHER), + } + } + + fn resource_kind(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + match (self.signed_resources.get(&r), new) { + (Some(false), _) => Some(kind::PAGE_CONTENT), + (Some(true), Object::Dict(dict)) if self.keeps_previous_entries(dict, before) => None, + (Some(true), _) => Some(kind::PAGE_CONTENT), + (None, _) => None, + } + } + + fn keeps_previous_entries(&self, dict: &Dict, before: Option<&Object>) -> bool { + before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| dict.get(key) == Some(value))) + } + + fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool { + self.signed_annots.contains(&r) && before.is_some_and(|object| object.as_array().is_some()) + } + + fn annotation_array_change(&self, now: &[Object], before: Option<&Object>) -> &'static str { + let was: HashSet = + before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|reference| !kept.contains(reference)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|reference| !was.contains(reference)).collect(); + added_annots(self.doc, &added) + } else if removed.iter().any(|reference| self.old.get(*reference).as_dict().is_some_and(|dict| dict.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } + } + + fn dictionary_change(&self, r: ObjRef, dict: &Dict, before: Option<&Object>) -> &'static str { + let previous = before.and_then(Object::as_dict); + if self.signed_pages.contains(&r) { + // A signed page is judged by what the page shows, whatever its new labels say. + page_change_kind(self.doc, self.old, dict, previous) + } else if previous.is_some() && Some(r) == self.old_info { + // Only the signed revision's own /Info dictionary is a metadata change. + kind::METADATA + } else { + change_kind(self.doc, self.old, dict, previous) + } + } + + fn stream_change(&self, r: ObjRef, stream: &pdfcraft_cos::Stream, before: Option<&Object>) -> Option<&'static str> { + // File plumbing is the stream's role when signed; a later /Type label cannot change it. + let was_plumbing = matches!(before, Some(Object::Stream(previous)) if matches!(previous.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); + if self.old_content.contains(&r) { + Some(kind::PAGE_CONTENT) + } else if before.is_none() || was_plumbing { + None + } else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r) == self.old_metadata { + // Only the signed catalog's XMP stream is metadata. + Some(kind::METADATA) + } else if self.old_xfa_data.contains(&r) { + // Signed XFA data and state packets are changed by a form fill. + Some(kind::FORM_FILL) + } else { + Some(kind::OTHER) + } + } +} + /// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` /// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one /// XFA stream holds its template too, and is not listed. diff --git a/crates/sign/src/pdf/listing.rs b/crates/sign/src/pdf/listing.rs index 88379b183..cbcf33971 100644 --- a/crates/sign/src/pdf/listing.rs +++ b/crates/sign/src/pdf/listing.rs @@ -1,6 +1,6 @@ use super::types::{Modification, Status, TrustStore}; use super::validate::{is_doc_timestamp, validate_doc_timestamp, validate_into}; -use super::*; +use super::{Arc, Dict, DigestAlg, Document, HashMap, HashSet, ObjRef, Object, SignatureInfo}; /// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of /// one document: incremental edits only append to the file, so the signed bytes and their diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index 0d00c68a6..71dd10b30 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -1,5 +1,5 @@ use super::listing::{mk_rotation, nums, sig_fields}; -use super::*; +use super::{Certificate, Dict, DigestAlg, DigitalId, Document, HashSet, Object, PdfString, SaveOptions, SignError, SignedData, Stream}; // ── signing ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/sign/src/pdf/types.rs b/crates/sign/src/pdf/types.rs index c061bc369..881f50357 100644 --- a/crates/sign/src/pdf/types.rs +++ b/crates/sign/src/pdf/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Certificate; /// Certificates the user trusts for signing (Acrobat: Trusted Certificates). #[derive(Clone, Debug, Default)] diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 1f0b8c342..08b482c4b 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -1,7 +1,25 @@ use super::changes::classify_changes; use super::listing::{DigestCache, list, nums, sig_fields, text}; use super::types::{Detail, Fill, Modification, Status, TrustStore}; -use super::*; +use super::{ + Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, +}; + +struct SignatureValidation<'a> { + doc: &'a Document, + bytes: &'a [u8], + trust: &'a TrustStore, + info: &'a mut SignatureInfo, + cache: &'a DigestCache, +} + +struct VerifiedSignature<'a> { + covered: usize, + certificate: Certificate, + embedded: &'a [Certificate], + cms_time: Option