diff --git a/ROADMAP.md b/ROADMAP.md index 8827e2da53..e20839c69b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -528,6 +528,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. - **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. +- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up. +- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/annot/README.md b/crates/annot/README.md index 6ab3736ed2..3c258a2ab7 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, comment another program drew with a font that has those characters, whose appearance is kept rather than redrawn with `?` (new text for it is taken). - **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status" - does), change text, move, resize, restyle, delete (with pop-up and replies). + does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close + or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment + that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up. ## API sketch diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0083761288..e16803484a 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -8,19 +8,25 @@ //! itself; for the other values, callers that hold the document follow references first //! (`draw::with_direct_values`). //! -//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard -//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3 -//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses -//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances. +//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text +//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for +//! ([`crate::text_style`]), and draw Thai from the craft-fonts Thai face as Type 3 glyphs +//! (`pdfcraft_fonts::shaped_text`, the writer Add text uses); lines break at that face's +//! standard-14 widths and the Thai face's shaped advances (no other font program is bundled). use pdfcraft_cos::{Dict, Object, PdfString, Stream}; use pdfcraft_fonts::shaped_text; -use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with}; +use pdfcraft_fonts::{first_undrawable, literal, win_ansi, wrap_with}; /// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai /// face that text boxes draw it with. pub fn text_width(text: &str, size: f64) -> f64 { - shaped_text::thai_width(text, size, helvetica_width) + text_width_in(pdfcraft_fonts::Std14::Helvetica, text, size) +} + +/// [`text_width`] in the standard `face` a text box draws its other text in. +pub fn text_width_in(face: pdfcraft_fonts::Std14, text: &str, size: f64) -> f64 { + shaped_text::thai_width(text, size, |s, size| face.text_width(s, size)) } /// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter. @@ -410,13 +416,17 @@ pub fn da_border(d: &Dict) -> Option { col } -/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt. +/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a +/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are +/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form +/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite +/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator. pub fn parse_da(d: &Dict) -> (Rgb, f64) { let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default(); let toks: Vec<&str> = da.split_whitespace().collect(); let mut col = [0.0; 3]; let mut size = 12.0; - let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); for (i, t) in toks.iter().enumerate() { match *t { "rg" if i >= 3 => { @@ -544,7 +554,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)), b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), - b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), + b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => { + ("the stamp's \"By\" line", text(b"PCByLine")?) + } // Drawn in Helvetica when the redaction is applied: refused when it is marked. b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?), _ => return None, @@ -554,18 +566,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. pub(crate) fn helvetica_resource() -> Object { - Object::Dict(helvetica_fonts()) + Object::Dict(font_resources(pdfcraft_fonts::Std14::Helvetica)) } -/// [`helvetica_resource`]'s dictionary. -fn helvetica_fonts() -> Dict { +/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name +/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica). +fn font_resources(face: pdfcraft_fonts::Std14) -> Dict { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"BaseFont".to_vec(), Object::name(face.base_font())); font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); + fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font)); fonts } @@ -661,7 +674,8 @@ pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> 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), + // The face a text box's `/DA` font names in the form's `/DR`, when it names one. + b"FreeText" => return draw_free_text(&context, drawing, crate::text_style::dr_face(doc, d)), _ => {} } let mut drawing = drawing; @@ -971,11 +985,31 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) } 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 = quads(context)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + // ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of + // three numbers is the one some writers give it, and is drawn (the observation is in + // `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an + // optional-content dictionary, which this leaves alone. + let outline = nums(d, b"OC").and_then(|c| match c.as_slice() { + [r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]), + _ => None, + }); + // Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were. + let (q, col) = match d.get(b"QuadPoints") { + Some(_) => (quads(context)?, outline.or(stroke)), + // Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its + // edges when the mark names an outline colour, else not drawn while marked. + None => { + let outline = outline?; + let rect = context.rect; + let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5]; + (vec![[x0, y1, x1, y1, x0, y0, x1, y0]], Some(outline)) + } + }; + let col = col.unwrap_or([0.89, 0.13, 0.13]); c.push_str(&format!("{}1 w\n", rg_stroke(col))); for quad in q.iter() { c.push_str(&format!( @@ -1230,7 +1264,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - 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 rectangle. An image signature or a stamp with a line 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) { @@ -1239,14 +1274,61 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)), _ => None, }); - let place = picture_place(rect, image, size)?; + // A text line (#1741) takes the bottom of the rectangle, and the picture keeps its + // shape, centred above it. Without one the picture fills the rectangle. + let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty()); + let turn = picture_turn(d); + // A lined stamp on a quarter-turned page is laid out in its displayed box (the + // rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below. + // Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale + // the two displayed axes differently and squash the picture and its line. + let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect }; + let [x0, y0, x1, y1] = bbox; + let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 }; + let (w, h) = (x1 - x0, y1 - y0 - band); + let place = match (&line, size) { + (Some(_), Some((sw, sh))) => { + let k = (w / sw).min(h / sh).max(0.0); + let (pw, ph) = (sw * k, sh * k); + let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0); + if image { + format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py)) + } else { + format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py)) + } + } + // Without a line the band is empty and this is the rectangle itself. + _ => picture_place([x0, y0 + band, x1, y1], image, size)?, + }; c.push_str(&format!("q {place} /Pic Do Q\n")); + // Bytes from here: the line is WinAnsi, not UTF-8. + let mut content = std::mem::take(c).into_bytes(); + if let Some(l) = &line { + // Centred in the band, at its usual size unless the stamp was made narrower. + let mut size = band * crate::CUSTOM_LINE_TEXT; + let tw = text_width(l, size); + if tw > w - 4.0 && tw > 0.0 { + size *= ((w - 4.0) / tw).max(0.0); + } + let tw = text_width(l, size); + let ty = y0 + (band - size * 0.72) / 2.0; + content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes()); + content.extend(literal(&win_ansi(l))); + content.extend_from_slice(b" Tj\nET\n"); + 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("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(f)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } 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 { + let mut stream = form(bbox, &content, res); + if (image || line.is_some()) && 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]; @@ -1359,7 +1441,7 @@ fn shaped_border( Some(rect) } -fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option) -> Option { let d = context.dict; let parts = context.parts; let rect = context.rect; @@ -1367,11 +1449,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin 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 crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face); // The box's border and a callout's leader line: their own colour, else the text's. let line_color = da_border(d).unwrap_or(text_color); let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166). + let bw = border_width(d); // 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 @@ -1469,37 +1552,38 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin c.push_str("/GS1 gs\n"); } c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + "{} {} {} {} re W n\nBT\n/{} {} Tf\n{}", n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]), + crate::text_style::resource_name(face), n(size), rg(text_color) )); let mut out = c.into_bytes(); - let lines = wrap(&text, size, width); + let lines = pdfcraft_fonts::wrap_with(&text, size, width, |s, size| text_width_in(face, s, size)); let mut y = rect[3] - pad - size * reach(&lines)[0]; if round { y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0); } // Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose // streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as - // `?`, which `undrawable_text` reports), in Helvetica. - let mut fonts = helvetica_fonts(); + // `?`, which `undrawable_text` reports), in the box's standard face. + let mut fonts = font_resources(face); let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts); let mut current = None; for pieces in &pieces { if y < rect[1] - size { break; } - let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width); + let lw = shaped_text::pieces_width(pieces, size, 0.0, |s, size| face.text_width(s, size)); let x = match q { 1 => rect[0] + pad + (width - lw) / 2.0, 2 => rect[2] - pad - lw, _ => rect[0] + pad, }; - shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current); + shaped.show_line(&mut out, pieces, x, y, size, crate::text_style::resource_name(face), win_ansi, |s| face.text_width(s, size), &mut current); y -= size * 1.2; } out.extend_from_slice(b"ET\n"); diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 1b55e48932..e592b5d934 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -204,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str { pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { let [x0, y0, x1, y1] = normalize(rect); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - if knee[0] < x0 { - [x0, cy] - } else if knee[0] > x1 { - [x1, cy] - } else if knee[1] > y1 { - [cx, y1] + match callout_side(rect, knee) { + Side::Left => [x0, cy], + Side::Right => [x1, cy], + Side::Top => [cx, y1], + Side::Bottom => [cx, y0], + } +} + +/// A side of a callout's text box. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + Left, + Right, + Top, + Bottom, +} + +impl Side { + /// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat + /// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019, + /// p. 357). + pub fn horizontal(self) -> bool { + matches!(self, Side::Left | Side::Right) + } +} + +/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to. +/// +/// Every position has one side (review on #4094): +/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a +/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it; +/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the +/// left or right edge past the box is above or below it; +/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands +/// positions back as `f32` values: the side whose edge it lies on, the left or right edge +/// first, so a corner takes the left or right side as a knee beside the box does. The line +/// stays out of the box (it was sent across the box to the bottom side); +/// - inside the box, or with a coordinate that isn't a number: the bottom side. +pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side { + let [x0, y0, x1, y1] = normalize(rect); + let [x, y] = knee; + // Before any comparison: one with NaN is false, so the other coordinate alone would decide. + if x.is_nan() || y.is_nan() { + return Side::Bottom; + } + if x < x0 { + Side::Left + } else if x > x1 { + Side::Right + } else if y > y1 { + Side::Top + } else if y < y0 { + Side::Bottom + } else if x - x0 < CALLOUT_SLACK { + Side::Left + } else if x1 - x < CALLOUT_SLACK { + Side::Right + } else if y1 - y < CALLOUT_SLACK { + Side::Top } else { - [cx, y0] + Side::Bottom + } +} + +/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in +/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again +/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the +/// knee can't come nearer the box than that. +pub const CALLOUT_KNEE_LINE: f64 = 12.0; + +/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle +/// of that side, so the knee line is level (left, right) or upright (top, bottom). +pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] { + let [x0, y0, x1, y1] = normalize(rect); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + match side { + Side::Left => [x0 - CALLOUT_KNEE_LINE, cy], + Side::Right => [x1 + CALLOUT_KNEE_LINE, cy], + Side::Top => [cx, y1 + CALLOUT_KNEE_LINE], + Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE], + } +} + +/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point` +/// ([`callout_knee_on`]). +pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] { + callout_knee_on(rect, callout_side(rect, point)) +} + +/// How far apart, in points, two positions of a callout's box or line may be and still count as the +/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few +/// hundred-thousandths of a point (review on #4094). +pub(crate) const CALLOUT_SLACK: f64 = 0.01; + +/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it. +pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool { + let [x0, y0, x1, y1] = normalize(rect); + let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK; + let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK; + match side { + Side::Left => near(end[0], x0) && within(end[1], y0, y1), + Side::Right => near(end[0], x1) && within(end[1], y0, y1), + Side::Top => near(end[1], y1) && within(end[0], x0, x1), + Side::Bottom => near(end[1], y0) && within(end[0], x0, x1), } } @@ -296,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); d.set(b"PCOutline".to_vec(), Object::Array(list)); } - Shape::CustomStamp { name, picture, image, size, .. } => { + Shape::CustomStamp { name, picture, image, size, line, .. } => { let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty()); + if let Some(l) = lined { + d.set(b"PCByLine".to_vec(), PdfString::text(l)); + } // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { + // turned back (see `appearance::build`), so it reads upright as displayed. A stamp + // with a line is laid out in the displayed box, so it needs the turn too. + if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) { let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if turn != 0 { d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); @@ -500,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: Ok(()) } +/// The share of a custom stamp's height its text line takes (the rest is the picture). +pub const CUSTOM_LINE_BAND: f64 = 0.22; + +/// A custom stamp line's text height as a share of its band. +pub const CUSTOM_LINE_TEXT: f64 = 0.62; + +/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`, +/// the band under the picture is added, and the stamp is as wide as the line at its usual size +/// when that is wider than the picture. +pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) { + let Some(line) = line else { return picture }; + let h = picture.1 / (1.0 - CUSTOM_LINE_BAND); + let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0; + (picture.0.max(text), h) +} + /// 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/info.rs b/crates/annot/src/info.rs index 71a4fbfa32..99c3264006 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -1,7 +1,8 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; -use super::modify::{fillable, touch, unlocked}; +use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked}; +use super::text_style; use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; @@ -23,7 +24,8 @@ pub struct Summary { pub in_reply_to: Option, /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + /// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is + /// ([`is_group_subordinate`]), not a reply to it. pub grouped: bool, pub rect: [f32; 4], pub color: Option<[f32; 3]>, @@ -35,9 +37,66 @@ pub struct Summary { pub fill_sign: bool, /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. pub no_rotate: bool, - /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud - /// (`/Vertices`), in user space; empty for other comments. + /// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows + /// the text box), in user space; empty for other comments. pub points: Vec<[f32; 2]>, + /// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments. + pub text_box: Option<[f32; 4]>, + /// The comment's pop-up window (`/Popup`), when it has one. + pub popup: Option, +} + +impl Summary { + /// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2 + /// §12.5.6.2): part of its primary's comment, not a reply. As + /// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`. + pub fn is_group_member(&self) -> bool { + self.grouped && self.parent.is_some() + } +} + +/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it +/// is shown open. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PopupInfo { + /// `/Rect` of the `/Popup` annotation, in user space. + pub rect: [f32; 4], + /// `/Open` of the `/Popup` annotation. + pub open: bool, +} + +/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in +/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a +/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another +/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be +/// an indirect reference)". +pub(crate) fn own_popup(doc: &Document, own: Option, annot: &Dict) -> Option { + let pr = annot.reference(b"Popup")?; + let is_own = doc.get(pr).as_dict().is_some_and(|pd| { + pd.name(b"Subtype") == Some(b"Popup") + && pd + .get(b"Parent") + .is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own)))) + }); + is_own.then_some(pr) +} + +/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it +/// is unreadable. +pub(crate) fn popup_info(doc: &Document, own: Option, annot: &Dict) -> Option { + // Another annotation's rectangle and open state aren't this comment's (review on #2137). + let r = own_popup(doc, own, annot)?; + let p = doc.get(r); + let p = p.as_dict()?; + let rect: Vec = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect(); + // Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order. + let [x0, y0, x1, y1] = rect[..] else { return None }; + if !rect.iter().all(|v| v.is_finite()) { + return None; + } + let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true))); + Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open }) } pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -74,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { r } +/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an +/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT` +/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored. +/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with +/// `pdfcraft_render::inspect`. +pub fn is_group_subordinate(d: &Dict) -> bool { + d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some() +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -130,15 +198,29 @@ pub fn summaries(doc: &Document) -> Vec { pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect() }) .unwrap_or_default(); - // Keep in step with `pdfcraft_render::inspect`. + // Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a + // readable text box, without which they can't be changed either. + let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten(); let points = match subtype.as_str() { "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + "FreeText" => callout + .as_ref() + .and_then(|(line, _)| line.split_last()) + .map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect()) + .unwrap_or_default(), _ => Vec::new(), }; + let text_box = callout.map(|(_, t)| t.map(|v| v as f32)); let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); + // A group is one page's, as Acrobat reads it: a subordinate whose primary is on + // another page is a comment of its own (keep in step with + // `pdfcraft_render::link_replies`). + let grouped = d.name(b"RT") == Some(b"Group"); + let cross_page = grouped && parent.is_some_and(|(p, _)| p != page); + let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) }; out.push(Summary { page, index, @@ -151,7 +233,7 @@ pub fn summaries(doc: &Document) -> Vec { name: text_value(doc, d, b"NM"), in_reply_to, parent, - grouped: d.name(b"RT") == Some(b"Group"), + grouped, rect, color, // A reply that gives only `/StateModel` has the model's default state (ISO 32000-2 @@ -167,6 +249,8 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, + text_box, + popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d), }); } } @@ -256,10 +340,15 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let direct = crate::draw::with_direct_values(doc, raw); let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") }; + // A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one). + let style = (subtype == "FreeText").then(|| text_style::text_style(d, None)); + let color = match &style { + Some(s) => Some(s.color), + None => drawn_color(d, b"C"), + }; let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { if subtype == "FreeText" { - if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + appearance::border_width(d) } else { d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) } @@ -276,7 +365,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), - font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + font_size: style.map(|s| s.size), border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(), border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)), fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill), diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 52c0aad74f..63ff5a5be0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -29,9 +29,12 @@ mod types; #[cfg(test)] use create::num_array; -pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use create::{ + CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, + custom_stamp_size, orient_image_stamp, +}; pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; -pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; +pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; @@ -41,9 +44,9 @@ pub use lookup::{ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, - set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, - set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members, + set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup, + set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ @@ -53,6 +56,7 @@ pub use types::{ pub mod appearance; pub mod links; +pub mod text_style; #[cfg(test)] mod tests; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 153be15346..eb77e3f973 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,15 +1,17 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect, - rect_for, rgb, + CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow, + ink_bounds, normalize, num_array, on_side, polygon_rect, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{line_endings_of, text_value}; -use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; +use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; +use super::text_style::{self, TextStyle}; use super::types::{ BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; +use pdfcraft_fonts::Std14; /// 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. @@ -44,11 +46,36 @@ pub(crate) fn n(v: f64) -> String { // ── edits ─────────────────────────────────────────────────────────────────────────────────── -/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its -/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. +/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it). pub(crate) fn rich_text(d: &mut Dict) { + let (color, size) = appearance::parse_da(d); + rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color }); +} + +/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names +/// none) and the border colour it gives (`RG`, [`appearance::da_border`]) with the new colour and +/// size, and `/DS` and `/RC` carry the face, so the text keeps the face it is drawn in (ISO +/// 32000-2 Table 177). +fn restyle_text(d: &mut Dict, style: TextStyle) { + let border = appearance::da_border(d); + restyle_text_bordered(d, style, border); +} + +/// [`restyle_text`] with the border colour `border` (`None`: the text's). +fn restyle_text_bordered(d: &mut Dict, style: TextStyle, border: Option) { + let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into()); + let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); + let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default(); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg {stroke}/{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes())); + rich_text_in(d, TextStyle { color: [r, g, b], ..style }); +} + +/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its +/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing. +fn rich_text_in(d: &mut Dict, style: TextStyle) { let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); - let (col, size) = appearance::parse_da(d); + let TextStyle { face, size, color: col } = style; + let (family, weight, slant) = text_style::css(face); let hex = format!( "#{:02X}{:02X}{:02X}", (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, @@ -63,13 +90,15 @@ pub(crate) fn rich_text(d: &mut Dict) { let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); let style = format!( - "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + "font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal", n(size) ); let rc = format!( "{body}" ); - d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + // "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight. + let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " }); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size)))); d.set(b"RC".to_vec(), PdfString::text(&rc)); } @@ -80,9 +109,14 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { } /// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. -/// Refused while it, or a reply that would go with it, is locked. +/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT +/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the +/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act +/// on the entire group; a subordinate left without its primary is a comment of its own and +/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and +/// go together from either ([`own_deletion`]). pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - deletable(doc, page, index)?; + let index = deleted_index(doc, page, index)?; lift_annotation(doc, page, index) } @@ -90,23 +124,44 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul /// changing anything: what a Delete command asks before it is offered. It is refused while the /// annotation is locked, or a reply that would go with it is. pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + deleted_index(doc, page, index).map(|_| ()) +} + +/// [`deletable`], returning the index [`delete_annotation`] deletes from: the caret for Replace +/// Text's strikeout, else `index`. +fn deleted_index(doc: &Document, page: usize, index: usize) -> Result { let list = annots(doc, page_ref(doc, page)?); - let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let mut index = index; if let Some(r) = target.as_ref() { unlocked(doc, r)?; } + // Replace Text is one comment: its strikeout goes with its caret, the group's primary, and + // the caret with it, as Acrobat deletes the pair from either. + let caret = doc + .resolve(target) + .as_dict() + .filter(|d| is_group_subordinate(d) && !own_deletion(doc, d)) + .and_then(|d| d.reference(b"IRT")) + .and_then(|irt| list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(irt))); + if let Some((i, e)) = caret { + (target, index) = (e, i); + } let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it - // answers either. Pop-ups follow their comment. - for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + // answers either (ISO 32000-2 §12.5.3, Table 167: Locked); nor does the other half of Replace + // Text. Pop-ups follow their comment. + for (i, e) in list.iter().enumerate().filter(|(i, e)| (*i != index || caret.is_some()) && goes_with(doc, e, &doomed)) { let obj = doc.resolve(e); if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) && annotation_flags(doc, d) & FLAG_LOCKED != 0 { - return Err(AnnotError::Invalid("a reply to it is locked".into())); + let grouped = is_group_subordinate(d) || caret.is_some_and(|(c, _)| c == i); + let what = if grouped { "a comment in its group is locked" } else { "a reply to it is locked" }; + return Err(AnnotError::Invalid(what.into())); } } - Ok(()) + Ok(index) } /// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as @@ -117,10 +172,42 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result< let list = annots(doc, p); let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); - let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect(); + let kept = release_members(doc, kept, &doomed)?; Ok(set_annots(doc, p, kept)?) } +/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays +/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its +/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted. +/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An +/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is +/// read once, following no chain. +pub fn release_members(doc: &mut Document, kept: Vec, going: &HashSet) -> Result, pdfcraft_cos::CosError> { + let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r)); + let mut out = Vec::with_capacity(kept.len()); + for e in kept { + match e.as_ref() { + Some(r) => { + if doc.get(r).as_dict().is_some_and(orphaned) { + doc.update_dict(r, |d| { + d.remove(b"IRT"); + })?; + } + out.push(e); + } + None => match e { + Object::Dict(mut d) if orphaned(&d) => { + d.remove(b"IRT"); + out.push(Object::Dict(d)); + } + other => out.push(other), + }, + } + } + Ok(out) +} + /// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline /// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a /// Replace Text proposal also takes the caret it is grouped under, so the two go together @@ -144,16 +231,35 @@ fn replaced_by(doc: &Document, strike: &Object) -> Option { /// one of them, or it is written inline (a file can, though the standard asks for references) and /// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference /// for the walk to collect, so it is matched here. -pub fn goes_with(entry: &Object, going: &HashSet) -> bool { +pub fn goes_with(doc: &Document, entry: &Object, going: &HashSet) -> bool { match entry.as_ref() { Some(r) => going.contains(&r), - None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + None => entry.as_dict().is_some_and(|d| hangs_off(doc, d).into_iter().flatten().any(|r| going.contains(&r))), } } +/// What the annotation `d` goes with when it is deleted: what it replies to (`/IRT`) and what it +/// pops up for (`/Parent`). A group subordinate (`/RT /Group`) is deleted on its own +/// ([`own_deletion`]), so its `/IRT` doesn't count. +fn hangs_off(doc: &Document, d: &Dict) -> [Option; 2] { + [d.reference(b"IRT").filter(|_| !own_deletion(doc, d)), d.reference(b"Parent")] +} + +/// Whether the annotation `d` stays when its group's primary is deleted: a group subordinate +/// (`/RT /Group`) does, as Acrobat deletes only the member clicked, unless it is half of Replace +/// Text, which Acrobat deletes as one comment: the strikeout (`/IT /StrikeOutTextEdit`) under its +/// caret, or a caret under such a strikeout, as earlier PdfCraft builds wrote the pair. +fn own_deletion(doc: &Document, d: &Dict) -> bool { + let edit = |d: &Dict| d.name(b"IT") == Some(b"StrikeOutTextEdit".as_slice()); + let under_edit = + || d.name(b"Subtype") == Some(b"Caret".as_slice()) && d.reference(b"IRT").is_some_and(|r| doc.get(r).as_dict().is_some_and(edit)); + is_group_subordinate(d) && !edit(d) && !under_edit() +} + /// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or /// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their -/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// own replies, and the pop-ups going annotations name (`/Popup`). A group subordinate stays +/// ([`own_deletion`]). An inline reply has no /// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or /// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also /// matches inline entries. @@ -165,7 +271,7 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int // What goes with the annotation this entry hangs off: the entry, or for an inline one the // pop-up it names. let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; - for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + for on in hangs_off(doc, d).into_iter().flatten() { hanging.entry(on).or_default().push(goes); } } @@ -186,15 +292,17 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int /// around its text stays snug around the new text, centred where it was. pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; - let d = annot_dict(doc, r); - let free_text = d.name(b"Subtype") == Some(b"FreeText"); + let before = annot_dict(doc, r); + let free_text = before.name(b"Subtype") == Some(b"FreeText"); refuse_undrawable_thai(free_text, text)?; - let snug = free_text && is_snug(&d); + let snug = free_text && is_snug(&before); + // The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one). + let style = text_style::text_style(&before, text_style::dr_face(doc, &before)); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); // The rich-text version follows the new plain text (text boxes), or would contradict it. if free_text { - rich_text(d); + rich_text_in(d, style); } else { d.remove(b"RC"); } @@ -227,7 +335,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let d = annot_dict(doc, r); let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; - let (_, size) = appearance::parse_da(&d); + // Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one). + let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d)); + let measure = |s: &str, size: f64| appearance::text_width_in(face, s, size); let pad = 2.0 + border_width_of(&d); let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; let rect = [rect[0], rect[1], rect[2], rect[3]]; @@ -245,10 +355,10 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { w } else { // No usable old width: fall back to the creation-time width (at most 300 pt). - let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max); (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) }; - let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size); + let height = appearance::text_lines_height(&pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure), size); Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top]) } @@ -328,6 +438,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } } +/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it, +/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its +/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new +/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply +/// shows in its parent's). The lock does not apply: the window's place and state are not the +/// comment. The modification date is left alone: nothing of the comment changed. +pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option, meta: &Meta) -> Result<(), AnnotError> { + let (p, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") { + return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own"))); + } + let rect = match rect { + Some(rect) => { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid pop-up rectangle".into())); + } + Some(rect) + } + None => None, + }; + let _ = meta; + // Where a new pop-up goes: to the right of the comment, as new notes' do. + let own: Vec = d + .get(b"Rect") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|v| doc.resolve(v).as_f64()) + .collect(); + let beside = match own[..] { + [_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1], + _ => [0.0, 0.0, 200.0, 120.0], + }; + // Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up + // whose `/Parent` is another comment) would move or open that one, so it gets its own. + match own_popup(doc, Some(r), &d) { + Some(pr) => { + // One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets + // the new pop-up's place: opening it then shows it. + let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside)); + doc.update_dict(pr, |p| { + if let Some(rect) = rect { + p.set(b"Rect".to_vec(), num_array(&rect)); + } + if let Some(open) = open { + p.set(b"Open".to_vec(), Object::Bool(open)); + } + })? + } + None => { + let mut pd = Dict::new(); + pd.set(b"Type".to_vec(), Object::name("Annot")); + pd.set(b"Subtype".to_vec(), Object::name("Popup")); + pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside))); + pd.set(b"Parent".to_vec(), Object::Ref(r)); + pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false))); + pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let pref = doc.add(Object::Dict(pd)); + doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; + let mut list = annots(doc, p); + list.push(Object::Ref(pref)); + set_annots(doc, p, list)?; + } + } + // A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4). + if let Some(open) = open + && (subtype == "Text" || d.contains(b"Open")) + { + doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?; + } + Ok(()) +} + /// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, /// restyled or deleted; its text and replies stay editable, as in Acrobat. pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { @@ -612,10 +799,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo /// At most this many points reshape a comment (a drawn polygon has a handful). const MAX_POINTS: usize = 100_000; -/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two -/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the -/// points with room for the line's width and endings, as when the comment was drawn, and the -/// appearance is redrawn. A measurement keeps the points it measured. +/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a +/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as +/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's +/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's +/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it +/// measured. pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; unlocked(doc, r)?; @@ -626,6 +815,15 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; if points.len() > MAX_POINTS { return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); } + if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() { + // A callout's points: its arrow point, then optionally its knee (a four-number `/CL` + // gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee. + if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) { + return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into())); + } + let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?; + return place_text_box(doc, r, text_box, Some(points), meta, false); + } // The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's. let style = Style { width: appearance::border_width(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); @@ -700,6 +898,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res /// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn /// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { + place_text_box(doc, r, rect, None, meta, new_text) +} + +/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six +/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`. +pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option> { + // All or nothing: a non-number would shift every later number into the wrong point. + let l: Vec = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::>()?; + (matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec()) +} + +/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both. +pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> { + let nums = |key: &[u8]| -> Option> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() }; + let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?; + let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?; + let r = normalize([r[0], r[1], r[2], r[3]]); + let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]]; + (tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb) +} + +/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by +/// their handles) replaces the leader line's own points. The arrow point never moves with the box +/// ("The text box moves around a stationary anchor point—the arrow on the end-point line", +/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021 +/// Continuous release) was seen to: +/// - the box moved without changing size: the knee and the end move with it (also seen in +/// Acrobat Pro 2026.002.22031); +/// - the box resized with the side the line meets left where it was (the end still on it): the +/// line doesn't change; +/// - otherwise (that side moved): the line meets the middle of that side, the knee +/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to +/// the arrow point then; PdfCraft leaves the box where it was put. +/// +/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the +/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side. +fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> { let rect = normalize(rect); if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); @@ -708,17 +943,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne if !new_text { refuse_foreign_text(doc, &d)?; } - // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. - let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); - let (outer, cl) = match callout { - Some(mut l) => { - let attach = callout_attach(rect, [l[2], l[3]]); - (l[4], l[5]) = (attach[0], attach[1]); - let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; - let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); - ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) + // A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number + // `/CL` (no knee) stays four numbers unless `lead` adds a knee. + let old_line = callout_line(doc, &d); + let old_box = callout_text_box(doc, &d); + let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied(); + // An arrow point alone moves the arrow: a knee the line has stays where it is. + let kept: [[f64; 2]; 2]; + let lead = match (lead, old_line.as_deref()) { + (Some([point]), Some([_, knee, _])) => { + kept = [*point, *knee]; + Some(&kept[..]) } - None => (rect, None), + _ => lead, + }; + let line: Option> = match (lead, old_line.as_deref()) { + (Some(lead @ [.., toward]), _) => { + let side = callout_side(rect, *toward); + let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward)); + Some(lead.iter().copied().chain([end]).collect()) + } + (Some(_), _) => None, + (None, Some(old @ [start, .., end])) => { + let (start, end) = (*start, *end); + let knee = old.get(1).filter(|_| old.len() == 3).copied(); + let toward = knee.unwrap_or(start); + Some(match old_box { + // Moved, not resized: the knee and the end come along. + Some(b) + if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK => + { + let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]); + let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy]; + [start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect() + } + // Resized, the side the line meets still under its end: nothing changes. + Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(), + // That side moved: laid out again as a new callout is, from the side facing the + // arrow point (the box may now be on the knee's other side). + Some(_) => { + let new_knee = callout_knee(rect, start); + let end = callout_attach(rect, new_knee); + [start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect() + } + None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(), + }) + } + (None, _) => None, + }; + let (outer, cl) = match line.as_deref() { + Some(line @ [lead @ .., _]) => { + // The end is on the box, so only the arrow point and the knee need room around them. + let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; + let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad); + ([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat())) + } + _ => (rect, None), }; let (shape, width) = (BorderShape::of(&d), border_width_of(&d)); let free_text = d.name(b"Subtype") == Some(b"FreeText"); @@ -749,8 +1029,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } -/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border` -/// (another app's), else 1. +/// The width a comment's border is drawn with ([`appearance::border_width`]: `/BS /W`, else +/// `/Border`'s third number, 1 when neither gives a usable one), so what is sized around it +/// leaves room for what is drawn. fn border_width_of(d: &Dict) -> f64 { appearance::border_width(d) } @@ -857,6 +1138,8 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> } refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + // The face and size the text is drawn in, which the new colour keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); // Typewriter text has no box of its own: a border goes around its text, so the box grows // (or shrinks) by the change on every side and the text keeps its place and its lines. // So does a text box inside an oval or a circle, which goes around it again. @@ -878,9 +1161,7 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> } if let Some(c) = color { if free_text { - let (_, size) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size)); - rich_text(d); + restyle_text(d, TextStyle { color: c, ..drawn }); } else { d.set(b"C".to_vec(), rgb(c)); } @@ -939,11 +1220,11 @@ pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Op return Err(AnnotError::Invalid("invalid colour".into())); } refuse_foreign_text(doc, &d)?; + // The face, size and colour the text is drawn in, which the border colour keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); let before = d; doc.update_dict(r, |d| { - let (text, size) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(text, color, size)); - rich_text(d); + restyle_text_bordered(d, drawn, color); touch(d, meta); })?; redraw_or_restore(doc, r, before) @@ -964,11 +1245,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m } refuse_foreign_text(doc, &d)?; let snug = is_snug(&d); + // The face and colour the text is drawn in, which the new size keeps. + let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d)); let before = d; doc.update_dict(r, |d| { - let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size)); - rich_text(d); + restyle_text(d, TextStyle { size, ..drawn }); touch(d, meta); })?; let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); @@ -1120,16 +1401,17 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { } /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, the two grouped so they move, list and delete as one. -/// Returns the strikeout's index. +/// struck text holding `replacement`, grouped (ISO 32000-2 §12.5.6.2) so they list, select and +/// delete as one. Returns the caret's index, the group's comment. /// -/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to -/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary, -/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has -/// to be on the primary. +/// The caret is the group's primary: it holds `replacement` in `/Contents` and has `/IT +/// /Replace`. The strikeout is its subordinate (`/IRT` to the caret, `/RT /Group`, `/IT +/// /StrikeOutTextEdit`) with no `/Contents`, since a reader ignores a subordinate's text. +/// Groups written before this the other way round (the strikeout primary, the text only on +/// the caret) still list their text: see `pdfcraft_render::fill_group_text`. /// /// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies -/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// to, so one id for both writes `inreplyto` equal to the strikeout'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. pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { @@ -1168,8 +1450,9 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")); d.set(b"IRT".to_vec(), Object::Ref(cr)); d.set(b"RT".to_vec(), Object::name("Group")); + d.remove(b"Contents"); })?; - Ok(s) + Ok(c) } /// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 9651263171..2e52ed9260 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -885,10 +885,10 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() { let outer = rect(d); let f = |k: &[u8]| -> Vec { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() }; let cl = f(b"CL"); - // The leader line keeps its start and knee; the attach point follows the re-fitted box. - assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]); + // The box grows down from its top; the side the line meets stays where it was, so the line + // doesn't change (as Acrobat keeps it while a box is resized from its other corner). let want = expected_box(612.0, 200.0, long, 12.0, 2.0); - assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into()); + assert_eq!(cl, [200.0, 520.0, 260.0, 560.0, 300.0, 606.0]); // The text box (recovered from /Rect and /RD) keeps its width and top edge. let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]]; assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0)); @@ -1609,36 +1609,43 @@ fn replace_text_groups_a_strikeout_and_a_caret() { .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); - let strike = &a[i]; - assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..]))); - let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); - // The caret is the group's primary: a group's text is read from its primary and the - // subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it. - assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..]))); - assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}"); - assert_eq!(strike.name(b"RT"), Some(&b"Group"[..])); - assert!(text(strike, b"Contents").is_empty()); + // The caret is the group's primary (ISO 32000-2 §12.5.6.2): it holds the text, has no + // `/IRT`, and is what the call returns. + let caret = &a[i]; + assert_eq!((caret.name(b"Subtype"), caret.name(b"IT")), (Some(&b"Caret"[..]), Some(&b"Replace"[..]))); + assert_eq!(text(caret, b"Contents").as_str(), "new words"); + assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none()); + // The strikeout is its subordinate, with no text of its own. + let strike = a.iter().find(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); + assert_eq!((strike.name(b"IT"), strike.name(b"RT")), (Some(&b"StrikeOutTextEdit"[..]), Some(&b"Group"[..]))); + assert!(strike.get(b"Contents").is_none()); + assert!(is_group_subordinate(strike)); // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it - // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the - // pair does not survive an export and import. + // replies to, so sharing one id writes `inreplyto` equal to the subordinate's own `name` + // and the pair does not survive an export and import. assert_eq!(text(caret, b"NM").as_str(), "r-caret"); assert_eq!(text(strike, b"NM").as_str(), "r"); - assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); let irt = strike.reference(b"IRT").unwrap(); - assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret"); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped with the caret"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); - // Deleting either one takes the other with it (the proposal is one comment). + let summary = summaries(&doc); + assert_eq!(summary.iter().filter(|s| s.page == 0 && !s.is_group_member()).count(), 2, "the fixture's Square and the caret: {summary:?}"); + // Replace Text is one comment: deleting either the caret or the strikeout deletes both, as + // Acrobat does. + for member in [i, a.iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap()] { + let mut doc = doc.clone(); + delete_annotation(&mut doc, 0, member).unwrap(); + assert!(!list(&doc, 0).iter().any(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))), "{member}"); + } let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count(); - let mut by_strike = doc.clone(); - delete_annotation(&mut by_strike, 0, i).unwrap(); - assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret"); + let si = a.iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); let mut doc = doc; let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); // A locked caret keeps the strikeout too: the pair can't be broken by deleting the other. let mut locked = doc.clone(); set_locked(&mut locked, 0, c, true).unwrap(); - assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion"); + assert!(deletable(&locked, 0, si).is_err() && delete_annotation(&mut locked, 0, si).is_err(), "a locked caret refuses its strikeout's deletion"); assert_eq!(marks(&locked), 2); delete_annotation(&mut doc, 0, c).unwrap(); assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout"); @@ -1660,6 +1667,218 @@ fn replace_text_groups_a_strikeout_and_a_caret() { ); } +/// ISO 32000-2 §12.5.6.2: `/RT /Group` makes an annotation a subordinate of the group its +/// `/IRT` annotation is primary of. Another `/RT` reads as a reply, and so does a bare `/IRT`. +/// Deleting a member deletes just it, as Acrobat does, although §12.5.6.2 lists deletion among the +/// operations that act on the entire group: the subordinate goes alone, and the primary goes with +/// its replies and leaves its subordinate, which reads as a comment of its own. +#[test] +fn a_group_reads_as_one_and_deletes_by_member() { + let mut doc = fixture(); + let square = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("sq")).unwrap(); + let (_, sr) = annot_ref(&mut doc, 0, square).unwrap(); + let irt = |doc: &mut Document, id: &str, rt: Option<&str>| { + let i = add_annotation(doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(id)).unwrap(); + let (_, r) = annot_ref(doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(sr)); + if let Some(rt) = rt { + d.set(b"RT".to_vec(), Object::name(rt)); + } + }) + .unwrap(); + i + }; + let member = irt(&mut doc, "member", Some("Group")); + irt(&mut doc, "reply", None); + irt(&mut doc, "explicit", Some("R")); + irt(&mut doc, "odd", Some("Bogus")); + let doc = reopen(&doc); + let grouped: Vec<(String, bool)> = + summaries(&doc).into_iter().filter(|s| s.in_reply_to.is_some()).map(|s| (s.name.clone().unwrap_or_default(), s.is_group_member())).collect(); + assert_eq!( + grouped, + [("member".into(), true), ("reply".into(), false), ("explicit".into(), false), ("odd".into(), false)], + "only /RT /Group groups" + ); + let mut alone = doc.clone(); + delete_annotation(&mut alone, 0, member).unwrap(); + assert_eq!(list(&alone, 0).len(), list(&doc, 0).len() - 1, "the subordinate goes alone"); + let mut doc = doc; + delete_annotation(&mut doc, 0, square).unwrap(); + let left: Vec> = list(&doc, 0).iter().map(|d| d.get(b"NM").map(|_| text(d, b"NM"))).collect(); + assert_eq!(left, [None, Some("member".into())], "the primary and its replies are gone; the fixture's Square and the subordinate stay"); + let summary = summaries(&doc); + let member = summary.iter().find(|s| s.name.as_deref() == Some("member")).unwrap(); + assert!(member.parent.is_none() && !member.is_group_member(), "left without its primary, it is a comment of its own: {member:?}"); +} + +/// A primary with one subordinate and one reply, all on page 0 of a reopened file. Returns the +/// primary's and the subordinate's `/Annots` positions and references. +fn group_of_two(text: &str) -> (Document, (usize, ObjRef), (usize, ObjRef)) { + let mut doc = fixture(); + let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap(); + let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap(); + doc.update_dict(pr, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::PdfString::text(text))).unwrap(); + let member = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("member")).unwrap(); + let (_, mr) = annot_ref(&mut doc, 0, member).unwrap(); + doc.update_dict(mr, |d| { + d.set(b"IRT".to_vec(), Object::Ref(pr)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + add_reply(&mut doc, 0, primary, "a reply", "Ada", &meta("reply")).unwrap(); + (reopen(&doc), (primary, pr), (member, mr)) +} + +/// Deleting a group's primary leaves its subordinate a comment of its own, as Acrobat does: the +/// subordinate loses its `/IRT` and keeps `/RT /Group`, so nothing refers to the deleted primary +/// and a full save leaves its text out. +#[test] +fn deleting_a_primary_releases_its_subordinate() { + let (mut doc, (primary, _), (_, mr)) = group_of_two("primary secret text"); + delete_annotation(&mut doc, 0, primary).unwrap(); + let member = doc.get(mr).as_dict().cloned().unwrap(); + assert!(member.get(b"IRT").is_none(), "the subordinate no longer names the deleted primary: {member:?}"); + assert_eq!(member.name(b"RT"), Some(&b"Group"[..]), "its /RT stays, as Acrobat leaves it"); + let s = summaries(&doc); + let listed = s.iter().find(|s| s.name.as_deref() == Some("member")).unwrap(); + assert!(listed.parent.is_none() && listed.in_reply_to.is_none() && !listed.is_group_member(), "a comment of its own: {listed:?}"); + assert!(!s.iter().any(|s| s.name.as_deref() == Some("reply")), "the primary's reply went with it"); + let full = pdfcraft_cos::write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!full.windows(19).any(|w| w == b"primary secret text"), "the deleted primary's text is not in a full save"); +} + +/// Deleting a subordinate leaves its primary as it was. +#[test] +fn deleting_a_subordinate_leaves_its_primary_untouched() { + let (mut doc, (_, pr), (member, _)) = group_of_two("primary text"); + let before = doc.get(pr).as_dict().cloned(); + delete_annotation(&mut doc, 0, member).unwrap(); + assert_eq!(doc.get(pr).as_dict().cloned(), before); + assert!(summaries(&doc).iter().any(|s| s.name.as_deref() == Some("reply")), "the primary keeps its reply"); +} + +/// An inline subordinate (`/Annots` may hold a dictionary, though the standard asks for a +/// reference) whose primary is deleted loses its `/IRT` in the page's list. +#[test] +fn an_inline_subordinate_is_released_too() { + let mut doc = fixture(); + let primary = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("primary")).unwrap(); + let (_, pr) = annot_ref(&mut doc, 0, primary).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, p); + let mut inline = Dict::new(); + inline.set(b"Type".to_vec(), Object::name("Annot")); + inline.set(b"Subtype".to_vec(), Object::name("Square")); + inline.set(b"Rect".to_vec(), Object::Array([300, 300, 350, 350].map(Object::Int).to_vec())); + inline.set(b"IRT".to_vec(), Object::Ref(pr)); + inline.set(b"RT".to_vec(), Object::name("Group")); + entries.push(Object::Dict(inline)); + set_annots(&mut doc, p, entries).unwrap(); + delete_annotation(&mut doc, 0, primary).unwrap(); + let left = list(&doc, 0); + let inline = left.iter().find(|d| d.name(b"RT") == Some(b"Group")).expect("the inline subordinate stays"); + assert!(inline.get(b"IRT").is_none()); +} + +/// Deleting follows `/IRT` replies by visiting each annotation once, with no step limit to cut a +/// thread short: a thread of 200 replies (past the 64 steps the listings follow) goes whole, and +/// replies or group members in a loop (`/IRT` back to each other) end the walk. A loop of group +/// members deletes only the one picked; the other is released. +#[test] +fn deleting_follows_a_long_or_looping_irt_chain_once_each() { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let mut on = top; + for k in 0..200 { + on = add_reply(&mut doc, 0, on, "reply", "Ada", &meta(&format!("r{k}"))).unwrap(); + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, top).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 201, "the comment and its 200-deep thread go together"); + for rt in [None, Some("Group")] { + let mut doc = fixture(); + let a = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("a")).unwrap(); + let b = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta("b")).unwrap(); + let (_, ar) = annot_ref(&mut doc, 0, a).unwrap(); + let (_, br) = annot_ref(&mut doc, 0, b).unwrap(); + for (r, to) in [(ar, br), (br, ar)] { + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(to)); + if let Some(rt) = rt { + d.set(b"RT".to_vec(), Object::name(rt)); + } + }) + .unwrap(); + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, a).unwrap(); + let left = list(&doc, 0); + match rt { + None => assert_eq!(left.len(), before - 2, "a reply loop goes whole"), + Some(_) => { + assert_eq!(left.len(), before - 1, "a group member goes alone"); + assert!(doc.get(br).as_dict().is_some_and(|d| d.get(b"IRT").is_none()), "the other member is released"); + } + } + } +} + +/// A subordinate deep in a chain of subordinates, within the 64 annotations the listing follows +/// up a chain or past them, is deleted alone: the chain above it stays. +#[test] +fn a_subordinate_deep_in_a_chain_is_deleted_alone() { + for subordinates in [63, 64, 65] { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let (_, mut up) = annot_ref(&mut doc, 0, top).unwrap(); + let mut last = top; + for k in 0..subordinates { + last = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(&format!("s{k}"))).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, last).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(up)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + up = r; + } + let before = list(&doc, 0).len(); + delete_annotation(&mut doc, 0, last).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 1, "{subordinates}: the subordinate is gone, the rest of its group stays"); + } +} + +/// A locked member doesn't hold back the deletion of another, the primary included: each goes +/// alone (ISO 32000-2 §12.5.3, Table 167: Locked). +#[test] +fn a_locked_group_member_holds_back_no_other_deletion() { + let mut doc = fixture(); + let top = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [100.0, 100.0, 200.0, 200.0] }), &meta("top")).unwrap(); + let (_, tr) = annot_ref(&mut doc, 0, top).unwrap(); + let mut members = Vec::new(); + for id in ["a", "b"] { + let i = add_annotation(&mut doc, &new(0, Shape::Oval { rect: [300.0, 300.0, 350.0, 350.0] }), &meta(id)).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.set(b"IRT".to_vec(), Object::Ref(tr)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + members.push(i); + } + set_locked(&mut doc, 0, members[1], true).unwrap(); + let before = list(&doc, 0).len(); + let mut alone = doc.clone(); + delete_annotation(&mut alone, 0, members[0]).unwrap(); + assert_eq!(list(&alone, 0).len(), before - 1, "the unlocked subordinate goes alone"); + delete_annotation(&mut doc, 0, top).unwrap(); + assert_eq!(list(&doc, 0).len(), before - 1, "the primary goes alone too"); + let locked = list(&doc, 0).iter().filter(|d| d.get(b"F").and_then(|f| f.as_int()).is_some_and(|f| f & 128 != 0)).count(); + assert_eq!(locked, 1, "the locked member stays"); +} + /// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the /// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity /// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever @@ -1679,8 +1898,9 @@ fn restyling_a_replace_text_pair_restyles_both_members() { strike_meta: &meta("r"), caret_meta: &meta("r-caret"), }; - let s = add_text_replacement(&mut doc, tr).unwrap(); - let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + // The call returns the caret, the group's primary. + let c = add_text_replacement(&mut doc, tr).unwrap(); + let s = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"StrikeOut")).unwrap(); let color = |doc: &Document, i: usize| { list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::>() }; @@ -2574,7 +2794,8 @@ fn free_text_properties_show_legacy_or_absent_border_width() { d.remove(b"Border"); }) .unwrap(); - assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0)); + // Neither /BS nor /Border: the /Border default [0 0 1] (ISO 32000-2 Table 166), as drawn. + assert_eq!(props(&doc, 0, index).unwrap().width, Some(1.0)); doc.update_dict(reference, |d| { d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect())); }) @@ -3526,6 +3747,978 @@ fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() { } } +/// PdfCraft's own text boxes, typewriter text and redaction marks write `/BS` (width 0 for text +/// boxes and typewriter text), `/DA /Helv` and a `/DS` that agrees with it, and quadrilaterals: +/// the default border, `/DA` fonts, `/DS` and the `/Rect` outline leave them exactly as they were. +#[test] +fn pdfcraft_text_boxes_and_redaction_marks_look_as_before() { + let mut doc = fixture(); + let mut tb = new(0, Shape::TextBox { rect: [100.0, 100.0, 220.0, 160.0], font_size: 12.5 }); + tb.style = Style { color: [0.1, 0.2, 0.3], fill: Some([1.0, 1.0, 0.8]), ..Style::default_for(&tb.shape) }; + tb.contents = "PdfCraft text box that wraps onto a second line".into(); + let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap(); + let mut tw = new(0, Shape::Typewriter { rect: [100.0, 300.0, 300.0, 320.0], font_size: 10.0 }); + tw.style = Style::default_for(&tw.shape); + let w = add_annotation(&mut doc, &tw, &meta("w")).unwrap(); + let quads = vec![[100.0, 520.0, 200.0, 520.0, 100.0, 500.0, 200.0, 500.0]]; + let mut rd = new(0, Shape::Redact { quads, overlay: String::new(), look: OverlayLook::default() }); + rd.style = Style::default_for(&rd.shape); + let r = add_annotation(&mut doc, &rd, &meta("r")).unwrap(); + let all = list(&doc, 0); + assert_eq!( + ap_content(&doc, &all[t]), + "1 1 0.8 rg\n100 100 120 60 re f\n100 100 120 60 re W n\nBT\n/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n1 0 0 1 102 146.75 Tm (PdfCraft text box) Tj\n1 0 0 1 102 131.75 Tm (that wraps onto a) Tj\n1 0 0 1 102 116.75 Tm (second line) Tj\nET\n" + ); + assert_eq!(text(&all[t], b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D"); + assert_eq!(ap_content(&doc, &all[w]), "100 300 200 20 re W n\nBT\n/Helv 10 Tf\n0 0 0 rg\n1 0 0 1 102 309 Tm (hello) Tj\nET\n"); + assert_eq!(ap_content(&doc, &all[r]), "0.89 0.13 0.13 RG\n1 w\n100 520 m 200 520 l 200 500 l 100 500 l h S\n"); + // New text keeps the box's look: Helvetica, /DA's exact colour (not /DS's 8-bit one). + set_contents(&mut doc, 0, t, "Edited", &meta("")).unwrap(); + let d = &list(&doc, 0)[t]; + assert_eq!(text(d, b"DS"), "font: Helvetica 12.5pt; text-align:left; color:#1A334D"); + let ap = ap_content(&doc, d); + assert!(ap.contains("/Helv 12.5 Tf\n0.1 0.2 0.3 rg\n") && !ap.contains(" re S"), "{ap}"); +} + +/// The display appearance of the annotation dictionary `annot` on a page of a document whose +/// form `/DR` maps `Helv`, `Cour`, `TiRo`, `F1` (Courier) and `F2` (a font that isn't standard), +/// and the font resources it draws with. +fn display_of(annot: &str) -> (String, Dict) { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /Helv 5 0 R /Cour 6 0 R /TiRo 7 0 R /F1 6 0 R /F2 9 0 R >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>", + b"<< /Length 0 >>\nstream\n\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>", + annot.as_bytes(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+HouseGothic >>", + ]); + let shown = with_missing_appearances(&doc).expect("drawn for display"); + let d = list(&shown, 0).remove(0); + let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("/AP /N"); + let Object::Stream(s) = &*shown.get(n) else { panic!("not a stream") }; + let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).cloned(); + (String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), fonts.unwrap_or_default()) +} + +fn base_fonts(fonts: &Dict) -> Vec { + fonts.iter().filter_map(|(_, f)| f.as_dict()?.name(b"BaseFont").map(|b| String::from_utf8_lossy(b).into_owned())).collect() +} + +/// A text box without `/BS` or `/Border` has the `/Border` default [0 0 1] (ISO 32000-2 +/// Table 166): a 1-pt border in the text colour. `/BS /W 0` or `/Border [0 0 0]` turn it off. +#[test] +fn free_text_without_a_border_entry_has_the_default_one() { + let (ap, _) = display_of("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Helv 12 Tf) /Contents (Hi) >>"); + assert!(ap.contains("0 0 1 RG\n1 w\n45.5 630.5 119 69 re S\n"), "{ap}"); + assert!(ap.contains("1 0 0 1 48 "), "text inset by the border: {ap}"); + for off in ["/BS << /W 0 >>", "/Border [0 0 0]"] { + let (ap, _) = display_of(&format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) {off} /Contents (Hi) >>")); + assert!(!ap.contains(" re S"), "{off}: {ap}"); + } +} + +/// Review on #4365: a text box's new text is fitted with the border it is drawn with. A 4-pt border +/// written as `/Border [0 0 4]` (no `/BS`) is drawn 4 pt wide, so the box grows as for `/BS /W 4`; +/// before, the fit read only `/BS` and measured it with a 1-pt border, so the drawn text, wrapped +/// 6 pt narrower, could run past the box. +#[test] +fn new_text_is_fitted_with_the_border_the_box_is_drawn_with() { + let fitted = |border: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 600 165 700] /DA (0 g /Helv 12 Tf) {border} /Contents (Hi) >>"); + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + set_contents(&mut doc, 0, 0, "Several words that wrap over a few lines of this box", &meta("")).unwrap(); + list(&doc, 0).remove(0).get(b"Rect").and_then(|r| r.as_array()).map(|r| r.iter().filter_map(|x| x.as_f64()).collect::>()).unwrap() + }; + let bs = fitted("/BS << /W 4 >>"); + assert_eq!(fitted("/Border [0 0 4]"), bs, "the same 4-pt border, the same box"); + assert_ne!(fitted("/BS << /W 1 >>"), bs, "a 1-pt border fits another box"); +} + +/// `/DA` names its font in the form's `/DR` (§12.7.4.3): a standard face is drawn in that face, +/// with its widths for wrapping; any other font stays Helvetica. +#[test] +fn free_text_da_fonts_draw_in_their_standard_face() { + use pdfcraft_fonts::Std14; + let width = 120.0 - 2.0 * 2.0; + let words = "iiiiiiiiii iiiiiiiiii"; + let lines = |face: Std14| pdfcraft_fonts::wrap_with(words, 12.0, width, |s, z| face.text_width(s, z)).len(); + assert_eq!((lines(Std14::Helvetica), lines(Std14::Courier)), (1, 2), "the case tells the faces apart"); + for (da, base, key, n) in [ + ("0 g /Cour 12 Tf", "Courier", "/Cour 12 Tf", 2), + ("1 0 0 rg /TiRo 12 Tf", "Times-Roman", "/TiRo 12 Tf", 1), + ("0 g /F1 12 Tf", "Courier", "/Cour 12 Tf", 2), + ("0 g /F2 12 Tf", "Helvetica", "/Helv 12 Tf", 1), + ("0 g /Nowhere 12 Tf", "Helvetica", "/Helv 12 Tf", 1), + ("0 g /CoBo 12 Tf", "Courier-Bold", "/CoBo 12 Tf", 2), + ] { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA ({da}) /BS << /W 0 >> /Contents ({words}) >>"); + let (ap, fonts) = display_of(&annot); + assert_eq!(base_fonts(&fonts), [base], "{da}"); + assert!(ap.contains(key), "{da}: {ap}"); + assert_eq!(ap.matches(" Tj").count(), n, "{da}: wrapped at {base}'s widths: {ap}"); + } +} + +/// Thai in a text box whose `/DA` names Times is drawn from the Thai face and the rest of its +/// text in Times: after a Thai run the text goes back to `/TiRo`, never to `/Helv`, and both +/// kinds of font are in the appearance's resources. +#[test] +fn thai_in_a_times_text_box_keeps_times_for_the_rest() { + // "สวัสดี Hi": Thai first, then Latin. + let annot = "<< /Type /Annot /Subtype /FreeText /Rect [45 630 245 700] /DA (0 g /TiRo 12 Tf) /BS << /W 0 >> /Contents >>"; + let (ap, fonts) = display_of(annot); + assert!(!ap.contains("/Helv"), "{ap}"); + assert!(base_fonts(&fonts).contains(&"Times-Roman".to_string()), "{fonts:?}"); + if pdfcraft_fonts::document_thai_font().is_none() { + return; + } + assert!(ap.contains("/PCTh1 ") && !ap.contains('?'), "the Thai is shaped: {ap}"); + assert_eq!(ap.matches("/TiRo 12 Tf").count(), 2, "Times is selected again after the Thai: {ap}"); + assert!(fonts.get(b"PCTh1").and_then(|f| f.as_dict()).is_some_and(|f| f.name(b"Subtype") == Some(&b"Type3"[..])), "{fonts:?}"); +} + +/// `/DS` (Table 177) sets the face, size and colour over `/DA`'s; a malformed one is ignored. +#[test] +fn free_text_ds_style_is_drawn() { + let annot = + "<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Times 14pt; color: #008000) /Contents (Hi) >>"; + let (ap, fonts) = display_of(annot); + assert_eq!(base_fonts(&fonts), ["Times-Roman"]); + assert!(ap.contains("/TiRo 14 Tf\n0 0.502 0 rg\n"), "{ap}"); + let junk = format!("font: NaNpt; font-size: inf; color: rgb(1e999, x); {}", "\u{FFFD}".repeat(20_000)); + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 0 1 rg /Cour 9 Tf) /DS ({junk}) /Contents (Hi) >>"); + let (ap, fonts) = display_of(&annot); + assert_eq!(base_fonts(&fonts), ["Courier"]); + assert!(ap.contains("/Cour 9 Tf\n0 0 1 rg\n"), "{ap}"); +} + +/// New text in a box drawn in Courier stays Courier: its rich text says so. +#[test] +fn new_text_keeps_a_foreign_box_face() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>", + ]); + set_contents(&mut doc, 0, 0, "Hello", &meta("")).unwrap(); + let d = &list(&doc, 0)[0]; + assert_eq!(text(d, b"DS"), "font: Courier 12pt; text-align:left; color:#000000"); + assert!(ap_content(&doc, d).contains("/Cour 12 Tf")); +} + +/// A text box whose `/DS` overrides `/DA` reports the colour and size it is drawn in, and a colour +/// or size change keeps the other drawn value. +#[test] +fn text_box_properties_report_the_drawn_style() { + let doc = || { + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA (1 0 0 rg /Helv 12 Tf) /DS (font: Helvetica 24pt; color:#0000FF) >>", + ]) + }; + let mut d = doc(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(24.0))); + set_style(&mut d, 0, 0, StyleChange::new(Some([0.0, 1.0, 0.0]), None, None, None), &meta("")).unwrap(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 1.0, 0.0]), Some(24.0)), "a colour change keeps the drawn size"); + let mut d = doc(); + set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap(); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([0.0, 0.0, 1.0]), Some(18.0)), "a size change keeps the drawn colour"); +} + +/// A redaction mark without `/QuadPoints` marks `/Rect` (Table 195). While marked it is outlined +/// only when it names an outline colour (an array `/OC` of three numbers); without one it draws +/// nothing. Marks with quadrilaterals and no `/OC` colour are outlined in `/C` as before. +#[test] +fn redaction_marks_without_quadpoints_outline_their_rect_in_their_oc_colour() { + let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /IC [0 0 0] /OC [1 0 0] /C [0 0 1] >>"); + assert_eq!(ap, "1 0 0 RG\n1 w\n45.5 539.5 m 164.5 539.5 l 164.5 470.5 l 45.5 470.5 l h S\n"); + // No /OC colour: nothing to draw while marked (no /C outline, no /IC fill, no overlay text). + let blank = |annot: &str| { + let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] {annot} >>"); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + with_missing_appearances(&doc).is_none() + }; + // An /OC that isn't three finite numbers is no colour; an optional-content dictionary keeps its meaning. + for annot in [ + "/C [0.9 0.1 0.1]", + "/IC [0 0 0] /OverlayText (REDACTED) /DA (1 1 1 rg /Helv 10 Tf) /C [0.9 0.1 0.1]", + "/OC [1 0]", + "/OC [(a) 0 0]", + "/OC << /Type /OCG /Name (L) >>", + ] { + assert!(blank(annot), "{annot}"); + } + // With /OC, the interior colour and overlay text still wait for the redaction to be applied. + let (ap, _) = + display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /OC [1 0 0] /IC [0 0 0] /OverlayText (X) /DA (1 1 1 rg /Helv 10 Tf) >>"); + assert!(!ap.contains(" f\n") && !ap.contains("Tj"), "{ap}"); + // With quadrilaterals and no /OC: the /C outline, as before. + let (ap, _) = display_of("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] /C [0 0 1] >>"); + assert_eq!(ap, "0 0 1 RG\n1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n"); +} + +/// A redaction mark with quadrilaterals is outlined in its array `/OC` colour when it has one, +/// over `/C`; without `/OC` in `/C`, and without either in the default red. +#[test] +fn redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c() { + let quad = |extra: &str| { + display_of(&format!("<< /Type /Annot /Subtype /Redact /Rect [45 470 165 540] /QuadPoints [45 540 165 540 45 470 165 470] {extra} >>")).0 + }; + let outline = "1 w\n45 540 m 165 540 l 165 470 l 45 470 l h S\n"; + assert_eq!(quad("/OC [0 0 1]"), format!("0 0 1 RG\n{outline}"), "/OC blue"); + assert_eq!(quad("/OC [0 0 1] /C [0 1 0]"), format!("0 0 1 RG\n{outline}"), "/OC over /C"); + assert_eq!(quad("/C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "/C only"); + assert_eq!(quad(""), format!("0.89 0.13 0.13 RG\n{outline}"), "neither: red"); + // An optional-content /OC is not a colour. + assert_eq!(quad("/OC << /Type /OCG /Name (L) >> /C [0 1 0]"), format!("0 1 0 RG\n{outline}"), "OCG /OC"); +} + +/// The six unapplied marks of the synthetic fixture the parity note's observation used: an array +/// `/OC` outlines the mark in its colour with or without `/QuadPoints`, beside `/C` or `/IC`; a +/// mark without `/QuadPoints` or `/OC` draws nothing while marked. +#[test] +fn unapplied_redaction_marks_follow_their_oc_outline_colour() { + let quad = "/QuadPoints [60 520 260 520 60 420 260 420]"; + let cases = [ + ("/OC [0 0 1]", Some("0 0 1 RG")), + ("/C [0 1 0]", None), + ("/OC [0 0 1] /C [0 1 0]", Some("0 0 1 RG")), + ("/OC [0 0 1] /IC [0 0 0]", Some("0 0 1 RG")), + ("", None), + (&format!("/OC [0 0 1] {quad}"), Some("0 0 1 RG")), + ]; + for (extra, colour) in cases { + let annot = format!("<< /Type /Annot /Subtype /Redact /Rect [60 420 260 520] {extra} >>"); + match colour { + Some(colour) => { + let (ap, _) = display_of(&annot); + assert!(ap.starts_with(&format!("{colour}\n1 w\n")) && ap.ends_with(" h S\n") && !ap.contains(" f\n"), "{extra}: {ap}"); + } + None => { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + assert!(with_missing_appearances(&doc).is_none(), "{extra}: drawn"); + } + } + } +} + +/// New text re-fits the box in the face and size it is drawn in (`/DA`, or `/DS` where it sets +/// one), so the rectangle holds every drawn line. +#[test] +fn new_text_refits_the_box_in_its_drawn_face_and_size() { + let bottom = |annot: &str, new_text: &str| { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]); + set_contents(&mut doc, 0, 0, new_text, &meta("")).unwrap(); + let d = &list(&doc, 0)[0]; + let rect: Vec = d.get(b"Rect").and_then(|r| r.as_array()).unwrap().iter().filter_map(|x| x.as_f64()).collect(); + assert_eq!(rect[3], 700.0, "the top edge stays"); + (rect[1], ap_content(&doc, d).matches(" Tj").count()) + }; + // The default 1-pt border: a 3-pt pad, so 114 pt for the text; the box is lines × 1.2 × size + 8 tall. + let words = "iiiiiiiiii iiiiiiiiii"; + let fits = |b: f64, want: f64| (b - want).abs() < 1e-9; + let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /Contents (Hi) >>", words); + assert!(n == 1 && fits(b, 700.0 - 14.4 - 8.0), "Helvetica: one line, bottom {b}"); + let (b, n) = bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Cour 12 Tf) /Contents (Hi) >>", words); + assert!(n == 2 && fits(b, 700.0 - 2.0 * 14.4 - 8.0), "Courier: two lines, bottom {b}"); + let (b, n) = + bottom("<< /Type /Annot /Subtype /FreeText /Rect [45 630 165 700] /DA (0 g /Helv 12 Tf) /DS (font: Helvetica 24pt) /Contents (Hi) >>", "Hi"); + assert!(n == 1 && fits(b, 700.0 - 28.8 - 8.0), "/DS 24 pt: bottom {b}"); +} + +/// A colour or size change keeps the face a text box is drawn in: `/DA` keeps naming its font +/// resource (here Courier, and a `/DR` font that is Times Bold), and `/DS` says the same face +/// (ISO 32000-2 Table 177). +#[test] +fn a_colour_or_size_change_keeps_the_drawn_face() { + use pdfcraft_fonts::Std14; + let doc = |da: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>"); + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]) + }; + let face = |doc: &Document| { + let d = &list(doc, 0)[0]; + text_style::text_style(d, text_style::dr_face(doc, d)).face + }; + for (da, font, want) in [("0 g /Cour 12 Tf", "/Cour", Std14::Courier), ("0 g /F1 12 Tf", "/F1", Std14::TimesBold)] { + let mut d = doc(da); + assert_eq!(face(&d), want, "{da}"); + set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + assert_eq!(face(&d), want, "colour change, {da}"); + assert!(text(&list(&d, 0)[0], b"DA").contains(&format!("{font} 12 Tf")), "{da}"); + set_font_size(&mut d, 0, 0, 18.0, &meta("")).unwrap(); + assert_eq!(face(&d), want, "size change, {da}"); + let p = props(&d, 0, 0).unwrap(); + assert_eq!((p.color, p.font_size), (Some([1.0, 0.0, 0.0]), Some(18.0)), "{da}"); + } + // The Courier box's appearance draws Courier. + let mut d = doc("0 g /Cour 12 Tf"); + set_style(&mut d, 0, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + assert!(ap_content(&d, &list(&d, 0)[0]).contains("/Cour 12 Tf")); +} + +/// A text box without an appearance is drawn from its `/DA` however the file wrote it: a `Tf` +/// without a size, a zero size, a font name the form's `/DR` doesn't list, and hostile operands. +/// The stream names the face the box is drawn in at a usable size, and no number in it is `NaN` +/// or infinite. +#[test] +fn text_boxes_draw_from_any_da_string() { + let doc = |da: &str| { + let annot = format!("<< /Type /Annot /Subtype /FreeText /Rect [45 500 300 700] /Contents (Hi) /DA ({da}) >>"); + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /DR << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >> >> >> >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + annot.as_bytes(), + ]) + }; + for (da, tf, colour) in [ + ("0 g /Cour Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour 0 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("0 g /Cour -5 Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("NaN NaN NaN rg /Cour NaN Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("inf g /Cour inf Tf", "/Cour 12 Tf", "0 0 0 rg"), + ("1 0 0 rg /Cour 1e300 Tf", "/Cour 400 Tf", "1 0 0 rg"), + // In `/DR`: Times Bold. Not in `/DR` and no standard name: Helvetica. + ("0 g /F1 Tf", "/TiBo 12 Tf", "0 0 0 rg"), + ("0 g /F9 11 Tf", "/Helv 11 Tf", "0 0 0 rg"), + // No Tf at all, or garbage. + ("0 0 1 rg", "/Helv 12 Tf", "0 0 1 rg"), + ("] [ << >> Tf", "/Helv 12 Tf", "0 0 0 rg"), + ] { + let mut d = doc(da); + set_appearance(&mut d, ObjRef::new(4, 0)).unwrap(); + let content = ap_content(&d, &list(&d, 0)[0]); + assert!(content.contains(tf), "{da:?}: {content}"); + assert!(content.contains(colour), "{da:?}: {content}"); + let lower = content.to_ascii_lowercase(); + assert!(!lower.contains("nan") && !lower.contains("inf"), "{da:?}: {content}"); + } +} + +/// A callout with its text box at [300 600 500 640], knee [260 620] and arrow point [200 520]: +/// the knee line runs level from the box's left side, as Acrobat draws it. +fn callout_fixture() -> (Document, usize) { + let mut doc = fixture(); + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 640.0], + knee: [260.0, 620.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), + &meta("c"), + ) + .unwrap(); + (doc, c) +} + +fn nums(d: &Dict, k: &[u8]) -> Vec { + d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() +} + +/// The text box a callout's `/Rect` and `/RD` describe. +fn callout_box_of(d: &Dict) -> [f64; 4] { + let (r, rd) = (rect(d), nums(d, b"RD")); + [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]] +} + +/// #1152: a callout's arrow point and knee move on their own, as its handles drag them (Acrobat: +/// "Callout text boxes have three parts: a text box, a knee line, and an end-point line. You can +/// resize each part by dragging a handle", Acrobat DC Help, 11/28/2019, p. 357). The text box +/// stays and the line re-attaches to it. +#[test] +fn a_callouts_arrow_point_and_knee_move_on_their_own() { + let (mut doc, c) = callout_fixture(); + // The arrow point alone: the knee and the box stay. + set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 620.0]], &meta("p")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]); + assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]); + // /Rect holds the line (padded) and the box. + let r = rect(d); + assert!(r[0] < 150.0 && r[1] < 400.0 && r[2] >= 500.0 && r[3] >= 640.0, "{r:?}"); + assert!(ap_content(&doc, d).contains("150 400 m"), "the line starts at the new arrow point"); + // The knee alone, to the right of the box: the line now meets the right side. + let mut doc = doc; + set_points(&mut doc, 0, c, &[[150.0, 400.0], [560.0, 620.0]], &meta("k")).unwrap(); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [150.0, 400.0, 560.0, 620.0, 500.0, 620.0]); + assert_eq!(callout_box_of(d), [300.0, 600.0, 500.0, 640.0]); + assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[150.0, 400.0], [560.0, 620.0]])); +} + +/// A callout's leader line as its box moves or is resized, as Acrobat Pro DC (a 2021 Continuous +/// release) was seen to keep it (#1152): the arrow point never moves ("The text box moves around +/// a stationary anchor point", Acrobat DC Help, 11/28/2019, p. 357); moved, the knee and the end +/// come along; +/// resized with the side the line meets left in place, nothing changes; that side moved, the line +/// is laid out again from the side facing the arrow point with a 12 pt knee line, and the box stays +/// where it was put (Acrobat moves it next to the arrow point). +#[test] +fn a_callouts_box_moves_around_its_arrow_and_keeps_the_knee_beside_it() { + let (mut doc, c) = callout_fixture(); + let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL"); + // Wider and taller, its left side where it was: the line stays. + set_rect(&mut doc, 0, c, [300.0, 580.0, 540.0, 640.0], &meta("r")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 260.0, 620.0, 300.0, 620.0]); + // Moved 40 right and 10 up, same size: the knee and the end come along. + set_rect(&mut doc, 0, c, [340.0, 590.0, 580.0, 650.0], &meta("m")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 300.0, 630.0, 340.0, 630.0]); + // The left side moved: the line meets its middle, the knee 12 pt out. + set_rect(&mut doc, 0, c, [380.0, 590.0, 580.0, 650.0], &meta("s")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 368.0, 620.0, 380.0, 620.0]); + assert_eq!(callout_box_of(&list(&doc, 0)[c]), [380.0, 590.0, 580.0, 650.0], "the box stays where it was put"); + // Past the arrow point: the line leaves from the side facing it. + set_rect(&mut doc, 0, c, [100.0, 600.0, 180.0, 640.0], &meta("p")).unwrap(); + assert_eq!(cl(&doc), [200.0, 520.0, 192.0, 620.0, 180.0, 620.0]); +} + +/// The line is laid out again when the end no longer lies on the side it met (the box shrank past +/// it), and from the side facing the arrow point, not the old knee, which can end up over the box. +#[test] +fn a_callout_is_laid_out_again_when_its_end_leaves_the_box() { + let (mut doc, c) = callout_fixture(); + // Left side unmoved, but the box now starts above the end (300, 620). + set_rect(&mut doc, 0, c, [300.0, 625.0, 500.0, 640.0], &meta("a")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 288.0, 632.5, 300.0, 632.5]); + let (mut doc, c) = callout_fixture(); + // Over the old knee (260, 620), which would face the bottom; the arrow faces the left side. + set_rect(&mut doc, 0, c, [250.0, 590.0, 450.0, 640.0], &meta("b")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 238.0, 615.0, 250.0, 615.0]); +} + +/// Review on #4094: the page sends a callout's text box back as `f32` values, so a box moved or +/// resized on screen comes back a few hundred-thousandths of a point off. That still counts as a +/// move (the knee comes along) and as the same side (the line stays), not as a new layout. +#[test] +fn a_callout_box_sent_back_as_f32_still_moves_and_keeps_its_line() { + let mut doc = fixture(); + let c = add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [323.05, 292.14, 506.98, 374.91], + knee: [311.05, 333.53], + point: [299.05, 274.83], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), + &meta("c"), + ) + .unwrap(); + let f32s = |r: [f64; 4]| r.map(|v| f64::from(v as f32)); + let cl = |doc: &Document| nums(&list(doc, 0)[c], b"CL"); + let before = cl(&doc); + // Resized from the bottom right, its left side where it was. + set_rect(&mut doc, 0, c, f32s([323.05, 250.14, 540.98, 374.91]), &meta("r")).unwrap(); + assert_eq!(cl(&doc), before, "the line stays"); + // Moved by (47.62, 26.13), same size. + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, f32s([tb[0] + 47.62, tb[1] + 26.13, tb[2] + 47.62, tb[3] + 26.13]), &meta("m")).unwrap(); + let after = cl(&doc); + assert!((after[2] - (before[2] + 47.62)).abs() < 0.01 && (after[3] - (before[3] + 26.13)).abs() < 0.01, "the knee came along: {after:?}"); + assert_eq!((after[0], after[1]), (before[0], before[1]), "the arrow point stayed"); +} + +/// Review on #4094: a `/CL` with something other than a number in it is not read at all, rather +/// than read with its numbers shifted into the wrong points. +#[test] +fn a_callout_line_with_a_non_number_is_not_read() { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| { + d.set( + b"CL".to_vec(), + Object::Array(vec![Object::name("x"), Object::Real(200.0), Object::Real(520.0), Object::Real(260.0), Object::Real(620.0)]), + ) + }) + .unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "not a callout line it can reshape"); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).map(|s| (s.points.len(), s.text_box.is_some())), Some((0, false))); +} + +/// A new callout's knee is Acrobat's: 12 pt out from the middle of the side facing the arrow. +#[test] +fn a_new_callouts_knee_line_is_twelve_points() { + let tb = [300.0, 600.0, 500.0, 640.0]; + assert_eq!(callout_knee(tb, [200.0, 520.0]), [288.0, 620.0]); + assert_eq!(callout_knee(tb, [600.0, 700.0]), [512.0, 620.0]); + assert_eq!(callout_knee(tb, [400.0, 700.0]), [400.0, 652.0]); + assert_eq!(callout_knee(tb, [400.0, 500.0]), [400.0, 588.0]); +} + +/// ISO 32000-2 Table 177: `/CL` may have four numbers (no knee). Resizing such a callout kept its +/// line only with six, so the box lost `/RD` and the line was left outside `/Rect`. +#[test] +fn a_callout_without_a_knee_keeps_its_line() { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap(); + set_rect(&mut doc, 0, c, [300.0, 580.0, 500.0, 640.0], &meta("r")).unwrap(); + let d = &list(&doc, 0)[c]; + assert_eq!(nums(d, b"CL"), [200.0, 520.0, 300.0, 620.0], "still four numbers, the left side unmoved"); + assert_eq!(callout_box_of(d), [300.0, 580.0, 500.0, 640.0]); + assert!(rect(d)[1] < 520.0, "/Rect holds the line"); + // Its one point is the arrow; a second adds a knee. + assert_eq!(summaries(&doc).iter().find(|s| s.index == c).map(|s| s.points.clone()), Some(vec![[200.0, 520.0]])); + set_points(&mut doc, 0, c, &[[210.0, 500.0]], &meta("p")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 300.0, 620.0]); + set_points(&mut doc, 0, c, &[[210.0, 500.0], [250.0, 610.0]], &meta("k")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 250.0, 610.0, 300.0, 620.0]); + // Back to no knee, then the left side moved: the end meets its middle. + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[210.0, 500.0, 300.0, 620.0]))).unwrap(); + set_rect(&mut doc, 0, c, [280.0, 590.0, 500.0, 640.0], &meta("s")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [210.0, 500.0, 280.0, 615.0]); +} + +#[test] +fn a_callout_refuses_points_it_cant_have() { + let (mut doc, c) = callout_fixture(); + let before = list(&doc, 0)[c].clone(); + for bad in [&[][..], &[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]][..], &[[f64::NAN, 1.0]][..], &[[1e9, 1.0]][..]] { + assert!(matches!(set_points(&mut doc, 0, c, bad, &meta("x")), Err(AnnotError::Invalid(_))), "{bad:?}"); + } + assert_eq!(list(&doc, 0)[c], before, "left as it was"); + // Without a readable /RD there is no box to keep, so nothing moves. + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"RD"); + }) + .unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_)))); + // Nor with an /RD that puts the box outside /Rect (negative) or leaves no box (under 1 pt): + // set_points refuses, and summaries offer no text box to drag. + let outer = rect(&list(&doc, 0)[c]); + for rd in [[-5.0, 50.0, 0.0, 0.0], [0.0, 0.0, outer[2] - outer[0] - 0.5, 0.0]] { + doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&rd))).unwrap(); + assert!(matches!(set_points(&mut doc, 0, c, &[[1.0, 1.0]], &meta("x")), Err(AnnotError::Invalid(_))), "{rd:?}"); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == c).and_then(|s| s.text_box), None, "{rd:?}"); + } +} + +/// The side a knee faces: left or right of the box first, then above or below. A knee on the line +/// of the left or right edge past the box is above or below it; the ties are in +/// `a_callouts_knee_has_one_side_for_every_tie`. +#[test] +fn a_callouts_knee_faces_the_side_it_is_beyond() { + let tb = [100.0, 100.0, 200.0, 150.0]; + assert_eq!(callout_side(tb, [99.0, 125.0]), Side::Left); + assert_eq!(callout_side(tb, [201.0, 125.0]), Side::Right); + assert_eq!(callout_side(tb, [100.0, 160.0]), Side::Top, "on the left edge's line"); + assert_eq!(callout_side(tb, [200.0, 90.0]), Side::Bottom, "on the right edge's line"); + assert_eq!(callout_side(tb, [150.0, 125.0]), Side::Bottom, "inside"); + assert!(Side::Left.horizontal() && Side::Right.horizontal() && !Side::Top.horizontal() && !Side::Bottom.horizontal()); +} + +#[test] +fn summaries_give_a_callouts_points_and_text_box() { + let (doc, c) = callout_fixture(); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!(s.points, vec![[200.0, 520.0], [260.0, 620.0]]); + assert_eq!(s.text_box, Some([300.0, 600.0, 500.0, 640.0])); + let square = summaries(&doc).into_iter().find(|s| s.subtype == "Square").unwrap(); + assert_eq!((square.points.len(), square.text_box), (0, None)); +} + +/// A callout whose text box can't be read (no `/RD`, a negative inset, or no box left inside +/// `/Rect`) lists no points either, as `pdfcraft_render::inspect` does: `set_points` refuses it. +#[test] +fn summaries_give_no_callout_points_without_a_text_box() { + for rd in [None, Some([-1.0, 0.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0, 1000.0])] { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| match rd { + Some(rd) => d.set(b"RD".to_vec(), num_array(&rd)), + None => { + d.remove(b"RD"); + } + }) + .unwrap(); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!((s.points.len(), s.text_box), (0, None), "/RD {rd:?}"); + } +} + +/// Review on #4094: a callout given its arrow point alone keeps its knee. The new line was built +/// from the one point and the end, so a six-number `/CL` silently became four. +#[test] +fn a_callouts_arrow_point_alone_keeps_its_knee() { + let (mut doc, c) = callout_fixture(); + let before = nums(&list(&doc, 0)[c], b"CL"); + assert_eq!(before.len(), 6, "{before:?}"); + set_points(&mut doc, 0, c, &[[190.0, 500.0]], &meta("p")).unwrap(); + let after = nums(&list(&doc, 0)[c], b"CL"); + assert_eq!(after.len(), 6, "the knee stays: {after:?}"); + assert_eq!(after[..2], [190.0, 500.0], "the arrow point moved"); + assert_eq!(after[2..], before[2..], "the knee and the end are where they were"); +} + +/// Review on #4094: every tie has one defined side. Beside the box (left or right of it) wins over +/// above or below it, so a knee off a corner diagonally, or on the line of the top or bottom edge +/// past the box, takes the left or right side. On the box's outline a knee takes the side whose +/// edge it lies on (within 0.01 pt), and a corner the left or right side, as beside the box. +/// Inside the box, or with a coordinate that isn't a number, it takes the bottom side. +#[test] +fn a_callouts_knee_has_one_side_for_every_tie() { + let tb = [100.0, 100.0, 200.0, 150.0]; + let cases: [([f64; 2], Side, &str); 30] = [ + ([100.004, 125.0], Side::Left, "on the left edge, give or take an f32"), + ([199.996, 125.0], Side::Right, "on the right edge, give or take an f32"), + ([150.0, 149.996], Side::Top, "on the top edge, give or take an f32"), + ([100.02, 125.0], Side::Bottom, "inside, past the slack"), + ([150.0, 125.0], Side::Bottom, "inside, at the middle"), + ([110.0, 145.0], Side::Bottom, "inside, near the top-left corner"), + ([100.0, 125.0], Side::Left, "on the left edge"), + ([200.0, 125.0], Side::Right, "on the right edge"), + ([150.0, 150.0], Side::Top, "on the top edge"), + ([150.0, 100.0], Side::Bottom, "on the bottom edge"), + ([100.0, 150.0], Side::Left, "the top-left corner"), + ([200.0, 150.0], Side::Right, "the top-right corner"), + ([100.0, 100.0], Side::Left, "the bottom-left corner"), + ([200.0, 100.0], Side::Right, "the bottom-right corner"), + ([90.0, 150.0], Side::Left, "the top edge's line, left of the box"), + ([210.0, 150.0], Side::Right, "the top edge's line, right of the box"), + ([90.0, 100.0], Side::Left, "the bottom edge's line, left of the box"), + ([100.0, 160.0], Side::Top, "the left edge's line, above the box"), + ([100.0, 90.0], Side::Bottom, "the left edge's line, below the box"), + ([200.0, 160.0], Side::Top, "the right edge's line, above the box"), + ([90.0, 160.0], Side::Left, "diagonally off the top-left corner"), + ([210.0, 90.0], Side::Right, "diagonally off the bottom-right corner"), + ([f64::NAN, 125.0], Side::Bottom, "x not a number"), + ([150.0, f64::NAN], Side::Bottom, "y not a number"), + ([f64::NAN, 160.0], Side::Bottom, "x not a number, y above the box"), + ([f64::NAN, 90.0], Side::Bottom, "x not a number, y below the box"), + ([90.0, f64::NAN], Side::Bottom, "y not a number, x left of the box"), + ([210.0, f64::NAN], Side::Bottom, "y not a number, x right of the box"), + ([f64::NEG_INFINITY, 125.0], Side::Left, "infinitely far left"), + ([150.0, f64::INFINITY], Side::Top, "infinitely far up"), + ]; + for (knee, side, what) in cases { + assert_eq!(callout_side(tb, knee), side, "{what}: {knee:?}"); + // Written corners first, the box is the same box. + assert_eq!(callout_side([200.0, 150.0, 100.0, 100.0], knee), side, "{what}, corners first"); + // The line meets the middle of that side. + let want = match side { + Side::Left => [100.0, 125.0], + Side::Right => [200.0, 125.0], + Side::Top => [150.0, 150.0], + Side::Bottom => [150.0, 100.0], + }; + assert_eq!(callout_attach(tb, knee), want, "{what}"); + } +} + +/// A knee dragged exactly onto the box's left edge keeps the line on the left side, where its end +/// already is, instead of sending it across the box to the bottom; a knee dragged inside the box +/// meets the bottom side. +#[test] +fn a_callouts_knee_on_or_inside_the_box_meets_a_defined_side() { + let (mut doc, c) = callout_fixture(); + set_points(&mut doc, 0, c, &[[200.0, 520.0], [300.0, 630.0]], &meta("edge")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 300.0, 630.0, 300.0, 620.0]); + set_points(&mut doc, 0, c, &[[200.0, 520.0], [350.0, 620.0]], &meta("inside")).unwrap(); + assert_eq!(nums(&list(&doc, 0)[c], b"CL"), [200.0, 520.0, 350.0, 620.0, 400.0, 600.0]); + assert_eq!(callout_box_of(&list(&doc, 0)[c]), [300.0, 600.0, 500.0, 640.0], "the box stays"); +} + +/// A callout's `/CL` read as numbers, and the check every edit must pass: four numbers (arrow +/// point, end) or six (arrow point, knee, end), all finite (ISO 32000-2 Table 177), every point +/// inside `/Rect`, and a text box (`/Rect` inset by `/RD`) at least 1 pt a side. +fn assert_callout_line(d: &Dict, len: usize, what: &str) -> Vec { + let cl = nums(d, b"CL"); + assert_eq!(cl.len(), len, "{what}: {cl:?}"); + assert!(cl.iter().all(|v| v.is_finite()), "{what}: {cl:?}"); + let r = rect(d); + for p in cl.chunks(2) { + assert!(p[0] >= r[0] - 1e-6 && p[0] <= r[2] + 1e-6 && p[1] >= r[1] - 1e-6 && p[1] <= r[3] + 1e-6, "{what}: {p:?} outside {r:?}"); + } + let tb = callout_box_of(d); + assert!(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0, "{what}: text box {tb:?}"); + cl +} + +/// Review on #4094: every edit of a callout keeps `/CL` four numbers when it had four and six when +/// it had six: the arrow point dragged, the knee dragged, the box moved, the box resized, the whole +/// callout moved, and the file saved and opened again. +#[test] +fn every_callout_edit_keeps_four_or_six_numbers() { + for len in [4usize, 6] { + let (mut doc, c) = callout_fixture(); + if len == 4 { + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), num_array(&[200.0, 520.0, 300.0, 620.0]))).unwrap(); + } + let cl = |doc: &Document, what: &str| assert_callout_line(&list(doc, 0)[c], len, &format!("{len}: {what}")); + set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("tip")).unwrap(); + assert_eq!(cl(&doc, "the arrow point")[..2], [150.0, 400.0]); + if len == 6 { + set_points(&mut doc, 0, c, &[[150.0, 400.0], [260.0, 630.0]], &meta("knee")).unwrap(); + assert_eq!(cl(&doc, "the knee")[2..4], [260.0, 630.0]); + } + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, [tb[0] + 40.0, tb[1] + 10.0, tb[2] + 40.0, tb[3] + 10.0], &meta("box")).unwrap(); + assert_eq!(cl(&doc, "the box moved")[..2], [150.0, 400.0], "the arrow point stays"); + let tb = callout_box_of(&list(&doc, 0)[c]); + set_rect(&mut doc, 0, c, [tb[0] + 30.0, tb[1] - 20.0, tb[2] + 10.0, tb[3]], &meta("size")).unwrap(); + let before = cl(&doc, "the box resized"); + move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("move")).unwrap(); + let after = cl(&doc, "the callout moved"); + let shifted: Vec = before.iter().enumerate().map(|(i, v)| v + if i % 2 == 0 { 20.0 } else { -15.0 }).collect(); + assert_eq!(after, shifted, "{len}: every point moves with it"); + let doc = reopen(&doc); + cl(&doc, "saved and opened again"); + } +} + +/// Review on #4094: a `/CL` that is neither four nor six finite numbers (two, three, five or eight +/// numbers, an infinite or not-a-number value, an empty array, a nested array, a name or a string) +/// is not a callout line PdfCraft can reshape. No edit panics, the arrow point and knee are +/// refused, and none writes a `/CL` of another length: resizing leaves it as it was and moving +/// shifts its numbers in place. A `/CL` held as an indirect object is read as the line it is. +#[test] +fn a_hostile_callout_line_is_never_rewritten_into_another_shape() { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let hostile: Vec<(&str, Object)> = vec![ + ("two numbers", reals(&[200.0, 520.0])), + ("three numbers", reals(&[200.0, 520.0, 260.0])), + ("five numbers", reals(&[200.0, 520.0, 260.0, 620.0, 300.0])), + ("eight numbers", reals(&[200.0, 520.0, 260.0, 620.0, 280.0, 620.0, 300.0, 620.0])), + ("not a number", reals(&[200.0, f64::NAN, 260.0, 620.0, 300.0, 620.0])), + ("infinite", reals(&[f64::INFINITY, 520.0, 260.0, 620.0])), + ("empty", Object::Array(Vec::new())), + ("nested", Object::Array(vec![reals(&[200.0, 520.0]), reals(&[300.0, 620.0])])), + ("a name", Object::name("CL")), + ("a string", PdfString::text("200 520 300 620").into()), + ]; + for (what, cl) in hostile { + let (mut doc, c) = callout_fixture(); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), cl.clone())).unwrap(); + let len = cl.as_array().map(Vec::len); + for points in [&[[150.0, 400.0]][..], &[[150.0, 400.0], [260.0, 630.0]][..]] { + assert!(set_points(&mut doc, 0, c, points, &meta("p")).is_err(), "{what}: {points:?}"); + } + // Compared as written: a not-a-number value is never equal to itself. + let same = |doc: &Document| format!("{:?}", list(doc, 0)[c].get(b"CL")) == format!("{:?}", Some(&cl)); + assert!(same(&doc), "{what}: refused, so unchanged"); + let s = summaries(&doc).into_iter().find(|s| s.index == c).unwrap(); + assert_eq!((s.points.len(), s.text_box), (0, None), "{what}: nothing to drag"); + let _ = set_rect(&mut doc, 0, c, [300.0, 580.0, 520.0, 640.0], &meta("r")); + assert!(same(&doc), "{what}: resizing leaves it as it was"); + let _ = move_annotation(&mut doc, 0, c, 20.0, -15.0, &meta("m")); + assert_eq!(list(&doc, 0)[c].get(b"CL").and_then(Object::as_array).map(Vec::len), len, "{what}: moved in place"); + reopen(&doc); + } + // Held as an indirect object, a six-number line is a callout line like any other. + let (mut doc, c) = callout_fixture(); + let line = doc.add(reals(&[200.0, 520.0, 260.0, 620.0, 300.0, 620.0])); + let (_, r) = annot_ref(&mut doc, 0, c).unwrap(); + doc.update_dict(r, |d| d.set(b"CL".to_vec(), Object::Ref(line))).unwrap(); + set_points(&mut doc, 0, c, &[[150.0, 400.0]], &meta("p")).unwrap(); + assert_eq!(assert_callout_line(&list(&doc, 0)[c], 6, "indirect"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]); +} + +/// A pop-up is opened, closed and moved through `set_popup` (#comment.popups): a note's own +/// pop-up follows, a comment without one gets one beside it, and the comment itself (its text, +/// its date) is left alone. Summaries report the pop-up. Text boxes, Fill & Sign marks and +/// replies have none of their own; a bad rectangle is refused. +#[test] +fn pop_ups_open_close_and_move_and_are_reported() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let before = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(before.popup.map(|p| p.open), Some(false), "new notes have a closed pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let after = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(after.popup.map(|p| p.open), Some(true)); + assert_eq!(after.popup.map(|p| p.rect), before.popup.map(|p| p.rect), "opening does not move it"); + assert_eq!((after.modified, after.contents.as_deref()), (before.modified, Some("hello")), "the comment is untouched"); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(true)), "the note's own /Open follows"); + set_popup(&mut doc, 0, note, Some([150.0, 100.0, 290.0, 200.0]), Some(false), &meta("n1")).unwrap(); + let moved = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(moved.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(false))); + + // A highlight is created without a pop-up: the first open gives it one, beside it. + let mark = add_annotation( + &mut doc, + &new(0, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 120.0, 200.0, 20.0, 190.0, 120.0, 190.0]] }), + &meta("h1"), + ) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup, None); + set_popup(&mut doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + let given = summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup.expect("a pop-up now"); + assert!(given.open); + assert!((given.rect[0] - 130.0).abs() < 0.01 && (given.rect[2] - 330.0).abs() < 0.01, "beside the mark: {:?}", given.rect); + + // #2137 review: a /Popup naming another annotation (here the note) isn't this comment's: the + // note is left alone and the mark gets a pop-up of its own. + let (_, mr) = annot_ref(&mut doc, 0, mark).unwrap(); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(r))).unwrap(); + let note_before = annot_dict(&doc, r); + set_popup(&mut doc, 0, mark, Some([10.0, 10.0, 110.0, 60.0]), Some(true), &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, r), note_before, "the note isn't moved or opened"); + let own = annot_dict(&doc, mr).reference(b"Popup").expect("a pop-up of its own"); + assert_ne!(own, r); + assert_eq!(doc.get(own).as_dict().and_then(|p| p.reference(b"Parent")), Some(mr)); + // Its own pop-up, with /Parent naming it, is the one moved next time. + set_popup(&mut doc, 0, mark, Some([12.0, 10.0, 112.0, 60.0]), None, &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, mr).reference(b"Popup"), Some(own), "the same pop-up"); + + // #2137 review: nor is it reported as the comment's (see the helper). + foreign_pop_ups_are_not_the_marks(&mut doc, r, mark, mr); + + // None of their own. + let text_box = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [10.0, 10.0, 110.0, 40.0], font_size: 12.0 }), &meta("t1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, text_box, None, Some(true), &meta("t1")), Err(AnnotError::Invalid(_)))); + let reply = add_reply(&mut doc, 0, note, "Agreed", "Ada", &meta("r1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, reply, None, Some(true), &meta("r1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([0.0, 0.0, 0.5, 0.5]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([f64::NAN, 0.0, 10.0, 10.0]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, 99, None, Some(true), &meta("n1")), Err(AnnotError::NoSuchAnnotation { .. }))); + + // #2137 review: /Annots may list the note through an object that is only a reference to it. + // Its pop-up's /Parent names the note itself, so the pop-up is still the note's. + let p = page_ref(&doc, 0).unwrap(); + let via = doc.add(Object::Ref(r)); + let list: Vec = annots(&doc, p).into_iter().map(|o| if o.as_ref() == Some(r) { Object::Ref(via) } else { o }).collect(); + set_annots(&mut doc, p, list).unwrap(); + let listed = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(listed.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }), "listed through {via:?}"); +} + +/// #2137 review: a pop-up that is not the mark's own is neither reported as its pop-up nor +/// changed through it (part of `pop_ups_open_close_and_move_and_are_reported`). +fn foreign_pop_ups_are_not_the_marks(doc: &mut Document, note: ObjRef, mark: usize, mr: ObjRef) { + // #2137 review: nor is it reported as the comment's. A /Popup naming the note, or the note's + // own pop-up (its /Parent is the note), gives the mark no pop-up in the summaries. + let popup_of_mark = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == mark).unwrap().popup; + let notes_popup = annot_dict(doc, note).reference(b"Popup").expect("the note's pop-up"); + for foreign in [note, notes_popup] { + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(foreign))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "{foreign:?} isn't the mark's"); + } + // A pop-up whose /Parent isn't a reference (it must be one) isn't the mark's either: it is + // neither reported nor changed, and the mark gets one of its own. + let mut bad = Dict::new(); + bad.set(b"Type".to_vec(), Object::name("Annot")); + bad.set(b"Subtype".to_vec(), Object::name("Popup")); + bad.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + bad.set(b"Parent".to_vec(), Object::Dict(Dict::new())); + let bad = doc.add(Object::Dict(bad)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(bad))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "a direct /Parent"); + let bad_before = doc.get(bad).as_dict().cloned(); + set_popup(doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + assert_eq!(doc.get(bad).as_dict().cloned(), bad_before, "left alone"); + assert_ne!(annot_dict(doc, mr).reference(b"Popup"), Some(bad)); + // A pop-up with no /Parent at all is taken as the comment's. + let mut orphan = Dict::new(); + orphan.set(b"Type".to_vec(), Object::name("Annot")); + orphan.set(b"Subtype".to_vec(), Object::name("Popup")); + orphan.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + let orphan = doc.add(Object::Dict(orphan)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(orphan))).unwrap(); + assert_eq!(popup_of_mark(doc), Some(PopupInfo { rect: [0.0, 0.0, 50.0, 50.0], open: false }), "no /Parent"); +} + +/// #2137 review: a comment's own pop-up without a `/Rect` of four numbers reads as none; opening +/// it gives it the place a new pop-up gets, so the window shows again. +#[test] +fn opening_a_pop_up_without_a_rect_gives_it_one() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + doc.update_dict(pr, |p| { + p.remove(b"Rect"); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup, None, "no /Rect reads as no pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let popup = summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup.expect("the pop-up reads again"); + assert!(popup.open); + assert!(popup.rect[2] - popup.rect[0] >= 100.0 && popup.rect[3] - popup.rect[1] >= 100.0, "a window's size: {:?}", popup.rect); + assert_eq!(annot_dict(&doc, r).reference(b"Popup"), Some(pr), "the same pop-up, not a new one"); +} + +/// #2137 review: a pop-up rectangle with its corners reversed is read, and saved, with them in +/// order (ISO 32000-2 §7.9.5: a reader normalises a rectangle). One whose numbers aren't finite +/// as `f32` (a file's huge number) reads as no pop-up, and opening it gives it a new place. +#[test] +fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + let popup = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == note).unwrap().popup; + let set_rect = |doc: &mut Document, rect: [f64; 4]| doc.update_dict(pr, |p| p.set(b"Rect".to_vec(), num_array(&rect))).unwrap(); + + // Reversed in the file: read in order. + set_rect(&mut doc, [290.0, 200.0, 150.0, 100.0]); + assert_eq!(popup(&doc), Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + // Reversed through `set_popup`: saved in order. + set_popup(&mut doc, 0, note, Some([300.0, 220.0, 160.0, 120.0]), None, &meta("n1")).unwrap(); + let saved: Vec = + doc.get(pr).as_dict().and_then(|p| p.get(b"Rect")).and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(saved, [160.0, 120.0, 300.0, 220.0]); + + // A number too large for `f32` (or infinite): no pop-up, and opening it gives it a place. + for huge in [1e300, f64::INFINITY] { + set_rect(&mut doc, [0.0, 0.0, huge, 100.0]); + assert_eq!(popup(&doc), None, "{huge}"); + } + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let given = popup(&doc).expect("the pop-up reads again"); + assert!(given.open && given.rect.iter().all(|v| v.is_finite()), "{given:?}"); +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; diff --git a/crates/annot/src/text_style.rs b/crates/annot/src/text_style.rs new file mode 100644 index 0000000000..dfa4c8c1fa --- /dev/null +++ b/crates/annot/src/text_style.rs @@ -0,0 +1,480 @@ +//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance: +//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's +//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is +//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`. +//! +//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular, +//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in +//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{Std14, Std14Match as Match}; + +use crate::Rgb; +use crate::appearance::parse_da; + +/// `/DS` past this many bytes is read only this far (a style string is a line or two). +const MAX_DS: usize = 4096; + +/// Font sizes a text box is drawn at, as for `/DA`. +const MAX_SIZE: f64 = 400.0; + +/// How a text box's text is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextStyle { + pub face: Std14, + pub size: f64, + pub color: Rgb, +} + +/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face +/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there, +/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead. +pub fn text_style(d: &Dict, dr_face: Option) -> TextStyle { + let (da_color, da_size) = parse_da(d); + let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica); + let mut style = TextStyle { face: da_face, size: da_size, color: da_color }; + let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style }; + let ds = parse_ds(&ds); + if let Some(size) = ds.size { + style.size = size; + } + // `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s. + if let Some(color) = ds.color + && bytes(color) != bytes(da_color) + { + style.color = color; + } + let base = ds.face.unwrap_or(style.face); + style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base))); + style +} + +/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3): +/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or +/// Helvetica for any other font — and `None` when it isn't. +pub fn dr_face(doc: &Document, d: &Dict) -> Option { + let name = da_font_name(d)?; + let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?; + let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?; + let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?; + let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?; + let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?; + let font = font.as_dict()?; + let base = font.name(b"BaseFont").map(String::from_utf8_lossy); + Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica)) +} + +/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and +/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back +/// as [`parse_da`] says. +pub fn da_font_name(d: &Dict) -> Option { + let da = d.get(b"DA").and_then(Object::as_string)?; + let da = String::from_utf8_lossy(&da.bytes); + let toks: Vec<&str> = da.split_whitespace().collect(); + let tf = toks.iter().rposition(|t| *t == "Tf")?; + let name = match toks.get(..tf)? { + [.., name, size] if !size.starts_with('/') => name, + [.., name] => name, + [] => return None, + }; + let name = name.strip_prefix('/')?; + (!name.is_empty()).then(|| name.to_string()) +} + +/// The face a font resource name stands for: the names PDF writers commonly give the standard +/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name. +pub fn resource_face(name: &str) -> Option { + Some(match name { + "Helv" => Std14::Helvetica, + "HeBo" => Std14::HelveticaBold, + "HeOb" | "HeIt" => Std14::HelveticaOblique, + "HeBO" | "HeBI" => Std14::HelveticaBoldOblique, + "Cour" => Std14::Courier, + "CoBo" => Std14::CourierBold, + "CoOb" | "CoIt" => Std14::CourierOblique, + "CoBO" | "CoBI" => Std14::CourierBoldOblique, + "TiRo" => Std14::TimesRoman, + "TiBo" => Std14::TimesBold, + "TiIt" => Std14::TimesItalic, + "TiBI" => Std14::TimesBoldItalic, + _ => return base_font_face(name), + }) +} + +/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their +/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint. +fn base_font_face(name: &str) -> Option { + match Std14::recognise(name)? { + (Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None, + (face, Match::Alias) => Some(face), + } +} + +/// `CSS` family names: the generic ones, else a standard face's name or alias. +fn family_face(name: &str) -> Option { + let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim(); + match name.to_ascii_lowercase().as_str() { + "serif" => Some(Std14::TimesRoman), + "monospace" => Some(Std14::Courier), + "sans-serif" => Some(Std14::Helvetica), + _ => base_font_face(name), + } +} + +fn is_bold(f: Std14) -> bool { + matches!( + f, + Std14::HelveticaBold + | Std14::HelveticaBoldOblique + | Std14::TimesBold + | Std14::TimesBoldItalic + | Std14::CourierBold + | Std14::CourierBoldOblique + ) +} + +fn is_italic(f: Std14) -> bool { + matches!( + f, + Std14::HelveticaOblique + | Std14::HelveticaBoldOblique + | Std14::TimesItalic + | Std14::TimesBoldItalic + | Std14::CourierOblique + | Std14::CourierBoldOblique + ) +} + +/// `base`'s family in the given weight and slant. +fn styled(base: Std14, bold: bool, italic: bool) -> Std14 { + use Std14::*; + match (base, bold, italic) { + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic, + (TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique, + (Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique, + (_, false, false) => Helvetica, + (_, true, false) => HelveticaBold, + (_, false, true) => HelveticaOblique, + (_, true, true) => HelveticaBoldOblique, + } +} + +/// The resource name an appearance stream gives `face` (the usual `/DR` names). +pub fn resource_name(face: Std14) -> &'static str { + match face { + Std14::HelveticaBold => "HeBo", + Std14::HelveticaOblique => "HeOb", + Std14::HelveticaBoldOblique => "HeBO", + Std14::TimesRoman => "TiRo", + Std14::TimesBold => "TiBo", + Std14::TimesItalic => "TiIt", + Std14::TimesBoldItalic => "TiBI", + Std14::Courier => "Cour", + Std14::CourierBold => "CoBo", + Std14::CourierOblique => "CoOb", + Std14::CourierBoldOblique => "CoBO", + _ => "Helv", + } +} + +/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`. +pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) { + let family = match styled(face, false, false) { + Std14::TimesRoman => "Times", + Std14::Courier => "Courier", + _ => "Helvetica", + }; + (family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" }) +} + +fn bytes(c: Rgb) -> [u8; 3] { + // Clamped to 0..=1 first, so the product is 0..=255 and the cast exact. + c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// What a `/DS` string sets. +#[derive(Debug, Default, PartialEq)] +struct Ds { + face: Option, + bold: Option, + italic: Option, + size: Option, + color: Option, +} + +/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are +/// skipped; later ones win. +fn parse_ds(ds: &str) -> Ds { + let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len()); + let ds = ds.get(..end).unwrap_or_default(); + let mut out = Ds::default(); + for decl in ds.split(';') { + let Some((prop, value)) = decl.split_once(':') else { continue }; + let value = value.trim(); + match prop.trim().to_ascii_lowercase().as_str() { + "font" => font_shorthand(value, &mut out), + "font-family" => { + if let Some(face) = value.split(',').find_map(family_face) { + out.face = Some(face); + } + } + "font-size" => { + if let Some(size) = font_size(value, true) { + out.size = Some(size); + } + } + "font-weight" => { + if let Some(bold) = weight(value) { + out.bold = Some(bold); + } + } + "font-style" => { + if let Some(italic) = slant(value) { + out.italic = Some(italic); + } + } + "color" => { + if let Some(c) = css_color(value) { + out.color = Some(c); + } + } + _ => {} + } + } + out +} + +/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is +/// the token with a unit, keywords set weight and slant, and the rest is the family list (some +/// writers put the family first). +fn font_shorthand(value: &str, out: &mut Ds) { + let mut family = Vec::new(); + for tok in value.split_whitespace() { + if let Some(size) = font_size(tok, false) { + out.size = Some(size); + } else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) { + out.bold = Some(bold); + } else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) { + out.italic = Some(italic); + } else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") { + family.push(tok); + } + } + if let Some(face) = family.join(" ").split(',').find_map(family_face) { + out.face = Some(face); + } +} + +/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where +/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are. +fn font_size(tok: &str, unitless: bool) -> Option { + let tok = tok.split('/').next()?.trim(); + let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) { + Some(n) => n, + None if unitless => tok, + None => return None, + }; + num.parse::().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE)) +} + +fn weight(v: &str) -> Option { + match v.to_ascii_lowercase().as_str() { + "bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true), + "normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false), + _ => None, + } +} + +fn slant(v: &str) -> Option { + match v.to_ascii_lowercase().as_str() { + "italic" | "oblique" => Some(true), + "normal" => Some(false), + _ => None, + } +} + +/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages). +fn css_color(v: &str) -> Option { + let v = v.trim(); + if let Some(hex) = v.strip_prefix('#') { + let digit = |c: char| c.to_digit(16).map(f64::from); + let ds: Vec = hex.chars().map(digit).collect::>()?; + return match ds.as_slice() { + [r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]), + [r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]), + _ => None, + }; + } + let lower = v.to_ascii_lowercase(); + let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?; + let parts: Vec = inner + .split(',') + .map(|p| { + let p = p.trim(); + let x = match p.strip_suffix('%') { + Some(pc) => pc.trim().parse::().ok()? / 100.0, + None => p.parse::().ok()? / 255.0, + }; + x.is_finite().then(|| x.clamp(0.0, 1.0)) + }) + .collect::>()?; + match parts.as_slice() { + [r, g, b] => Some([*r, *g, *b]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::PdfString; + + use super::*; + + fn dict(da: &str, ds: Option<&str>) -> Dict { + let mut d = Dict::new(); + d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec())); + if let Some(ds) = ds { + d.set(b"DS".to_vec(), PdfString::text(ds)); + } + d + } + + #[test] + fn da_resource_names_pick_the_standard_faces() { + assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier); + assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman); + assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold); + assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold); + // Unknown, symbolic and family-hint-only names keep Helvetica. + for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] { + assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}"); + } + // A `/DR` lookup outranks the name. + assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman); + } + + #[test] + fn ds_font_size_and_colour_win_over_da() { + let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None); + assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0)); + assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color); + let s = text_style( + &dict( + "1 0 0 rg /Cour 9 Tf", + Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"), + ), + None, + ); + assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0])); + // CSS order, a line height, a #RGB colour and a generic family. + let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None); + assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0])); + // Bold from the style keeps the DA family. + assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold); + } + + /// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay. + #[test] + fn pdfcraft_written_ds_matches_da() { + let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None); + assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] }); + } + + #[test] + fn malformed_ds_falls_back_to_da() { + let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] }; + let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20)); + let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS)); + for ds in [ + "font-size: NaN", + "font-size: inf", + "font-size: -3pt", + "font-size: 0", + "font: NaNpt Zzz", + "font: 1e999pt", + "color: #12", + "color: #zzzzzz", + "color: rgb(1, 2)", + "color: rgb(NaN, 0, 0)", + "color: #ff0000é", + ":::;;;", + "font-family: Wingdings, 'Comic Sans'", + "é: ü; ;", + past_cap.as_str(), + ] { + assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}"); + } + // Read up to the cap: what comes first still counts, and nothing past it is scanned. + assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]); + // Sizes are capped as /DA's are. + assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE); + } + + /// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form + /// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it + /// can read, and nothing that isn't a finite number reaches the style. + #[test] + fn hostile_da_strings_keep_what_they_can() { + let style = |da: &str| text_style(&dict(da, None), None); + let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] }; + for (da, want) in [ + // A missing size keeps the named face at the default size. + ("/TiRo Tf", black12(Std14::TimesRoman)), + ("0 g /Cour Tf", black12(Std14::Courier)), + // Zero, negative, NaN, infinite or not a number: the default size. + ("/Cour 0 Tf", black12(Std14::Courier)), + ("/Cour -0 Tf", black12(Std14::Courier)), + ("/Cour -12 Tf", black12(Std14::Courier)), + ("/Cour NaN Tf", black12(Std14::Courier)), + ("/Cour inf Tf", black12(Std14::Courier)), + ("/Cour -inf Tf", black12(Std14::Courier)), + ("/Cour 1e999 Tf", black12(Std14::Courier)), + ("/Cour twelve Tf", black12(Std14::Courier)), + // No Tf at all, or one with nothing before it. + ("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }), + ("Tf", black12(Std14::Helvetica)), + ("Tf Tf Tf", black12(Std14::Helvetica)), + ("", black12(Std14::Helvetica)), + // Colour operands that aren't finite numbers leave the colour black. + ("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + ("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }), + // Out-of-range colour components are clamped. + ("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }), + // Huge sizes are capped. + ("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + ("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }), + // Garbage around a valid Tf: the last Tf is read. + ("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }), + ] { + assert_eq!(style(da), want, "{da:?}"); + let s = style(da); + assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}"); + } + // A font name that is no standard face and that no `/DR` lists keeps Helvetica. + assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) }); + // Binary bytes that aren't UTF-8 don't panic. + let mut d = Dict::new(); + d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f'])); + assert_eq!(text_style(&d, None).size, 9.0); + assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo")); + assert_eq!(da_font_name(&dict("12 Tf", None)), None); + assert_eq!(da_font_name(&dict("0 g Tf", None)), None); + } + + #[test] + fn css_round_trips_through_the_parser() { + for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] { + let (family, weight, style) = css(face); + let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}"); + assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face); + } + } +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index f6bcc68a7e..d9dcba7678 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -417,13 +417,17 @@ pub enum Shape { contours: Vec>, }, /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a + /// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in + /// when it was placed; the picture then fills the rectangle above it + /// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)). CustomStamp { rect: [f64; 4], name: String, picture: ObjRef, image: bool, size: (f64, f64), + line: Option, }, /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3174e68dbd..e4da78ab09 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -179,7 +179,21 @@ impl Automation { let doc = self.doc(a)?; let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize); let all = self.comments(a)?; + // A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's + // attributes: the subordinates are neither comments nor replies, only its `group`. + let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) { + if let Some(p) = p.and_then(|p| all.get(p)) { + members.entry((p.page, p.index)).or_default().push(m); + } + } let view = |c: &Annotation| -> Value { + let group: Vec = members + .get(&(c.page, c.index)) + .into_iter() + .flatten() + .map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype })) + .collect(); let mut v = json!({ "id": c.name, "page": c.page + 1, @@ -191,6 +205,9 @@ impl Automation { "modified": c.modified, "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), + "group": group, + // Unchanged on a page the viewer could not read, as `rect` is. + "popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })), }); // A text box's or typed text's oval or circle (a rectangle is not reported). if c.subtype == "FreeText" @@ -211,8 +228,8 @@ impl Automation { v["text_opacity"] = json!(t); } } - // A line's ends, or the corners of connected lines, a polygon or a cloud, as - // comment_edit's `points` takes them. + // A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's + // arrow point and knee, as comment_edit's `points` takes them. // Unchanged on a page the viewer could not read, as `rect` is. if !c.points.is_empty() { let round = |v: f32| (v * 100.0).round() / 100.0; @@ -220,15 +237,20 @@ impl Automation { v["points"] = json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::>()); } + // A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line). + if let Some(tb) = c.text_box { + v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb)); + } v }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would // drop it. Parents are found by reference, as `/NM` need not be unique. Status and - // checkmark replies are read through `review` instead. + // checkmark replies are read through `review` instead. A group subordinate is in no + // thread, and a reply to one is the group's, its primary's. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); - for r in all.iter().filter(|r| r.state.is_none()) { + for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; threads.entry(top).or_default().push(r); } @@ -408,18 +430,8 @@ impl Automation { let point = to_user(&info, t[0], t[1]); let knee = match a.nums::<2>("knee")? { Some(k) => to_user(&info, k[0], k[1]), - // Halfway between the point and the box, level with the box's middle. - None => { - let mid = (rect[1] + rect[3]) / 2.0; - let side = if point[0] < rect[0] { - rect[0] - } else if point[0] > rect[2] { - rect[2] - } else { - (rect[0] + rect[2]) / 2.0 - }; - [(point[0] + side) / 2.0, mid] - } + // Acrobat's: 12 pt out from the middle of the side facing the point. + None => pdfcraft_engine::callout_knee(rect, point), }; Shape::Callout { rect, @@ -700,6 +712,17 @@ impl Automation { self.apply(a, Edit::MarkAnnotation { page, index, marked, author }) } + pub(crate) fn comment_popup(&mut self, a: &Args) -> Result { + let (page, index) = self.comment_target(a)?; + let info = self.doc(a)?.info.pages[page].clone(); + let open = a.opt_bool("open")?; + let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r)); + if open.is_none() && rect.is_none() { + return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into())); + } + self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open }) + } + pub(crate) fn comment_lock(&mut self, a: &Args) -> Result { let (page, index) = self.comment_target(a)?; let locked = a.opt_bool("locked")?.unwrap_or(true); @@ -873,6 +896,7 @@ impl Automation { let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); let edit = Edit::AddCustomStamp { page, rect, @@ -882,7 +906,16 @@ impl Automation { bytes: std::sync::Arc::new(bytes), page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1, }, - author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(), + line: None, + author: author.clone(), + }; + // A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now. + let edit = match (edit, a.opt_str("line")?) { + (Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => { + let line = Some(self.session().stamp_line(t, &author)); + Edit::AddCustomStamp { page, rect, name, file, author, line } + } + (edit, _) => edit, }; self.apply(a, edit) } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index bfd9e98675..2781eb2848 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -916,6 +916,7 @@ impl Automation { "sign_trust" => self.sign_trust(&a)?, "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, + "comment_popup" => self.comment_popup(&a)?, "comments_hide" => self.comments_hide(&a)?, "layer_set" => self.layer_set(&a)?, "view_reading_colors" => self.view_reading_colors(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 303d814d1d..ec8e1be41d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -575,7 +575,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "rect", "rows"], )), - t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.") + t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.") .cmd("comment.stamp") .with(schema( json!({ @@ -587,6 +587,7 @@ pub fn tools() -> Vec { "file_page": { "type": "integer", "minimum": 1 }, "name": { "type": "string" }, "author": { "type": "string" }, + "line": { "type": "string", "maxLength": 200 }, }), &["doc", "page", "path"], )), @@ -892,7 +893,7 @@ pub fn tools() -> Vec { .ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])), t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.") .cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])), - t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).") + t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( @@ -1063,7 +1064,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -1077,13 +1078,18 @@ pub fn tools() -> Vec { "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), - "points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." }, + "points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." }, })), &["doc"], )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.") + .with(schema(comment_ref(json!({ + "open": { "type": "boolean" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + })), &["doc"])), t( "comments_copy", "Copy comments", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 81417f0327..6abfbd5ebf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2844,6 +2844,193 @@ trailer << /Root 1 0 R >> assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}"); } +// Contributor-original PDF: a Square, a Circle grouped with it (`/RT /Group`) that keeps its own +// author, text, colour, dates and subject, an ordinary reply, a reply to the Circle, a review +// status reply and a reply with a `/RT` name that is neither `R` nor `Group`. +const GROUP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-sub) /T (Bob) /Contents (Subordinate) /C [0 0 1] /IRT 5 0 R /RT /Group /M (D:20260101000000) /CreationDate (D:20260101000000) /Subj (Oval) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [120 20 140 40] /NM (reply) /T (Carol) /Contents (Reply) /IRT 5 0 R >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [120 50 140 70] /NM (sub-reply) /T (Dan) /Contents (Reply to the subordinate) /IRT 6 0 R >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [120 80 140 100] /NM (status) /T (Eve) /IRT 5 0 R /StateModel /Review /State /Accepted >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [150 20 170 40] /NM (odd) /T (Fay) /Contents (Odd reply type) /IRT 5 0 R /RT /Bogus >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// ISO 32000-2 §12.5.6.2: an annotation with `/RT /Group` is a subordinate of the group whose +/// primary its `/IRT` names. It lists as one comment, with the primary's attributes; the +/// subordinate's own are ignored, and a reply to it joins the primary's thread. +#[test] +fn a_group_lists_as_its_primary() { + let dir = workdir("rt-group"); + std::fs::write(dir.join("group.pdf"), GROUP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap(); + let check = |list: &Value, when: &str| { + assert_eq!(list["count"], 1, "{when}: one comment, the group's primary: {list}"); + let c = &list["comments"][0]; + assert_eq!((c["id"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("sq-primary"), Some("Alice"), Some("Primary")), "{when}"); + assert_eq!(c["color"], "#FF0000", "{when}: the primary's colour"); + let group: Vec<&str> = c["group"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["circ-sub"], "{when}: the subordinate is a member of the group"); + assert_eq!(c["group"][0]["type"], "Circle", "{when}"); + let mut replies: Vec<&str> = c["replies"].as_array().unwrap().iter().filter_map(|r| r["id"].as_str()).collect(); + replies.sort_unstable(); + assert_eq!(replies, ["odd", "reply", "sub-reply"], "{when}: an unknown /RT reads as a reply, a reply to the subordinate joins the thread"); + assert_eq!(c["status"], "Accepted", "{when}: a status reply is still a reply"); + let text = list.to_string(); + for leak in ["Bob", "Subordinate", "#0000FF"] { + assert!(!text.contains(leak), "{when}: the subordinate's own {leak:?} is ignored: {list}"); + } + }; + // As opened (the inspector), and after an edit (the engine's own list). + check(&ok(&mut a, "comment_list", json!({ "doc": doc })), "opened"); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let after = ok(&mut a, "comment_list", json!({ "doc": doc })); + let primary = after["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + check(&json!({ "count": 1, "comments": [primary] }), "edited"); + assert_eq!(after["count"], 2, "{after}"); + + // Deleting a member deletes just it, as Acrobat does: the subordinate, or the primary, which + // leaves the subordinate a comment of its own. One undo step each. + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "circ-sub" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(left["count"], 2, "the primary and the new note are left: {left}"); + assert!(left.to_string().contains("sq-primary") && !left.to_string().contains("circ-sub"), "{left}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "sq-primary" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + let ids: Vec<&str> = left["comments"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"circ-sub") && !ids.contains(&"sq-primary") && ids.len() == 2, "the new note and the Circle, on its own: {left}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + let back = ok(&mut a, "comment_list", json!({ "doc": doc })); + let primary = back["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + check(&json!({ "count": 1, "comments": [primary] }), "undone"); +} + +// Contributor-original PDF: group subordinates whose `/IRT` chains loop (each other, and one +// itself), and a chain of two subordinates that leads to a primary. +const GROUP_LOOP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Circle /Rect [10 10 30 30] /NM (a) /Contents (Loop A) /IRT 6 0 R /RT /Group >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [40 10 60 30] /NM (b) /Contents (Loop B) /IRT 5 0 R /RT /Group >> endobj +7 0 obj << /Type /Annot /Subtype /Circle /Rect [70 10 90 30] /NM (self) /Contents (Itself) /IRT 7 0 R /RT /Group >> endobj +8 0 obj << /Type /Annot /Subtype /Square /Rect [10 100 60 150] /NM (top) /Contents (Top) >> endobj +9 0 obj << /Type /Annot /Subtype /Circle /Rect [70 100 90 120] /NM (mid) /Contents (Middle) /IRT 8 0 R /RT /Group >> endobj +10 0 obj << /Type /Annot /Subtype /Circle /Rect [100 100 120 120] /NM (low) /Contents (Lowest) /IRT 9 0 R /RT /Group >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Looping `/IRT` chains between group subordinates neither hang nor panic, and list as comments +/// of their own, as looping replies do; a subordinate of a subordinate joins the group of the +/// primary the chain leads to. +#[test] +fn looping_groups_end_and_chained_groups_reach_their_primary() { + let dir = workdir("rt-group-loop"); + std::fs::write(dir.join("loop.pdf"), GROUP_LOOP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "loop.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let comments = list["comments"].as_array().unwrap(); + let mut ids: Vec<&str> = comments.iter().filter_map(|c| c["id"].as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["a", "b", "self", "top"], "{list}"); + let top = comments.iter().find(|c| c["id"] == "top").unwrap(); + let group: Vec<&str> = top["group"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["mid", "low"], "{list}"); + assert_eq!(top["replies"], json!([]), "{list}"); + // Deleting a looping member ends too and takes just it, as any group member goes alone; so + // does the lowest subordinate of the chain. + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "a" })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "self" })); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "low" })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let mut ids: Vec<&str> = list["comments"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, ["b", "top"], "{list}"); + let top = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "top").cloned().unwrap_or_default(); + let group: Vec<&str> = top["group"].as_array().into_iter().flatten().filter_map(|m| m["id"].as_str()).collect(); + assert_eq!(group, ["mid"], "{list}"); +} + +// Contributor-original PDF: a Replace Text group as earlier PdfCraft builds wrote it, the +// strikeout (primary) with empty `/Contents` and the replacement only on the caret (subordinate). +const OLD_REPLACE_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [20 100 120 120] /QuadPoints [20 120 120 120 20 100 120 100] /NM (strike) /T (Ada) /Contents () /IT /StrikeOutTextEdit >> endobj +6 0 obj << /Type /Annot /Subtype /Caret /Rect [115 95 125 105] /NM (caret) /T (Ada) /Contents (red) /IRT 5 0 R /RT /Group /Subj (Replace Text) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +// Contributor-original PDF: a Replace Text group with the caret as the primary (holding the +// text, `/IT /Replace`) and the strikeout as its subordinate, each with its own author, colour +// and dates. +const REPLACE_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Caret /Rect [115 95 125 105] /NM (caret) /T (Ada) /C [0 0 1] /Contents (busy) /IT /Replace /Subj (Replace Text) /M (D:20260101000000) >> endobj +6 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [20 100 120 120] /QuadPoints [20 120 120 120 20 100 120 100] /NM (strike) /T (Grace) /C [1 0 0] /IRT 5 0 R /RT /Group /IT /StrikeOutTextEdit /Subj (Cross-Out) /M (D:20260101000000) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A Replace Text comment lists once, with its replacement text: one made now (the caret is the +/// primary and holds it), one with that orientation from another writer, and one saved by an +/// earlier PdfCraft (the strikeout primary with no text, the caret holding it), which is read +/// leniently and not rewritten. +#[test] +fn replace_text_lists_its_replacement() { + let dir = workdir("rt-group-replace"); + std::fs::write(dir.join("old.pdf"), OLD_REPLACE_FIXTURE).unwrap(); + std::fs::write(dir.join("other.pdf"), REPLACE_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "replace", "find": "Page 1", "contents": "red" })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!( + (c["type"].as_str(), c["contents"].as_str(), c["group"][0]["type"].as_str()), + (Some("Caret"), Some("red"), Some("StrikeOut")), + "{list}" + ); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "new.pdf" })); + let mut b = auto(&dir); + for (path, text, primary) in [("new.pdf", "red", "Caret"), ("other.pdf", "busy", "Caret"), ("old.pdf", "red", "StrikeOut")] { + let doc = ok(&mut b, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let list = ok(&mut b, "comment_list", json!({ "doc": doc })); + let c = &list["comments"][0]; + assert_eq!( + (list["count"].as_u64(), c["type"].as_str(), c["contents"].as_str()), + (Some(1), Some(primary), Some(text)), + "{path} as opened: {list}" + ); + assert!(!list.to_string().contains("Grace"), "{path}: the subordinate's author is ignored: {list}"); + // And from the engine's own list, after an edit. + ok(&mut b, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let list = ok(&mut b, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 2, "{path}: {list}"); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == primary).cloned().unwrap(); + assert_eq!(c["contents"], text, "{path} after an edit: {list}"); + // Replace Text is one comment: deleting either half deletes both, as Acrobat does, in + // either orientation. + for id in [c["id"].clone(), c["group"][0]["id"].clone()] { + ok(&mut b, "comment_delete", json!({ "doc": doc, "id": id })); + let left = ok(&mut b, "comment_list", json!({ "doc": doc })); + assert_eq!(left["count"], 1, "{path}: deleting {id} leaves only the note: {left}"); + assert!(!left.to_string().contains("StrikeOut") && !left.to_string().contains("Caret"), "{path}: {left}"); + ok(&mut b, "edit_undo", json!({ "doc": doc })); + } + } +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -7553,17 +7740,26 @@ fn drawing_comments_through_tools() { ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page", "color": "green" })); let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 })); let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); - assert_eq!(caret["contents"], "second page", "the replacement is on the caret"); - assert_eq!(caret["replies"][0]["type"], "StrikeOut", "the strikeout is grouped under the caret"); + // One comment, the caret (the group's primary, ISO 32000-2 §12.5.6.2), holding the new text; + // the strikeout is in its group, not a reply or a comment of its own. + assert_eq!(caret["contents"], "second page", "{list}"); + assert_eq!(caret["replies"], json!([]), "{list}"); + assert_eq!(caret["group"][0]["type"], "StrikeOut", "{list}"); + assert!(!list["comments"].as_array().unwrap().iter().any(|c| c["type"] == "StrikeOut"), "{list}"); // Review on #5689 (CodeRabbit): the requested colour is the group's, so the primary has it. - assert_eq!(caret["replies"][0]["color"], caret["color"], "one colour for the pair: {caret}"); - assert_eq!(caret["color"], json!("#2E9E5C"), "the requested green, not the caret's default blue: {caret}"); - // Restyling the caret restyles the proposal, its strikeout too, as one undo step. + // The list gives a group the primary's colour alone, so the strikeout's own (`/C`, what it is + // drawn in) is read from an XFDF export. let pair = |a: &mut Automation| { let list = ok(a, "comment_list", json!({ "doc": doc, "page": 2 })); let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); - (c["color"].clone(), c["replies"][0]["color"].clone()) + ok(a, "doc_export_data", json!({ "doc": doc, "path": "pair.xfdf", "what": "comments" })); + let xfdf = std::fs::read_to_string(dir.join("pair.xfdf")).unwrap(); + let strike = xfdf.split("').next()).unwrap_or_default().to_owned(); + let color = strike.split("color=\"").nth(1).and_then(|v| v.split('"').next()).map(|v| json!(v)).unwrap_or(Value::Null); + (c["color"].clone(), color) }; + assert_eq!(pair(&mut a), (json!("#2E9E5C"), json!("#2E9E5C")), "the requested green on both, not the caret's default blue: {caret}"); + // Restyling the caret restyles the proposal, its strikeout too, as one undo step. ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 2, "index": caret["index"], "color": "purple" })); assert_eq!(pair(&mut a), (json!("#8A3FD1"), json!("#8A3FD1")), "the strikeout follows its caret"); ok(&mut a, "edit_undo", json!({ "doc": doc })); @@ -11838,6 +12034,49 @@ fn text_box_and_callout_fill_and_border_through_tools() { } } +/// A custom stamp can carry a dynamic line (#1741): `{name}`, `{date}` and `{time}` are filled in +/// when it is placed and drawn under the picture, which keeps its shape; the stamp widens to fit +/// the line; a line too long is cut. +#[test] +fn a_custom_stamp_line_is_filled_in_when_placed() { + let dir = workdir("stamp_line"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 2, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Plain" })); + ok( + &mut a, + "stamp_custom", + json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Logo", "author": "Ada", "line": "Received {date} {time} by {name}" }), + ); + // The clock is 2023-11-14 22:13:20, taken as local time. + let text = page_text(&mut a, doc)[0].clone(); + assert!(text.contains("Received Nov 14, 2023 10:13 pm by Ada"), "{text}"); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = |page: u64| { + let c = list["comments"].as_array().unwrap().iter().find(|c| c["page"] == page).unwrap(); + let r: Vec = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + (r[2] - r[0], r[3] - r[1]) + }; + let ((pw, ph), (lw, lh)) = (rect(2), rect(1)); + // The stamp stays within the natural 200 pt. Its line, at its usual size, is wider than the + // picture, so the stamp is wider than the picture's shape with the band added. + assert!(lw / lh > pw / ph * (1.0 - 0.22) + 0.01, "widened for the line: {pw}x{ph} vs {lw}x{lh}"); + // A template within the schema's 200 characters whose filled-in line runs past the limit + // (33 times of 8 characters) is cut to exactly 200, not refused. + let long = "{time}".repeat(33); + assert!(long.chars().count() <= 200); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 3, "path": "b.pdf", "file_page": 2, "at": [100, 150], "line": long })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "stamped.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("stamped.pdf")).unwrap())).unwrap(); + let lines: Vec = saved + .scan_objects() + .filter_map(|(_, o)| o.as_dict()?.get(b"PCByLine")?.as_string().map(pdfcraft_cos::PdfString::to_text)) + .filter(|l| l.starts_with("10:13 pm")) + .collect(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0].chars().count(), 200, "{:?}", lines[0]); +} + /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading /// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves /// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. @@ -12675,6 +12914,118 @@ fn text_extract_says_when_a_pages_content_was_cut() { let _ = std::fs::remove_dir_all(&dir); } +// Contributor-original PDF: a Square on page 1 and, on page 2, a Circle with `/IRT` to it and +// `/RT /Group`. +const SPREAD_GROUP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-other-page) /T (Bob) /Contents (Member on page 2) /C [0 0 1] /IRT 5 0 R /RT /Group >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A group is one page's, as Acrobat reads it: a subordinate whose primary is on another page +/// lists as a comment of its own, with its own author and text, not as part of the group or a +/// reply, and deleting the primary leaves it. ISO 32000-2 §12.5.6.2 doesn't limit a group to a +/// page; Acrobat (desktop and iPad) and PDF-XChange all delete the primary without such a +/// subordinate, and Acrobat desktop lists it on its own. +#[test] +fn a_group_member_on_another_page_is_a_comment_of_its_own() { + let dir = workdir("rt-group-spread"); + std::fs::write(dir.join("group.pdf"), SPREAD_GROUP_FIXTURE).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "group.pdf" }))["doc"].as_u64().unwrap(); + let check = |list: &Value, when: &str| { + assert_eq!(list["count"], 2, "{when}: the Square and the Circle, each a comment: {list}"); + let circle = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "circ-other-page").cloned().unwrap(); + assert_eq!((circle["author"].as_str(), circle["contents"].as_str()), (Some("Bob"), Some("Member on page 2")), "{when}"); + let square = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == "sq-primary").cloned().unwrap(); + assert!(square["group"].as_array().unwrap().is_empty() && square["replies"].as_array().unwrap().is_empty(), "{when}: {square}"); + }; + check(&ok(&mut a, "comment_list", json!({ "doc": doc })), "opened"); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [280, 20], "contents": "Other" })); + let after = ok(&mut a, "comment_list", json!({ "doc": doc })); + let kept: Vec = after["comments"].as_array().unwrap().iter().filter(|c| c["contents"] != "Other").cloned().collect(); + check(&json!({ "count": kept.len(), "comments": kept }), "edited"); + ok(&mut a, "comment_delete", json!({ "doc": doc, "id": "sq-primary" })); + let left = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert!(left.to_string().contains("circ-other-page"), "deleting the primary leaves the Circle on page 2: {left}"); +} + +/// #1152 headless: comment_list gives a callout's arrow point, knee and text box; comment_edit's +/// `points` moves the arrow point and knee, and `rect` resizes the text box with the arrow left +/// where it is. +#[test] +fn a_callouts_parts_are_listed_and_changed_through_the_tools() { + let dir = workdir("callout-parts"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "knee": [80, 220], "contents": "Look" }), + ); + let first = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc }))["comments"][0].clone(); + let near = |v: &Value, want: &[f64]| v.as_array().unwrap().iter().zip(want).all(|(x, w)| (x.as_f64().unwrap() - w).abs() < 0.6); + let c = first(&mut a); + assert!(near(&c["points"][0], &[40.0, 160.0]) && near(&c["points"][1], &[80.0, 220.0]), "{c}"); + assert!(near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}"); + let (page, index) = (c["page"].clone(), c["index"].clone()); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "points": [[30, 150], [80, 220]] })); + let c = first(&mut a); + assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 190.0, 240.0]), "{c}"); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "rect": [110, 200, 230, 260] })); + let c = first(&mut a); + assert!(near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]) && near(&c["points"][0], &[30.0, 150.0]), "{c}"); + // Three points, or none, aren't a callout's. + assert!(matches!( + a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "points": [[1, 1], [2, 2], [3, 3]] })), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "callout.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "callout.pdf" }))["doc"].as_u64().unwrap(); + let c = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"][0].clone(); + assert!(near(&c["points"][0], &[30.0, 150.0]) && near(&c["text_box"], &[110.0, 200.0, 230.0, 260.0]), "after reopening: {c}"); + // Without a knee, comment_add puts it where Acrobat does: 12 pt out from the middle of the side + // facing the point. + ok( + &mut a, + "comment_add", + json!({ "doc": saved, "page": 1, "type": "callout", "rect": [300, 200, 380, 240], "to": [250, 160], "contents": "Auto" }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": saved }))["comments"].clone(); + let auto = list.as_array().unwrap().iter().find(|c| c["contents"] == "Auto").unwrap(); + assert!(near(&auto["points"][1], &[288.0, 220.0]), "{auto}"); +} + +/// comment_popup opens, closes and moves a comment's pop-up, comment_list reports it, and each +/// change is one undo step with its own label. +#[test] +fn comment_popups_are_opened_moved_and_listed() { + let dir = workdir("popup"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [30.0, 40.0], "contents": "Look here" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let note = &listed["comments"][0]; + assert_eq!(note["popup"]["open"], json!(false), "{listed}"); + let id = note["id"].as_str().unwrap().to_string(); + let opened = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": true })); + assert_eq!(opened["undo"], json!("Open comment pop-up"), "{opened}"); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["open"], json!(true)); + let moved = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "rect": [60.0, 60.0, 180.0, 140.0] })); + assert_eq!(moved["undo"], json!("Move comment pop-up")); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["rect"], json!([60.0, 60.0, 180.0, 140.0]), "{listed}"); + let closed = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": false })); + assert_eq!(closed["undo"], json!("Close comment pop-up")); + assert!(matches!(err(&mut a, "comment_popup", json!({ "doc": doc, "id": id })), ToolError::InvalidArgs(_))); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index c8262a7241..a700e38f17 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -24,9 +24,12 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec &str { } } -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +/// [`comment_type_name`], telling replaced text, callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("StrikeOutTextEdit" | "Replace") => "Replace Text", Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", @@ -106,19 +110,26 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su } // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + // A group subordinate (`/RT /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a + // reply: it is not written, and replies to it are written at its level, as the comment's. + // `depth` bounds the walk; `level` is the indent. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize, level: usize) { use std::fmt::Write; if depth > 64 { return; } for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { - let indent = " ".repeat(depth + 1); + if r.is_group_member() { + write_replies(out, children, r, depth + 1, level); + continue; + } + let indent = " ".repeat(level + 1); let _ = writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); for line in r.contents.as_deref().unwrap_or("").lines() { let _ = writeln!(out, "{indent}{line}"); } - write_replies(out, children, r, depth + 1); + write_replies(out, children, r, depth + 1, level + 1); } } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); @@ -157,7 +168,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { let _ = writeln!(out, "{c}"); } - write_replies(&mut out, &children, a, 0); + write_replies(&mut out, &children, a, 0, 0); out.push('\n'); } out diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 612cd58a4e..1d5181bf65 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -160,12 +160,14 @@ pub enum Edit { /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, /// and centred in it (#4077). + /// `line` is a text line under the picture, already filled in ([`Session::stamp_line`](crate::Session::stamp_line)). AddCustomStamp { page: usize, rect: [f64; 4], name: String, file: MarkFile, author: String, + line: Option, }, /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an @@ -243,6 +245,14 @@ pub enum Edit { index: usize, points: Vec<[f64; 2]>, }, + /// Open, close or move a comment's pop-up window (`rect` in user space; a comment without a + /// pop-up gets one). + SetAnnotationPopup { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + open: Option, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 042a2c1395..be1cb1a0a4 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -88,6 +88,9 @@ pub const EDIT_LABELS: &[&str] = &[ "Unlock comment", "Move comment", "Reshape comment", + "Open comment pop-up", + "Close comment pop-up", + "Move comment pop-up", "Resize comment", "Change comment properties", "Fill in {name}", @@ -233,6 +236,9 @@ impl Edit { Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::SetAnnotationPopup { rect: Some(_), .. } => Label::of("Move comment pop-up"), + Edit::SetAnnotationPopup { open: Some(true), .. } => Label::of("Open comment pop-up"), + Edit::SetAnnotationPopup { .. } => Label::of("Close comment pop-up"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index ea2a824351..0d058f3e5c 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -62,6 +62,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -574,9 +575,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } note_skipped(doc, &report); } - Edit::AddCustomStamp { page, rect, name, file, author } => { + Edit::AddCustomStamp { page, rect, name, file, author, line } => { let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let line = line.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|l| l.chars().take(crate::MAX_STAMP_LINE).collect::()); + let picture = (src.size.0.max(1.0), src.size.1.max(1.0)); + // A text line adds its band under the picture, and widens the stamp to fit (#1741). + let (sw, sh) = pdfcraft_annot::custom_stamp_size(picture, line.as_deref()); let natural = rect[2] == rect[0] && rect[3] == rect[1]; let rotation = pdfcraft_organize::page_rotation(doc, *page)?; // The picture as displayed: a quarter turn swaps its sides in user space. @@ -588,7 +592,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } else { fit_picture(*rect, dw, dh) }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: picture, line }; let style = pdfcraft_annot::Style::default_for(&shape); let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; @@ -643,7 +647,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?; } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by + // One `/NM` each: the strikeout is grouped with the caret, and XFDF names that link by // the caret's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); pdfcraft_annot::add_text_replacement( @@ -666,6 +670,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::SetAnnotationPopup { page, index, rect, open } => pdfcraft_annot::set_popup(doc, *page, *index, *rect, *open, &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( diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 67b4c4549c..4259e6c721 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -67,6 +67,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64e63930c8..17f196cf6a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -120,8 +120,9 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom}; pub use pdfcraft_annot::{ - Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, - OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, BorderShape, CALLOUT_KNEE_LINE, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, + OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, Side as CalloutSide, StampGroup, StampKind, Style, callout_attach, + callout_knee, callout_side, rect_quad, }; pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; @@ -191,5 +192,8 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re config.clone().on_screen() } +/// How long a custom stamp's text line may be, in characters (#1741). +pub const MAX_STAMP_LINE: usize = 200; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index f6e75c2ba9..7232f64631 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -62,20 +62,54 @@ impl Session { return None; } - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {time}, {date}") + } + + /// A custom stamp's text line (#1741): `template` with `{name}` (the author), `{date}` + /// ("Oct 02, 2026") and `{time}` ("2:14 pm") filled in now, in local time, as the dynamic + /// stamps' "By" line has them. Cut to [`MAX_STAMP_LINE`](crate::MAX_STAMP_LINE) characters. + pub fn stamp_line(&self, template: &str, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + // One pass over the template, so a placeholder the author's name happens to contain + // (`Ada {date}`) stays as written rather than being filled in too. + let mut filled = String::new(); + let mut rest = template.trim(); + while let Some(c) = rest.chars().next() { + if let Some(after) = rest.strip_prefix("{name}") { + filled.push_str(author.trim()); + rest = after; + } else if let Some(after) = rest.strip_prefix("{date}") { + filled.push_str(&date); + rest = after; + } else if let Some(after) = rest.strip_prefix("{time}") { + filled.push_str(&time); + rest = after; + } else { + filled.push(c); + rest = rest.get(c.len_utf8()..).unwrap_or_default(); + } + } + filled.chars().take(crate::MAX_STAMP_LINE).collect() + } + + /// Now, as the stamps write it: ("Oct 02, 2026", "2:14 pm"), local time. + fn stamp_date_time(&self) -> (String, String) { let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + let month = MONTHS.get(mo.clamp(1, 12) as usize - 1).copied().unwrap_or("Jan"); + (format!("{month} {day:02}, {y}"), format!("{h12}:{mm:02} {}", if hh < 12 { "am" } else { "pm" })) } + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. pub fn today(&self) -> (i64, u32, u32) { let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index 074adfc82d..198904850d 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -241,6 +241,7 @@ impl SignatureImage { name: if initials { "Initials" } else { "Signature" }.into(), file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 }, author: author.into(), + line: None, }], }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9591a2026f..8a9ea105c6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -106,6 +106,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -441,6 +442,7 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -493,6 +495,7 @@ fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Half red".into(), file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -522,6 +525,7 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { name: "Diagram".into(), file: MarkFile { name: "diagram.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }; session.apply(id, stamp([56.0, 400.0, 539.0, 700.0])).unwrap(); assert_eq!(session.get(id).unwrap().info.annotations[0].rect, [56.0, 429.25, 539.0, 670.75]); @@ -5224,6 +5228,85 @@ fn comment_summary_includes_replies_to_replies() { assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); } +/// ISO 32000-2 §12.5.6.2: a group subordinate (`/RT /Group`) is part of its primary's comment, +/// not a reply: the summary leaves it out and writes the replies to it as the comment's. +#[test] +fn comment_summary_leaves_group_members_out_and_keeps_their_replies() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "MEMBER_TEXT".into(), author: "Bob".into() }).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "REPLY_TO_MEMBER".into(), author: "Carol".into() }).unwrap(); + let mut all = s.get(id).unwrap().info.annotations.clone(); + all.iter_mut().find(|a| a.contents.as_deref() == Some("MEMBER_TEXT")).unwrap().grouped = true; + let text = comment_summary("doc.pdf", &all, SummarySort::Page); + assert!(!text.contains("MEMBER_TEXT") && !text.contains("Bob"), "{text}"); + assert!(text.lines().any(|l| l == " REPLY_TO_MEMBER"), "the reply is the comment's, one level in: {text}"); +} + +/// The summary follows `/IRT` down from a comment at most 64 steps, group subordinates included: +/// a reply under a longer chain of subordinates is left out, one under a short chain is written, +/// and a subordinate listed at its own primary's position (a loop, from a malformed listing) +/// ends the walk. +#[test] +fn comment_summary_follows_a_group_chain_a_bounded_number_of_steps() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + let top = s.get(id).unwrap().info.annotations.iter().find(|a| a.is_comment()).unwrap().clone(); + let at = |a: &pdfcraft_render::Annotation| (a.page, a.index); + let under = |index: usize, parent: (usize, usize), grouped: bool, text: &str| pdfcraft_render::Annotation { + index, + parent: Some(parent), + grouped, + contents: Some(text.into()), + ..top.clone() + }; + for (members, written) in [(10, true), (100, false)] { + let mut all = vec![top.clone()]; + let mut up = at(&top); + for k in 0..members { + let m = under(1000 + k, up, true, "MEMBER"); + up = at(&m); + all.push(m); + } + all.push(under(5000, up, false, "DEEP_REPLY")); + let text = comment_summary("doc.pdf", &all, SummarySort::Page); + assert_eq!(text.contains("DEEP_REPLY"), written, "{members}: {text}"); + assert!(!text.contains("MEMBER"), "{members}: {text}"); + } + let looped = vec![top.clone(), under(top.index, at(&top), true, "LOOP")]; + let text = comment_summary("doc.pdf", &looped, SummarySort::Page); + assert!(!text.contains("LOOP"), "{text}"); +} + +/// Deleting a group's primary removes the subordinate's `/IRT` in the same edit, as Acrobat does: +/// the subordinate lists as a comment of its own and keeps `/RT /Group`. One undo puts the group +/// back, `/IRT` included. +#[test] +fn deleting_a_group_primary_releases_its_member_in_one_undo_step() { + let bytes = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /NM (primary) /Contents (PRIMARY_TEXT) >>", + b"<< /Type /Annot /Subtype /Circle /Rect [60 60 90 90] /NM (member) /IRT 4 0 R /RT /Group >>", + ]); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("group.pdf", None, Arc::new(bytes), None).unwrap(); + let names = |s: &Session| -> Vec<(Option, bool, bool)> { + s.get(id).unwrap().info.annotations.iter().map(|a| (a.name.clone(), a.is_comment(), a.is_group_member())).collect() + }; + // Listed by top edge: the Circle is the higher one. + let grouped = vec![(Some("member".into()), false, true), (Some("primary".into()), true, false)]; + assert_eq!(names(&s), grouped); + s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); + assert_eq!(names(&s), [(Some("member".into()), true, false)], "a comment of its own"); + let member = |s: &Session| { + let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos; + cos.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().cloned().unwrap() + }; + assert!(member(&s).get(b"IRT").is_none() && member(&s).name(b"RT") == Some(b"Group".as_slice()), "{:?}", member(&s)); + s.undo(id).unwrap(); + assert_eq!(names(&s), grouped, "one undo puts the group back"); + assert!(member(&s).reference(b"IRT").is_some()); +} + #[test] fn reading_colors_set_and_invalidated() { let (mut s, id) = session_with(1); @@ -8635,6 +8718,44 @@ fn a_line_without_an_appearance_is_drawn_at_its_leader_length() { assert_eq!(px(100, 200), vec![255, 255, 255, 255], "nothing along /L"); } +/// A text box without `/AP`, `/BS` or `/Border` is drawn with the `/Border` default, a 1-pt +/// border in its text colour (ISO 32000-2 Table 166), and a redaction mark without `/AP` or +/// `/QuadPoints` is outlined around `/Rect` (Table 195), here in its array `/OC` colour. +#[test] +fn text_boxes_and_redaction_marks_without_appearances_show_their_outlines() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (1 0 0 rg /Helv 10 Tf) >>", + b"<< /Type /Annot /Subtype /Redact /Rect [20 20 180 120] /IC [0 0 0] /OC [0 0 1] >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("outlines.pdf", None, Arc::new(bytes), None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + // y down: the text box spans rows 40..100, the redaction mark rows 180..280; both left edges + // are column 20. + assert_eq!(px(20, 70), [255, 0, 0], "the text box's left border, in its /DA colour"); + assert_eq!(px(100, 99), [255, 0, 0], "the text box's bottom border"); + assert_eq!(px(20, 230), [0, 0, 255], "the redaction mark's outline, in its /OC colour"); + assert_eq!(px(100, 230), [255, 255, 255], "no fill before the redaction is applied"); +} + /// A stamp's `/Rect` and the colour family expected inside it. type StampRect = ([f64; 4], char); @@ -10134,7 +10255,8 @@ fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. // Named "Signature" so `signature_image` finds its picture. let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + let stamp = + Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into(), line: None }; session.apply(id, stamp).unwrap(); let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); @@ -11799,6 +11921,92 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() black(&s, rewritten); } +/// A custom stamp's line template (#1741) fills in the author, date and time as the dynamic +/// stamps write them, and the "By" line itself is unchanged. +#[test] +fn stamp_lines_fill_in_the_name_date_and_time() { + let s = Session::new().with_clock(|| 1_700_000_000); + assert_eq!(s.stamp_line("Received {date} {time} by {name}", " Ada "), "Received Nov 14, 2023 10:13 pm by Ada"); + // Review on #2345 (gabglus): placeholders inside the author's name are kept as written. + assert_eq!(s.stamp_line("By {name} — {date} {time}", "Ada {date} {time}"), "By Ada {date} {time} — Nov 14, 2023 10:13 pm"); + assert_eq!(s.stamp_line("{name}{name}", "{name}"), "{name}{name}"); + assert_eq!(s.stamp_by_line("Ada"), "By Ada at 10:13 pm, Nov 14, 2023"); + assert_eq!(s.stamp_by_line(""), "at 10:13 pm, Nov 14, 2023"); + assert_eq!(s.stamp_line(&"{date}".repeat(100), "Ada").chars().count(), MAX_STAMP_LINE); +} + +/// A custom stamp's line keeps Western European letters (#1741): the appearance holds them as +/// WinAnsi bytes. A lossy UTF-8 round trip once turned them into replacement characters. +#[test] +fn a_custom_stamp_line_keeps_accented_letters() { + let image = image::RgbaImage::from_pixel(80, 40, image::Rgba([20, 120, 220, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let (mut session, id) = session_with(1); + let edit = Edit::AddCustomStamp { + page: 0, + rect: [100.0, 100.0, 100.0, 100.0], + name: "Logo".into(), + file: MarkFile { name: "logo.png".into(), bytes: Arc::new(png.into_inner()), page: 0 }, + author: "Zoë".into(), + line: Some(session.stamp_line("Reçu à Zürich par {name}", "Zoë")), + }; + session.apply(id, edit).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + const WANT: &[u8] = b"(Re\xe7u \xe0 Z\xfcrich par Zo\xeb)"; + let found = doc.scan_objects().any(|(_, o)| match o.as_ref() { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|b| b.windows(WANT.len()).any(|w| w == WANT)), + _ => false, + }); + assert!(found, "the line's letters are WinAnsi bytes in the appearance"); +} + +/// A custom stamp with a line keeps its picture's shape over the line on a turned page: it is +/// laid out in the displayed box, then turned into `/Rect`. Laid out in user space, a quarter +/// turn squashed the picture into a narrow strip and stretched the band. +#[test] +fn a_lined_custom_stamp_keeps_its_layout_on_turned_pages() { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = Arc::new(png.into_inner()); + // A short line: the stamp is the picture's 80 pt wide, 40 / 0.78 pt tall with its band. + let (w, h) = pdfcraft_annot::custom_stamp_size((80.0, 40.0), Some("OK")); + assert_eq!(w, 80.0); + let top = 100.0 - h / 2.0; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = session_with(1); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); + let edit = Edit::AddCustomStamp { + page: 0, + rect: [at[0], at[1], at[0], at[1]], + name: "Quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + line: Some("OK".into()), + }; + session.apply(id, edit).unwrap(); + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let pixel = |x: f64, y: f64| { + let offset = ((y as u32 * rendered.width + x as u32) * 4) as usize; + rendered.rgba[offset..offset + 4].to_vec() + }; + // The picture fills the top 80 x 40 pt of the displayed box, its quadrants in place. + let points = [(70.0, top + 5.0), (130.0, top + 5.0), (70.0, top + 35.0), (130.0, top + 35.0)]; + for ((x, y), colour) in points.into_iter().zip(colours) { + assert_eq!(pixel(x, y), colour, "page rotation {degrees}; displayed point ({x}, {y})"); + } + // The band under it holds no picture. + assert_eq!(pixel(64.0, top + h - 3.0), [255, 255, 255, 255], "page rotation {degrees}: the band"); + } +} + fn contains(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 253246a79e..38292245cc 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -34,7 +34,7 @@ pub use script::{ pub use shaping::{ ShapedCluster, ShapedScript, arabic_glyph, arabic_has, face_glyph, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has, }; -pub use std14::Std14; +pub use std14::{Match as Std14Match, Std14}; /// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics. /// diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs index 7c181dab48..f7f4c20b5d 100644 --- a/crates/platform/src/owner_only.rs +++ b/crates/platform/src/owner_only.rs @@ -538,7 +538,7 @@ mod tests { /// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write /// owner* that way, the access list can't be replaced although the owner can still be set: the - /// file isn't taken as restricted. + /// file isn't taken as restricted. Run elevated, it can be replaced, and then must be restricted. #[cfg(windows)] #[test] fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() { @@ -566,10 +566,24 @@ mod tests { // Nothing but the two entries: no Administrators entry for the elevated runner to use. let sddl = sddl_of(&file).unwrap(); assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}"); - assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced"); + // An elevated administrator (the windows-latest runner) may replace the list anyway; then the + // file must really be the user's alone, since the result is checked before it is trusted. + if restrict_to_owner(&file, &user()).is_ok() { + let after = sddl_of(&file).unwrap(); + assert!(elevated(), "the access list could not be replaced: {after}"); + assert_eq!(foreign_access(&after, &me), None, "{after}"); + } let _ = std::fs::remove_dir_all(&dir); } + /// Whether the tests run elevated: `whoami /groups` lists the high (or system) mandatory label. + #[cfg(windows)] + fn elevated() -> bool { + let out = system_tool(WHOAMI).args(["/groups", "/fo", "csv", "/nh"]).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + text.contains("\"S-1-16-12288\"") || text.contains("\"S-1-16-16384\"") + } + /// When the tools fail, the functions say so: nothing is taken as restricted or as checked. #[cfg(windows)] #[test] diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index ea1063fe01..8846b7a501 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -1041,10 +1041,14 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect(); + let kept: Vec = annots.iter().filter(|a| !pdfcraft_annot::goes_with(doc, a, &doomed)).cloned().collect(); + // A group subordinate of a mark stays, as when a comment's group primary is deleted, and + // loses its `/IRT`: through it the mark would stay in the file. + let kept = pdfcraft_annot::release_members(doc, kept, &doomed)?; // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, - // and an indirect array stays the one object the page points to. - if kept.len() != annots.len() { + // and an indirect array stays the one object the page points to. A page whose inline + // member lost its `/IRT` is written too. + if kept != annots { pdfcraft_annot::set_annots(doc, p.obj, kept)?; } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 76068dbe5a..031296a01e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2719,6 +2719,34 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() { assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); } +/// A group subordinate (`/RT /Group`) of a mark stays when the marks are cleared, as one stays +/// when its primary comment is deleted, and loses its `/IRT`: nothing then refers to the mark, +/// so a full save leaves the mark's text out. +#[test] +fn clearing_marks_releases_a_group_member_of_a_mark() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + let m = marks(&doc)[0].obj; + doc.update_dict(m, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::PdfString::text("MARK_SECRET_TEXT"))).unwrap(); + let mut member = Dict::new(); + member.set(b"Type".to_vec(), Object::name("Annot")); + member.set(b"Subtype".to_vec(), Object::name("Square")); + member.set(b"Rect".to_vec(), Object::Array([60, 60, 90, 90].map(Object::Int).to_vec())); + member.set(b"IRT".to_vec(), Object::Ref(m)); + member.set(b"RT".to_vec(), Object::name("Group")); + let member = doc.add(Object::Dict(member)); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap()); + listed.push(Object::Ref(member)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap(); + assert_eq!(clear_marks(&mut doc, None), Ok(1)); + assert_eq!(annots_of(&doc, doc.get(page).as_dict().unwrap()), [Object::Ref(member)], "the member stays"); + let d = doc.get(member).as_dict().cloned().unwrap(); + assert!(d.get(b"IRT").is_none() && d.name(b"RT") == Some(b"Group".as_slice()), "{d:?}"); + let full = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!full.windows(16).any(|w| w == b"MARK_SECRET_TEXT"), "the cleared mark is not in a full save"); +} + #[test] fn redacted_copies_are_named_with_redacted_before_the_extension() { assert_eq!(redacted_file_name("contract.pdf"), "contract_Redacted.pdf"); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e3455e083f..015f32449d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,8 +37,8 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, - OutlineItem, PageInfo, PageTransition, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, - thread_tops, + MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, + fill_group_text, group_primaries, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 780ad71508..7313716a2e 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,6 +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, state_or_default, + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, Popup, RemoteDestination, fill_group_text, link_replies, + state_or_default, }; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -207,6 +208,35 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; + // A callout: its arrow point and knee, and its text box (keep in step with + // `pdfcraft_annot::summaries`). + let (points, text_box) = match self.callout(d, rect) { + Some((lead, text_box)) if subtype == "FreeText" => (lead, Some(text_box)), + _ => (points, None), + }; + // Only the comment's own pop-up: a pop-up annotation whose /Parent, if any, is a + // reference to this comment (keep in step with `pdfcraft_annot::summaries`). + let own = self.resolved_id(a); + let is_own = |p: &&Dictionary| { + self.name(p, b"Subtype").as_deref() == Some("Popup") + && p.get(b"Parent") + .map_or(true, |parent| parent.as_reference().is_ok() && self.resolved_id(parent).is_some_and(|id| Some(id) == own)) + }; + // A pop-up without a `/Rect` of four numbers finite as `f32` is none, as + // `pdfcraft_annot::summaries` reads it: both lists of the comments agree. Its corners + // may come in any order. + let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).and_then(|p| { + let rect = self.resolve(p.get(b"Rect").ok()?).as_array().ok()?; + let nums: Vec = rect.iter().filter_map(|v| self.resolve(v).as_float().ok()).collect(); + let [x0, y0, x1, y1] = nums[..] else { return None }; + if !nums.iter().all(|v| v.is_finite()) { + return None; + } + Some(Popup { + rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], + open: p.get(b"Open").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false), + }) + }); let annotation = Annotation { page, subtype, @@ -229,6 +259,8 @@ impl<'a> Inspector<'a> { fill_sign, no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), points, + text_box, + popup, }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { @@ -239,6 +271,27 @@ impl<'a> Inspector<'a> { } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); link_replies(&mut info.annotations); + fill_group_text(&mut info.annotations); + } + + /// A callout's arrow point and knee (`/CL`, four or six numbers, without the end that meets + /// the text box) and its text box (`/Rect`, here `rect`, inset by `/RD`); ISO 32000-2 + /// Table 177. `None` when either is missing or unusable. + fn callout(&self, d: &Dictionary, rect: [f32; 4]) -> Option<(Vec<[f32; 2]>, [f32; 4])> { + let nums = |key: &[u8]| -> Option> { + match self.resolve(d.get(key).ok()?) { + Object::Array(v) => v.iter().map(|o| self.resolve(o).as_float().ok()).collect(), + _ => None, + } + }; + let cl = nums(b"CL").filter(|l| matches!(l.len(), 4 | 6) && l.iter().all(|x| x.is_finite()))?; + let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite() && *x >= 0.0))?; + let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]; + if tb[2] - tb[0] < 1.0 || tb[3] - tb[1] < 1.0 { + return None; + } + let (_, lead) = cl.as_chunks::<2>().0.split_last()?; + Some((lead.to_vec(), tb)) } /// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest` diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 622761576f..18b7533f2c 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -346,8 +346,9 @@ pub struct Annotation { /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed /// comment ([`link_replies`]). pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its - /// strikeout) rather than a reply to it. + /// `/RT /Group` (ISO 32000-2 §12.5.6.2, Table 172): with a `parent`, a subordinate of the + /// group whose primary `/IRT` points at, not a reply ([`Self::is_group_member`]). Its own + /// text, author, colour and dates are ignored; see [`group_primaries`]. pub grouped: bool, /// Rect in PDF user space: [x0, y0, x1, y1]. pub rect: [f32; 4], @@ -369,9 +370,83 @@ pub struct Annotation { /// appearance stays upright, turned about the upper-left corner of `rect` /// ([`Self::shown_rect`]). pub no_rotate: bool, - /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a - /// cloud (their corners, `/Vertices`), in user space; empty for other comments. + /// The points that shape a line (its two ends, `/L`), connected lines, a polygon or a cloud + /// (their corners, `/Vertices`), or a callout (its arrow point and knee: `/CL` without its + /// end, which follows the text box), in user space; empty for other comments. pub points: Vec<[f32; 2]>, + /// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments. + pub text_box: Option<[f32; 4]>, + /// The comment's pop-up window (`/Popup`): where it sits and whether it is open. + pub popup: Option, +} + +/// How far [`group_primaries`] follows `/IRT` from a group subordinate to its primary. +pub const MAX_GROUP_DEPTH: usize = 64; + +/// For each annotation of `all`, the position in `all` of the primary of the group it is a +/// subordinate of ([`Annotation::is_group_member`], ISO 32000-2 §12.5.6.2), or `None` when it is +/// not one. The group's attributes, its comment and its replies are the primary's. A subordinate +/// of a subordinate leads to the first non-subordinate up the `/IRT` chain. Expects links +/// [`link_replies`] settled: a subordinate whose chain loops or names nothing listed has lost its +/// `parent` there and is a comment of its own, as such a reply is. One still on a subordinate +/// after [`MAX_GROUP_DEPTH`] steps has no primary (`None`) and is listed under no comment. +pub fn group_primaries(all: &[Annotation]) -> Vec> { + let mut at: HashMap<(usize, usize), usize> = HashMap::new(); + for (i, a) in all.iter().enumerate() { + at.entry((a.page, a.index)).or_insert(i); + } + let primary = |start: usize| -> Option { + let mut i = start; + for _ in 0..MAX_GROUP_DEPTH { + let a = all.get(i)?; + if !a.is_group_member() { + return Some(i); + } + i = *at.get(&a.parent?)?; + } + None + }; + all.iter().enumerate().map(|(i, a)| if a.is_group_member() { primary(i) } else { None }).collect() +} + +/// Give a group's StrikeOut primary with no text of its own (`/Contents` missing or empty) the +/// text of its first Caret subordinate that has some. A reader ignores a subordinate's +/// `/Contents` (ISO 32000-2 §12.5.6.2), but Replace Text groups written by earlier PdfCraft builds +/// made the strikeout the primary, with no text, and kept the replacement only on the caret: +/// without this they would list as a comment with no text. A primary's own text always wins, no +/// other subordinate lends its text, and no other kind of primary borrows one. Only the listing +/// changes, not the file. +pub fn fill_group_text(all: &mut [Annotation]) { + if !all.iter().any(Annotation::is_group_member) { + return; + } + let primaries = group_primaries(all); + let lacks_text = |p: usize| all.get(p).is_some_and(|a| a.subtype == "StrikeOut" && a.contents.as_deref().is_none_or(str::is_empty)); + // Keyed by the primary's position: the first caret with text per primary wins (an empty one + // lends nothing), and each primary is written once, so the order of the map does not matter. + let mut texts: HashMap = HashMap::new(); + for (a, p) in all.iter().zip(&primaries) { + if let (Some(p), Some(text), "Caret") = (*p, a.contents.as_ref(), a.subtype.as_str()) + && !text.is_empty() + && lacks_text(p) + { + texts.entry(p).or_insert_with(|| text.clone()); + } + } + for (p, text) in texts { + if let Some(primary) = all.get_mut(p) { + primary.contents = Some(text); + } + } +} + +/// A comment's pop-up window (ISO 32000-2 §12.5.6.14). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Popup { + /// `/Rect` of the `/Popup` annotation, in user space. + pub rect: [f32; 4], + /// `/Open`: shown open. + pub open: bool, } impl Annotation { @@ -392,6 +467,13 @@ impl Annotation { !self.is_reply() && self.state.is_none() } + /// A subordinate of a group (`/RT /Group` with an `/IRT` that leads to a listed comment, ISO + /// 32000-2 §12.5.6.2): part of its primary's comment ([`group_primaries`]), neither a comment + /// of its own nor one of its replies. Expects links [`link_replies`] settled. + pub fn is_group_member(&self) -> bool { + self.grouped && self.is_reply() + } + /// Whether this annotation's `/IRT` points at `other`. pub fn replies_to(&self, other: &Annotation) -> bool { self.parent == Some((other.page, other.index)) @@ -496,6 +578,18 @@ pub fn link_replies(annotations: &mut [Annotation]) { a.parent = None; } } + // A group is one page's (Acrobat reads it so): a subordinate whose primary is on another page + // is a comment of its own, with its own author and text, rather than part of that group or a + // reply to it. ISO 32000-2 §12.5.6.2 doesn't limit a group to a page, but Acrobat (desktop and + // iPad) and PDF-XChange all delete the primary without such a subordinate, and Acrobat desktop + // lists it on its own. Keep in step with + // `pdfcraft_annot::summaries`. + for a in annotations.iter_mut() { + if a.grouped && a.parent.is_some_and(|(page, _)| page != a.page) { + a.parent = None; + a.in_reply_to = None; + } + } } /// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a @@ -547,6 +641,9 @@ impl<'a> ReviewStates<'a> { // costs one walk, not one per reply. let mut reaches: HashMap<(usize, usize), Option<(usize, usize)>> = HashMap::new(); let mut by_target: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + // A state set on a group subordinate is the group's, its primary's (ISO 32000-2 §12.5.6.2). + let primary_of: HashMap<(usize, usize), (usize, usize)> = + all.iter().zip(group_primaries(all)).filter_map(|(a, p)| all.get(p?).map(|p| ((a.page, a.index), (p.page, p.index)))).collect(); for r in all.iter().filter(|a| a.state.is_some()) { let (mut path, mut seen, mut up) = (vec![(r.page, r.index)], HashSet::from([(r.page, r.index)]), r.parent); let target = loop { @@ -567,7 +664,7 @@ impl<'a> ReviewStates<'a> { for key in path { reaches.insert(key, target); } - if let Some(t) = target { + if let Some(t) = target.map(|t| primary_of.get(&t).copied().unwrap_or(t)) { by_target.entry(t).or_default().push(r); } } @@ -806,6 +903,8 @@ mod review_state_tests { fill_sign: false, no_rotate: false, points: Vec::new(), + text_box: None, + popup: None, }) .collect(); link_replies(&mut all); @@ -874,6 +973,24 @@ mod review_state_tests { assert!(states.history(by_name(&all, "s1")).is_empty(), "a state reply has no states of its own"); } + /// §12.5.6.2: a group is one comment, its primary's, so a status set on a subordinate is the + /// group's; one set on an ordinary reply stays the reply's. + #[test] + fn a_status_on_a_group_subordinate_is_the_groups() { + let mut all = listed(&[ + ("p", None, "Alice", None), + ("sub", Some("p"), "Bob", None), + ("s", Some("sub"), "Dan", Some("Accepted")), + ("r", Some("p"), "Carol", None), + ("rs", Some("r"), "Eve", Some("Rejected")), + ]); + all[1].grouped = true; + let states = ReviewStates::new(&all); + assert_eq!(states.statuses(by_name(&all, "p")), [("Dan", "Accepted")]); + assert!(states.history(by_name(&all, "sub")).is_empty()); + assert_eq!(states.statuses(by_name(&all, "r")), [("Eve", "Rejected")]); + } + /// States follow object references, not `/NM`: two comments with the same `/NM` (some writers /// name the first comment on every page alike) each keep their own statuses. #[test] diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 5d20412202..e89fab5c99 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -304,6 +304,8 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { fill_sign: false, no_rotate: true, points: Vec::new(), + text_box: None, + popup: None, }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { @@ -2242,6 +2244,39 @@ fn carriage_returns_in_comment_text_become_line_breaks() { assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree")); } +/// A comment's pop-up (`/Popup`) is read with its place and state; one without reads none. +#[test] +fn a_comments_pop_up_is_read_with_its_place_and_state() { + let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R 6 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R /Open true >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect [190 160 50 280] /Parent 4 0 R /Open true >> endobj\n6 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 60] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + let info = inspect(Arc::new(bytes.to_vec()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "normalised, and open"); + let square = info.annotations.iter().find(|a| a.subtype == "Square").expect("the square"); + assert_eq!(square.popup, None); + assert!(info.annotations.iter().all(|a| a.subtype != "Popup"), "the pop-up is not a comment of its own"); + + // #2137 review: a /Popup naming another annotation, a pop-up of another comment, or one + // whose /Parent isn't a reference isn't this comment's pop-up; one without /Parent is. + for (target, extra, own) in [ + ("6 0 R", "", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent 6 0 R >> endobj\n", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent << >> >> endobj\n", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] >> endobj\n", true), + ] { + let text = String::from_utf8_lossy(bytes).replace("/Popup 5 0 R", &format!("/Popup {target}")).replace("trailer", &format!("{extra}trailer")); + let info = inspect(Arc::new(text.into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.is_some(), own, "/Popup {target} {extra}"); + } + + // #2137 review: /Annots may list the note through an object that is only a reference to + // it; its pop-up's /Parent names the note itself, and is still its own. + let text = String::from_utf8_lossy(bytes).replace("/Annots [4 0 R", "/Annots [8 0 R").replace("trailer", "8 0 obj 4 0 R endobj\ntrailer"); + let info = inspect(Arc::new(text.into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "listed through 8 0 R"); +} + #[test] fn dates_are_prettified() { assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); @@ -2263,6 +2298,186 @@ trailer << /Root 1 0 R >> assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); } +mod group_tests { + use crate::inspect::{Annotation, MAX_GROUP_DEPTH, group_primaries, link_replies}; + + /// The annotation at `/Annots` position `index` of page 0, whose `/IRT` points at position + /// `parent`, with `/RT /Group` when `grouped`. + fn annot(index: usize, parent: Option, grouped: bool) -> Annotation { + Annotation { + page: 0, + subtype: "Circle".into(), + author: None, + contents: None, + modified: None, + name: Some(index.to_string()), + in_reply_to: parent.map(|p| p.to_string()), + parent: parent.map(|p| (0, p)), + grouped, + rect: [0.0; 4], + color: None, + index, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + text_box: None, + popup: None, + } + } + + /// `all` with its reply links settled, as both comment readers list it. + fn listed(mut all: Vec) -> Vec { + link_replies(&mut all); + all + } + + /// ISO 32000-2 §12.5.6.2: a subordinate's primary is the annotation its `/IRT` points at, + /// past any other subordinate; a reply is no subordinate. A loop, or an `/IRT` to nothing + /// listed, leaves a comment of its own (as `link_replies` leaves such a reply), and an + /// over-long chain ends. + #[test] + fn group_primaries_follow_irt_to_the_first_non_subordinate() { + let all = listed(vec![ + annot(0, None, false), // top + annot(1, Some(0), true), // mid + annot(2, Some(1), true), // low + annot(3, Some(0), false), // a reply + annot(4, Some(5), true), // a loop + annot(5, Some(4), true), // of two + annot(6, Some(6), true), // itself + annot(7, Some(99), true), // nothing listed + annot(8, Some(3), true), // grouped with the reply + ]); + assert_eq!(group_primaries(&all), [None, Some(0), Some(0), None, None, None, None, None, Some(3)]); + assert!(all.iter().skip(4).take(4).all(Annotation::is_comment), "{all:?}"); + // A chain longer than the cap has no primary; one within it does, whether the reply links + // are settled first or not. + for (len, found) in [(MAX_GROUP_DEPTH - 1, true), (MAX_GROUP_DEPTH + 1, false)] { + let mut chain = vec![annot(0, None, false)]; + chain.extend((1..=len).map(|i| annot(i, Some(i - 1), true))); + assert_eq!(group_primaries(&chain).last().copied().flatten().is_some(), found, "{len}"); + assert_eq!(group_primaries(&listed(chain)).last().copied().flatten().is_some(), found, "{len}, listed"); + } + } + + /// `group_primaries` is public and may be handed links `link_replies` has not settled: a loop + /// of subordinates (each other's `/IRT`, or its own) still ends, within `MAX_GROUP_DEPTH` + /// steps, with no primary. + #[test] + fn group_primaries_end_on_an_unsettled_loop() { + let all = vec![annot(0, Some(1), true), annot(1, Some(0), true), annot(2, Some(2), true), annot(3, None, false)]; + assert_eq!(group_primaries(&all), [None, None, None, None]); + } + + fn with(mut a: Annotation, subtype: &str, t: Option<&str>) -> Annotation { + a.subtype = subtype.into(); + a.contents = t.map(Into::into); + a + } + + /// A StrikeOut primary with no text lists its first Caret subordinate's (earlier Replace + /// Text groups kept the replacement on the caret only); a primary's own text always wins, a + /// subordinate of another type lends nothing, and a primary of another type borrows nothing + /// (ISO 32000-2 §12.5.6.2: the group's text is the primary's). + #[test] + fn a_primary_without_text_lists_its_carets() { + let mut all = listed(vec![ + with(annot(0, None, false), "StrikeOut", None), + with(annot(1, Some(0), true), "Caret", Some("red")), + with(annot(2, Some(0), true), "Caret", Some("second")), + with(annot(3, None, false), "Caret", Some("kept")), + with(annot(4, Some(3), true), "Caret", Some("ignored")), + with(annot(5, Some(0), false), "Text", Some("a reply")), + with(annot(6, None, false), "StrikeOut", None), + with(annot(7, Some(6), true), "Circle", Some("not lent")), + with(annot(8, None, false), "Square", None), + with(annot(9, Some(8), true), "Caret", Some("not borrowed")), + ]); + super::fill_group_text(&mut all); + let shown: Vec> = all.iter().map(|a| a.contents.as_deref()).collect(); + assert_eq!( + shown, + [ + Some("red"), + Some("red"), + Some("second"), + Some("kept"), + Some("ignored"), + Some("a reply"), + None, + Some("not lent"), + None, + Some("not borrowed"), + ] + ); + } + + /// Review on #4358: an empty caret ahead of one with text lends nothing, so the primary lists + /// the replacement rather than nothing. + #[test] + fn an_empty_caret_does_not_hide_the_next_carets_text() { + let mut all = listed(vec![ + with(annot(0, None, false), "StrikeOut", None), + with(annot(1, Some(0), true), "Caret", Some("")), + with(annot(2, Some(0), true), "Caret", Some("new words")), + ]); + super::fill_group_text(&mut all); + assert_eq!(all[0].contents.as_deref(), Some("new words")); + } + + /// Many groups each get their own caret's text (one map lookup per caret, not a scan of the + /// groups seen so far). + #[test] + fn many_groups_each_list_their_own_caret() { + const N: usize = 20_000; + let mut all = Vec::with_capacity(2 * N); + for i in 0..N { + all.push(with(annot(i, None, false), "StrikeOut", None)); + } + // Carets in reverse order, so a primary's caret is far from it in the list. + for (k, i) in (0..N).rev().enumerate() { + all.push(with(annot(N + k, Some(i), true), "Caret", Some(&format!("t{i}")))); + } + let mut all = listed(all); + super::fill_group_text(&mut all); + for (i, a) in all.iter().take(N).enumerate() { + assert_eq!(a.contents.as_deref(), Some(format!("t{i}").as_str()), "{i}"); + } + } +} + +/// #2137 review: a pop-up without a `/Rect` of four numbers reads as no pop-up, as +/// `pdfcraft_annot::summaries` reads it, so the two lists of comments agree. +#[test] +fn a_pop_up_without_a_four_number_rect_is_none() { + let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + for (rect, read) in [("", false), ("/Rect [0 0 50]", false), ("/Rect (box)", false), ("/Rect [0 0 50 /x]", false), ("/Rect [0 0 50 60]", true)] { + let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.is_some(), read, "{rect:?}"); + } +} + +/// #2137 review: a pop-up `/Rect` with its corners reversed reads with them in order (ISO +/// 32000-2 §7.9.5), and one with a number too large for `f32` reads as no pop-up, as +/// `pdfcraft_annot::summaries` reads both. +#[test] +fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() { + let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + let popup = |rect: &str| { + let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens"); + info.annotations.iter().find(|a| a.subtype == "Text").expect("the note").popup.map(|p| p.rect) + }; + assert_eq!(popup("[150 200 50 100]"), Some([50.0, 100.0, 150.0, 200.0]), "reversed"); + let huge = format!("1{}", "0".repeat(40)); + assert_eq!(popup(&format!("[0 0 {huge} 100]")), None, "a number past f32"); + assert_eq!(popup(&format!("[0 0 {huge}.0 100]")), None, "a real past f32"); +} + /// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it /// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every /// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0377dfe16d..0edaf558bc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -20,8 +20,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, - OutlineItem, PageInfo, PageTransition, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, - check_pages, inspect, link_replies, pretty_date, thread_tops, + MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, + attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, }; pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri}; pub use pixels::Pixels; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8887683ac0..eebcece9b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1346,6 +1346,8 @@ struct PageEnv<'a> { comments_hidden: bool, content_h: f32, content_w: f32, + /// The custom stamp's line, filled in now (#1741). + custom_line: &'a Option, custom_stamp: &'a Option, date_text: &'a Result, doc: &'a pdfcraft_engine::Document, @@ -2805,7 +2807,8 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 { /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { - let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; + let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = + *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2824,6 +2827,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); } + if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { + // A click on the composer's Post also takes the focus from a pop-up's box, saving what + // was typed there: both edits land in this frame, so both are kept, in one batch. + view.pending_edit = Some(match view.pending_edit.take() { + Some(first) => Edit::Batch { label: "Edit comment".into(), edits: vec![first, e] }, + None => e, + }); + } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } @@ -3308,6 +3319,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, }; + let custom_line = custom_stamp.as_ref().and_then(|cs| cs.line.as_deref()).map(|t| app.session.stamp_line(t, &author)); let mut open_props: Option<(usize, usize)> = None; let mut recolour: Option = None; // The table being set up in its window, shown on its own document's page. @@ -3351,6 +3363,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { comments_hidden, content_h, content_w, + custom_line: &custom_line, custom_stamp: &custom_stamp, date_text: &date_text, doc, @@ -3729,7 +3742,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true }); } Some(PageMenuAction::FlattenComments) => { - app.views[index].comments.selected = None; + app.views[index].comments.select(None); app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false }); } Some(PageMenuAction::AddBookmark) => { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index b07ed55380..fa0a15562b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -340,6 +340,7 @@ fn tool_input( by_line, can_crop, can_modify, + custom_line, custom_stamp, date_text, doc, @@ -368,7 +369,7 @@ fn tool_input( } if let Some(cs) = custom_stamp.as_ref() && allowed - && crate::stamps_ui::place_custom(cx, cs, author, free, view) + && crate::stamps_ui::place_custom(cx, cs, custom_line, author, free, view) { frame.out.stamp_placed = true; } @@ -445,7 +446,7 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; - comments::context(hits.pcx, comment, view); + comments::context(hits.pcx, p, comment, view); } if let Some(Owner::PrepareField { field, widget }) = context { crate::prepare::context(hits.form, field, widget, view); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 9246bb83f5..4868b22e4a 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -164,7 +164,7 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 37] = [ +const PAGE_MODULES: [&str; 38] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -182,6 +182,7 @@ const PAGE_MODULES: [&str; 37] = [ "tools/comments/input.rs", "tools/comments/menu.rs", "tools/comments/paint.rs", + "tools/comments/popups.rs", "tools/comments/state.rs", "tools/forms_ui.rs", "tools/prepare.rs", @@ -241,13 +242,14 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 12] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 13] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), ("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), + ("tools/comments/popups.rs", "popups", "the comment pop-up windows are Areas of their own over the page"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), ("tools/forms_ui.rs", "choice_list", "a drop-down's open list is an Area of its own over the page"), ("tools/forms_ui.rs", "scroll_bar", "the scroll bar of a drop-down's open list, in its Area over the page"), @@ -294,10 +296,15 @@ const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [ const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; /// Functions in the page modules that set a cursor other than the pages': (file, function, why). -const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [ +const NOT_PAGE_CURSOR: [(&str, &str, &str); 4] = [ ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"), ("tools/calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"), + ( + "tools/comments/popups.rs", + "popups", + "the comment pop-up windows are Areas of their own over the page; their title bars are dragged to move them", + ), ]; /// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its `.`, is @@ -589,9 +596,14 @@ const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ const COMMENT_POINT_TESTS: [&str; 2] = [".comment_at(", "PageCx::comment_at("]; /// The functions that ask which comment lies under a point: (file, function, why). -const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ +const COMMENT_POINT_TESTERS: [(&str, &str, &str); 4] = [ ("tools/comments/input.rs", "hit", "the comments' hit test"), - ("tools/comments/input.rs", "context_hit", "the comments' hit test for a right-click"), + ( + "tools/comments/state.rs", + "click_target", + "the comments' hit tests' lookup: a group subordinate (`/RT /Group`, ISO 32000-2 §12.5.6.2) stands for its primary", + ), + ("tools/comments/state.rs", "picked", "which group subordinate a click selected its primary through, when the click is acted on"), ("tools/comments/paint.rs", "paint_page", "a redaction mark shows its fill under the pointer with every tool, as comments' tips show"), ]; diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 0d864bd38a..c3ffcfb6ee 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -26,6 +26,11 @@ impl DocView { pub fn page_xform(&self, page: usize) -> Option { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf) } + + /// Where the pages are on screen this frame, with their transforms. + pub(crate) fn page_xforms(&self) -> Vec<(usize, PageXform)> { + self.screen_xforms.iter().map(|(p, xf)| (*p, *xf)).collect() + } } /// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 2309e7e600..8a535a83d1 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -119,7 +119,7 @@ impl PdfCraftApp { self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); return; } - let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; + let Some(tool) = crate::comments::tool_for_default(&doc.info.annotations, a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { style.color = c; @@ -323,9 +323,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }) .unwrap_or_default(); let has_default = app.comment_props.as_ref().is_some_and(|d| { - let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); - doc.and_then(|doc| doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())) - .is_some_and(|a| crate::comments::tool_for_default(a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a)) + let Some(doc) = app.active_ids().and_then(|(_, id)| app.session.get(id)) else { return false }; + doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()).is_some_and(|a| { + crate::comments::tool_for_default(&doc.info.annotations, a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a) + }) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2f528e01cb..60889a113f 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -169,6 +169,15 @@ impl PdfCraftApp { } _ => Vec::new(), }; + // The comments the pop-ups hold unsaved text or a drag for, to see afterwards whether + // their places still name them (pages may move, come or go). + let popups_before: Vec = match (self.views.get(i), self.session.get(id)) { + (Some(v), Some(d)) => { + let keys = v.comments.popup_keys(); + d.info.annotations.iter().filter(|a| keys.contains(&(a.page, a.index))).cloned().collect() + } + _ => Vec::new(), + }; match self.session.apply(id, edit.clone()) { Ok(()) => { let Some(doc) = self.session.get(id) else { return true }; @@ -211,8 +220,7 @@ impl PdfCraftApp { view.forms.unbaked.extend(unbaked); } } else if let Some(page) = one_page { - // Comment and content edits change one page: keep every other raster. - view.page_changed(page); + one_page_changed(view, &edit, page); } else if let Some((changed_pages, unbaked)) = form_edit { view.forms.unbaked.extend(unbaked); for page in changed_pages { @@ -224,6 +232,10 @@ impl PdfCraftApp { if matches!(edit, Edit::AddAnnotation(_)) { view.comments.comment_added(); } + // A deletion moves the pop-ups' state with its comments (`annotation_deleted`). + if !matches!(edit, Edit::DeleteAnnotation { .. }) { + view.comments.popups_after_edit(&popups_before, &info.annotations); + } // Keep the pages the user acted on selected, where they now are. select_after_edit(view, &edit, info, &annots_before, self.quick_tool); if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) { @@ -817,7 +829,7 @@ impl PdfCraftApp { .session .get(view.id) .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max()); - view.comments.selected = newest.map(|index| (page, index)); + view.comments.select(newest.map(|index| (page, index))); view.comments.reveal = true; self.quick_tool = crate::QuickTool::Select; } @@ -1040,6 +1052,15 @@ fn content_page(edit: &Edit) -> Option { } } +/// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing +/// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation +/// paints nothing in the raster (ISO 32000-2 §12.5.6.14). +fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) { + if !matches!(edit, Edit::SetAnnotationPopup { .. }) { + view.page_changed(page); + } +} + fn comment_page(edit: &Edit) -> Option { match edit { Edit::AddAnnotation(a) => Some(a.page), @@ -1049,6 +1070,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } | Edit::ReshapeAnnotation { page, .. } + | Edit::SetAnnotationPopup { page, .. } | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index cc328db5ab..102da43db8 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -40,6 +40,8 @@ pub fn subtype_label(s: &str) -> &str { /// The comment type as the panel names it, telling callouts, clouds and replaced text apart by `/IT`. pub fn kind_label(a: &Annotation) -> &str { match a.intent.as_deref() { + // Replace Text: the caret (`/IT /Replace`) is the group's primary; earlier builds made + // the strikeout primary. Some("StrikeOutTextEdit" | "Replace") => "Replace text", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", @@ -89,10 +91,10 @@ pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| a.is_comment()).count() } -/// The replies under the header's count, at any depth: not status changes, checkmarks or a -/// Replace Text caret (grouped with its strikeout, not a reply to it). +/// The replies under the header's count, at any depth: not status changes, checkmarks or group +/// subordinates (`/RT /Group`, such as Replace Text's strikeout: part of the comment, not a reply). pub fn reply_count(info: &DocInfo) -> usize { - info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.grouped).count() + info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.is_group_member()).count() } fn matches(a: &Annotation, q: &str) -> bool { @@ -176,10 +178,14 @@ pub(crate) fn show( // annotation its `/IRT` chain refers to past earlier state replies (ISO 32000-2 §12.5.6.3), // so one set on a reply, at any depth, is that reply's and not the comment's, and the card's // badges and checkmark leave it out (comment_list reports it on the reply). Page badges count - // roots before filtering. + // roots before filtering. A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) is one comment, its + // primary's: a subordinate is no reply, and a reply to one (a status too) is the group's. let tops = pdfcraft_render::thread_tops(&info.annotations); let states = ReviewStates::new(&info.annotations); for a in &info.annotations { + if a.is_group_member() { + continue; + } if let Some(parent) = a.parent { if a.state.is_none() { replies.entry(tops.get(&(a.page, a.index)).copied().unwrap_or(parent)).or_default().push(a); diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index bfd0592a08..b9b6b1b9cc 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -26,6 +26,7 @@ mod geometry; mod input; mod menu; mod paint; +mod popups; mod state; pub(crate) use composer::{composer, submit_key}; @@ -45,6 +46,7 @@ use menu::{RECENT_CUSTOM, same_colour}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; +pub(crate) use popups::popups; #[cfg(test)] use state::after_delete; use state::login_name; @@ -443,14 +445,23 @@ pub(crate) fn made_by(a: &Annotation, tool: CommentTool) -> bool { /// The tool whose defaults "Make Current Properties Default" should update. /// /// Arrows are stored as Line annotations. An arrow ending belongs to the Arrow tool, so its -/// defaults stay separate from a plain line. -pub(crate) fn tool_for_default(a: &Annotation, endings: Option<&[LineEnding]>) -> Option { +/// defaults stay separate from a plain line. A Replace Text group is selected by its caret (the +/// group's primary, ISO 32000-2 §12.5.6.2), so a caret whose group holds a Replace Text strikeout +/// (`/IT /StrikeOutTextEdit`) belongs to the Replace Text tool; `all` is the document's listing. +pub(crate) fn tool_for_default(all: &[Annotation], a: &Annotation, endings: Option<&[LineEnding]>) -> Option { match tool_for(a) { Some(CommentTool::Line) if endings.is_some_and(|ends| ends.iter().copied().any(arrow_ending)) => Some(CommentTool::Arrow), + Some(CommentTool::Caret) if replaces_text(all, a) => Some(CommentTool::ReplaceText), other => other, } } +/// Whether the caret `a` is the primary of a Replace Text group: a grouped strikeout with +/// `/IT /StrikeOutTextEdit` is one of its subordinates. +fn replaces_text(all: &[Annotation], a: &Annotation) -> bool { + all.iter().any(|s| s.is_group_member() && s.subtype == "StrikeOut" && s.intent.as_deref() == Some("StrikeOutTextEdit") && s.replies_to(a)) +} + fn arrow_ending(ending: LineEnding) -> bool { matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) } diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 837141b4d3..842a5dfc84 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -137,14 +137,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // A callout's leader and box stay in user-space axes (its appearance isn't turned). let font_size = prefs.font_size(CommentTool::Callout).unwrap_or(10.0); let rect = text_box_rect(c.at, &text, font_size, 0); - let side = if point[0] < rect[0] { - rect[0] - } else if point[0] > rect[2] { - rect[2] - } else { - (rect[0] + rect[2]) / 2.0 - }; - let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0]; + let knee = pdfcraft_engine::callout_knee(rect, point); Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size, ending: LineEnding::OpenArrow }, text)) } ComposerKind::Replace => { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 594abae86e..4a2ace8fc0 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -12,21 +12,72 @@ pub(super) fn on_text(a: &Annotation) -> bool { is_markup(&a.subtype) || a.subtype == "Caret" && a.intent.as_deref() == Some("Replace") } -/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's -/// `/Rect` also holds its leader line, so it only moves. +/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout +/// resizes its text box ([`frame`]); one whose box can't be read (no `/RD`) only moves, since its +/// `/Rect` also holds its leader line. pub(super) fn resizable(a: &Annotation) -> bool { - matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout") + matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") + && (a.intent.as_deref() != Some("FreeTextCallout") || a.text_box.is_some()) } -/// Lines, connected lines, polygons and clouds: their ends and corners have handles of their own -/// (measurements keep the points they measured, and a locked comment its shape). +/// Lines, connected lines, polygons, clouds and callouts: their ends, corners, or a callout's +/// arrow point and knee have handles of their own (measurements keep the points they measured, +/// and a locked comment its shape). pub(super) fn reshapable(a: &Annotation) -> bool { - matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon") + (matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon") || is_callout(a)) && !a.points.is_empty() && !a.locked && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) } +/// A callout whose text box and leader line PdfCraft can read. +pub(super) fn is_callout(a: &Annotation) -> bool { + a.subtype == "FreeText" && a.text_box.is_some() +} + +/// The rectangle a comment's selection frame and resize handles go around, in user space: a +/// callout's text box, otherwise where the comment is drawn (`/Rect`, or a sticky note kept +/// upright on a page turned `rotation` degrees). +pub(super) fn frame(a: &Annotation, rotation: u16) -> [f32; 4] { + a.text_box.unwrap_or_else(|| a.shown_rect(rotation)) +} + +/// A callout's points while one is dragged to `to`. Its knee moves along its knee line only +/// (Acrobat: "The knee line can be resized in one direction only; horizontal knee lines can be +/// resized horizontally only; vertical knee lines can be resized vertically only", Acrobat DC Help, +/// 11/28/2019, p. 357), and no nearer the box than [`pdfcraft_engine::CALLOUT_KNEE_LINE`] +/// (Acrobat Pro DC, a 2021 Continuous release, lays a knee dragged into the box out again at that +/// distance). Its arrow point moves freely, with Shift held to 45° from the knee. +pub(super) fn callout_reshaped(points: &[[f64; 2]], text_box: [f32; 4], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { + let tb = text_box.map(f64::from); + let gap = pdfcraft_engine::CALLOUT_KNEE_LINE; + match (i, points.get(1)) { + (1, Some(knee)) => { + let to = match pdfcraft_engine::callout_side(tb, *knee) { + pdfcraft_engine::CalloutSide::Left => [to[0].min(tb[0] - gap), knee[1]], + pdfcraft_engine::CalloutSide::Right => [to[0].max(tb[2] + gap), knee[1]], + pdfcraft_engine::CalloutSide::Top => [knee[0], to[1].max(tb[3] + gap)], + pdfcraft_engine::CalloutSide::Bottom => [knee[0], to[1].min(tb[1] - gap)], + }; + let mut out = points.to_vec(); + if let Some(k) = out.get_mut(1) { + *k = to; + } + out + } + _ => reshaped(points, i, to, shift), + } +} + +/// A selected comment's points moved as handle `i` is dragged to `to` ([`reshaped`], or +/// [`callout_reshaped`] for a callout). +pub(super) fn reshape_for(a: &Annotation, points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { + match a.text_box.filter(|_| is_callout(a)) { + Some(tb) => callout_reshaped(points, tb, i, to, shift), + None => reshaped(points, i, to, shift), + } +} + /// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools. /// The drawing tools draw from where they are pressed, so a new line starts at the end of the /// one just drawn rather than dragging it. diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 59a9b40ca2..cfaa4967ce 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,8 +1,9 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, on_text, point_handle, reaches_points, reshapable, reshaped, - resizable, resized, snapped_caret, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, frame, handle_pos, held, is_callout, is_markup, on_text, point_handle, reaches_points, + reshapable, reshape_for, resizable, resized, snapped_caret, }; -use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; +use super::paint::{closes_at, drawn_shape, edit_anchor, erase, moves_box_only, new_comment}; +use super::popups::has_popup; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -118,10 +119,10 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op && reshapable(a) && let Some(point) = point_handle(cx, a, p) { - return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Point(point) }, Layer::Handle, egui::CursorIcon::Crosshair)); + return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Point(point) }, Layer::Handle, point_cursor(Some(a), point))); } if resizable(a) - && let Some((x, y)) = HANDLES.into_iter().find(|h| handle_pos(cx.screen_rect(a), *h).distance(p) <= 7.0) + && let Some((x, y)) = HANDLES.into_iter().find(|h| handle_pos(cx.frame_rect(a), *h).distance(p) <= 7.0) { let icon = match (x, y) { (0, _) => egui::CursorIcon::ResizeVertical, @@ -132,7 +133,13 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon)); } } - let a = cx.comment_at(p).filter(|a| bodies && pick(a))?; + // A group subordinate (`/RT /Group`) stands for its primary (ISO 32000-2 §12.5.6.2): a click + // there selects the group. Only a click: a drag still moves what is under the pointer. + let (a, member) = match cx.comment_at(p) { + Some(a) => (a, false), + None => (cx.click_target(p)?, true), + }; + let a = Some(a).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; @@ -140,7 +147,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op let markup = on_text(a); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; // Text markup does not move: a click selects it, a drag across it selects the text. - Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) + Some(Hit { declines_drag: markup || member, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) } /// The layer a comment's body lies on: what a drag moves whole (a note, a shape, a signature, a @@ -151,13 +158,14 @@ fn layer(a: &Annotation) -> Layer { } /// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows -/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it -/// follows the link, [`hit`]). `None` off the page or with comments hidden. +/// whatever tool is active; on a group subordinate (`/RT /Group`), its group's primary, so the menu +/// offers the comment's actions (ISO 32000-2 §12.5.6.2). A highlight over a link is the highlight's +/// here (a left click on it follows the link, [`hit`]). `None` off the page or with comments hidden. pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } - let a = cx.comment_at(p)?; + let a = cx.click_target(p)?; Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } @@ -167,10 +175,18 @@ pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) { view.comments.context_at = Some((cx.page, cx.to_user(p))); } -/// A right-click on comment `index` of this page ([`context_hit`], when it wins there) selects it, -/// and one on anything else selects none. -pub(crate) fn context(cx: &PageCx<'_>, index: Option, view: &mut DocView) { - view.comments.selected = index.map(|i| (cx.page, i)); +/// A right-click at `p` on comment `index` of this page ([`context_hit`], when it wins there) +/// selects it, remembering the group subordinate under `p` when it is that group's primary; one on +/// anything else selects none. +pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { + match index { + // `index` is what the right-click is on ([`context_hit`]): no second look at the point. + Some(i) => match cx.get(i) { + Some(a) => view.comments.pick(cx, p, a), + None => view.comments.select(Some((cx.page, i))), + }, + None => view.comments.select(None), + } } /// Whether the page shows a tip for what lies under the pointer (a comment, a signature's badge) @@ -192,6 +208,8 @@ pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { // tip is that of the one drawn on top, among those with something to say (#3620). let top = cx .comments() + // An open pop-up already shows the comment: no tooltip over it. + .filter(|a| !a.popup.is_some_and(|w| w.open)) .filter(|a| cx.screen_rects(a).any(|r| r.contains(p))) .filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text))) .max_by_key(|(index, _)| *index); @@ -247,7 +265,7 @@ fn select_input( let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio }); // A drawn signature keeps its shape from the corners, as image signatures do. let aspect_ratio = aspect_ratio.or_else(|| { - let r = cx.screen_rect(a); + let r = cx.frame_rect(a); (a.fill_sign && a.subtype == "Ink" && r.height() > 0.0).then(|| r.width() / r.height()) }); cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: (x, y), from: o, aspect_ratio }); @@ -258,7 +276,8 @@ fn select_input( // a selection of its own. let grabbed = (cx.page, a.index); if !cv.is_selected(grabbed) { - cv.select_only(grabbed); + // A press on a group picks its member under the pointer (`CommentView::pick`). + cv.pick(cx, o, a); } else { // The grabbed one takes the handles; the one that had them stays selected. if let Some(previous) = cv.selected.replace(grabbed) @@ -305,7 +324,10 @@ fn select_input( if ui.input(|i| i.modifiers.command || i.modifiers.shift) { cv.toggle((cx.page, a.index)); } else { - cv.select_only((cx.page, a.index)); + match pointer { + Some(p) => cv.pick(cx, p, a), + None => cv.select_only((cx.page, a.index)), + } } cv.reveal = true; consumed = true; @@ -314,12 +336,20 @@ fn select_input( None => {} } } - if resp.double_clicked() + if (resp.double_clicked() || resp.triple_clicked()) && on_page && cx.allowed && let Some((a, _)) = owner { cv.select_only((cx.page, a.index)); + // A double-click opens the comment's pop-up window, as in Acrobat; a text box, whose + // text is the comment itself, is edited in place. + if has_popup(a) { + if !a.popup.is_some_and(|p| p.open) { + view.pending_edit = Some(Edit::SetAnnotationPopup { page: cx.page, index: a.index, rect: None, open: Some(true) }); + } + return true; + } cv.composer = Some(Composer { page: cx.page, at: edit_anchor(cx.info, a), @@ -341,6 +371,12 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: let (a, b) = (cx.to_user(from), cx.to_user(p)); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); if from.distance(p) >= 2.0 { + // A callout dragged by its text box moves the box alone: its arrow point stays. + if let Some(a) = cx.get(index).filter(|a| moves_box_only(cx, a, from)) { + let f = frame(a, cx.rotation()).map(f64::from); + view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect: [f[0] + dx, f[1] + dy, f[2] + dx, f[3] + dy] }); + return; + } // The other selected comments on this page (not text markup or a Replace Text // caret, which are where their text is, nor a locked one, which would refuse the // whole step) move with the dragged one, as one undo step. @@ -362,7 +398,7 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { view.comments.gesture = None; if let Some(a) = cx.get(index) { - let from_rect = cx.screen_rect(a); + let from_rect = cx.frame_rect(a); let shift = ui.input(|i| i.modifiers.shift); let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); @@ -375,8 +411,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: view.comments.gesture = None; let shift = ui.input(|i| i.modifiers.shift); let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); - if moved { - view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + if moved && let Some(a) = cx.get(index) { + view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshape_for(a, &points, point, cx.to_user(p), shift) }); } } _ => {} @@ -448,7 +484,7 @@ impl Gestures<'_> { && let Some(o) = origin { cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; + cv.select(None); } if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() && *page == cx.page @@ -489,7 +525,7 @@ impl Gestures<'_> { && let Some(p) = pointer && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) { - cv.selected = None; + cv.select(None); let now = ui.input(|i| i.time); cv.settle(now, cx.page, tool, &[cx.to_user(p)]); view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); @@ -554,7 +590,7 @@ impl Gestures<'_> { } _ => { cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; + cv.select(None); } } } @@ -586,7 +622,7 @@ impl Gestures<'_> { && let Some(o) = origin { cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); - cv.selected = None; + cv.select(None); } if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() && *page == cx.page @@ -623,7 +659,7 @@ impl Gestures<'_> { && let Some(p) = pointer { cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; + cv.select(None); } true } @@ -645,7 +681,7 @@ impl Gestures<'_> { let snapped = cx.info.pages.get(cx.page).zip(caret_text).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); - cv.selected = None; + cv.select(None); } true } @@ -667,7 +703,7 @@ impl Gestures<'_> { && let Some(o) = origin { cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; + cv.select(None); } if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() && *page == cx.page @@ -697,7 +733,7 @@ impl Gestures<'_> { { let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); - cv.selected = None; + cv.select(None); } true } @@ -732,7 +768,9 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool /// The edit that deletes every selected comment (one undo step for several), and clears the /// selection. Later positions go first, so each deletion leaves the earlier ones where they are. pub(crate) fn delete_selected(cv: &mut CommentView) -> Option { - let mut keys = cv.selection(); + // A group's member picked on the page, with its group the selection alone, goes by itself, as + // Acrobat deletes the member clicked ([`CommentView::target`]). + let mut keys = if cv.also.is_empty() { cv.target().into_iter().collect() } else { cv.selection() }; cv.clear_selection(); keys.sort_unstable_by(|a, b| b.cmp(a)); keys.dedup(); @@ -802,3 +840,17 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); } } + +/// The pointer over point handle `i` of `selected`: a callout's knee moves along its knee line only +/// (see `callout_reshaped`), so it shows that direction; other points move freely. +fn point_cursor(selected: Option<&Annotation>, i: usize) -> egui::CursorIcon { + let knee_side = selected.filter(|a| i == 1 && is_callout(a)).and_then(|a| { + let (tb, knee) = (a.text_box?, a.points.get(1)?); + Some(pdfcraft_engine::callout_side(tb.map(f64::from), [f64::from(knee[0]), f64::from(knee[1])])) + }); + match knee_side { + Some(side) if side.horizontal() => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Crosshair, + } +} diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 9a599b7796..4ee49921e4 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -144,8 +144,9 @@ pub(crate) fn context_menu( } ui.separator(); // The engine's own check, for each selected comment: a locked comment, or one with a - // locked reply, stays. - let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); + // locked reply, stays. On a group subordinate, Delete takes just it, as Acrobat's does. + let (dpage, dindex) = view.comments.target().unwrap_or((page, index)); + let deletes = deletable(dpage, dindex) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() { action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e))); ui.close(); @@ -177,7 +178,7 @@ pub(crate) fn context_menu( if !info.annotations.is_empty() { ui.separator(); if ui.add_enabled(can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { - view.comments.selected = None; + view.comments.select(None); action = Some(CanvasAction::Edit(Box::new(Edit::Flatten { comments: true, fields: false }))); ui.close(); } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index c122bca275..8cdcb03c81 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,4 +1,5 @@ -use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::geometry::{HANDLES, handle_pos, held, is_callout, is_markup, reaches_points, reshapable, reshape_for, resizable}; +use super::popups::paint_connectors; use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -48,10 +49,18 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> && page == cx.page && let Some(a) = cx.get(index) { + // The rest of the group is selected with its primary. + for (m, _) in cx.group_members().into_iter().filter(|(_, p)| p.index == index) { + for r in cx.screen_rects(m) { + painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + } + } if is_markup(&a.subtype) && !a.quads.is_empty() { for r in cx.screen_rects(a) { painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); } + // Selected text markup is linked to its open pop-up too. + paint_connectors(painter, cx, view, pointer); return paint_gesture(painter, cx, view); } if view.signature_drag.contains(page, index) && matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. }) if (p, i) == (page, index)) @@ -59,7 +68,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_gesture(painter, cx, view); } let shift = ui.input(|i| i.modifiers.shift); - if cx.allowed && reaches_points(cx.tool) && reshapable(a) { + let points = cx.allowed && reaches_points(cx.tool) && reshapable(a); + if points && !is_callout(a) { return paint_points(painter, cx, view, a, pointer, shift); } let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0); @@ -70,15 +80,30 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE)); } } + // A callout's arrow point and knee, over its text box's frame. + if points { + return paint_points(painter, cx, view, a, pointer, shift); + } } + paint_connectors(painter, cx, view, pointer); paint_gesture(painter, cx, view); } +/// Whether dragging `a` from `from` moves a callout's text box alone: a press inside the box. Its +/// arrow point stays and its knee comes along, as seen in Acrobat Pro DC (a 2021 Continuous +/// release) and Acrobat Pro 2026.002.22031 (#1152), and "The text box moves around a stationary +/// anchor point" (Acrobat DC Help, 11/28/2019, p. 357). A press on the leader line moves the whole +/// callout (seen in the 2021 release). +pub(super) fn moves_box_only(cx: &PageCx<'_>, a: &Annotation, from: Pos2) -> bool { + is_callout(a) && resizable(a) && cx.frame_rect(a).contains(from) +} + /// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while /// one is dragged (or its edit is on its way), the new shape over the old. -/// The points a selected line, polygon or cloud is drawn with while it changes: an end or corner -/// being dragged, the whole comment being moved by its body, or the edit just released (until the -/// page is drawn again). `None` while it isn't changing. +/// The points a selected line, polygon, cloud or callout is drawn with while it changes: an end, +/// corner, arrow point or knee being dragged (moved by `reshape`: [`reshaped`], or a callout's +/// [`callout_reshaped`]), the whole comment being moved by its body, or the edit just released +/// (until the page is drawn again). `None` while it isn't changing. pub(super) fn changing_points( gesture: Option<&Gesture>, pending: Option<&Edit>, @@ -86,12 +111,12 @@ pub(super) fn changing_points( points: &[[f64; 2]], to_user: impl Fn(Pos2) -> [f64; 2], pointer: Option, - shift: bool, + reshape: impl Fn(&[[f64; 2]], usize, [f64; 2]) -> Vec<[f64; 2]>, ) -> Option> { let shifted = |dx: f64, dy: f64| points.iter().map(|q| [q[0] + dx, q[1] + dy]).collect(); match (gesture, pending) { (Some(Gesture::Reshape { page: p, index: i, point, points: from }), _) if (*p, *i) == (page, index) => { - pointer.map(|at| reshaped(from, *point, to_user(at), shift)) + pointer.map(|at| reshape(from, *point, to_user(at))) } (Some(Gesture::Move { page: p, index: i, from }), _) if (*p, *i) == (page, index) => pointer.map(|at| { let (a, b) = (to_user(*from), to_user(at)); @@ -104,6 +129,10 @@ pub(super) fn changing_points( Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), _ => None, }), + // A callout's text box resized (or moved alone): its points stay, and its line is drawn to + // the box where it is going (see `paint_points`). + (Some(Gesture::Resize { page: p, index: i, .. }), _) if (*p, *i) == (page, index) => pointer.map(|_| points.to_vec()), + (_, Some(Edit::ResizeAnnotation { page: p, index: i, .. })) if (*p, *i) == (page, index) => Some(points.to_vec()), _ => None, } } @@ -111,17 +140,43 @@ pub(super) fn changing_points( fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option, shift: bool) { let cv = &view.comments; let points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); - let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, shift); + let reshape = |from: &[[f64; 2]], i: usize, to: [f64; 2]| reshape_for(a, from, i, to, shift); + let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, reshape); let changed = changing.is_some(); - let points = changing.unwrap_or(points); + let mut points = changing.unwrap_or(points); + // A callout's box moved alone leaves its arrow point where it is. + if let (Some(Gesture::Move { page, index, from }), Some(first)) = (cv.gesture.as_ref(), a.points.first()) + && (*page, *index) == (cx.page, a.index) + && moves_box_only(cx, a, *from) + && let Some(p) = points.first_mut() + { + *p = [f64::from(first[0]), f64::from(first[1])]; + } + // A callout's box moved alone and released: its knee came along, as while it was dragged. + if let (Some(Edit::ResizeAnnotation { page, index, rect }), Some(tb)) = (view.pending_edit.as_ref(), a.text_box.filter(|_| is_callout(a))) + && (*page, *index) == (cx.page, a.index) + { + let tb = tb.map(f64::from); + let same = |a: f64, b: f64| (a - b).abs() < 0.01; + if same(rect[2] - rect[0], tb[2] - tb[0]) && same(rect[3] - rect[1], tb[3] - tb[1]) { + let (dx, dy) = (rect[0] - tb[0], rect[1] - tb[1]); + for q in points.iter_mut().skip(1) { + *q = [q[0] + dx, q[1] + dy]; + } + } + } let screen: Vec = points.iter().map(|q| cx.to_screen(*q)).collect(); if changed { let stroke = Stroke::new(1.5, SELECT_BLUE); - painter.add(if a.subtype == "Polygon" { - egui::Shape::closed_line(screen.clone(), stroke) - } else { - egui::Shape::line(screen.clone(), stroke) - }); + // A callout's line ends where it meets the (possibly moving) text box. + let mut line = screen.clone(); + if is_callout(a) + && let Some(toward) = points.last() + { + let tb = cx.rect_to_user(cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref())); + line.push(cx.to_screen(pdfcraft_engine::callout_attach(tb, *toward))); + } + painter.add(if a.subtype == "Polygon" { egui::Shape::closed_line(line, stroke) } else { egui::Shape::line(line, stroke) }); } for c in screen { painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE)); diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs new file mode 100644 index 0000000000..839cdcd9c7 --- /dev/null +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -0,0 +1,558 @@ +use super::state::PageCx; +use super::{Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, pos2, theme}; + +/// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a +/// status (replies are found by `/IRT` reference, [`Annotation::is_comment`]). +pub(super) fn has_popup(a: &Annotation) -> bool { + a.subtype != "FreeText" && !a.fill_sign && a.is_comment() +} + +/// The connector from a comment to its open pop-up, in the comment's colour, while the pointer is +/// on either or the comment is selected (Acrobat's "lines connecting comment markups to their +/// pop-ups on mouse rollover"). It is a faint wedge: a point on the comment where it is +/// nearest the window, widening to a base on the window's side that faces +/// the comment ([`wedge_base`]). +pub(super) fn paint_connectors(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, pointer: Option) { + let cv = &view.comments; + for ((page, index), window) in &cv.popup_rects { + if *page != cx.page { + continue; + } + let Some(a) = cx.get(*index) else { continue }; + if !a.popup.is_some_and(|p| p.open) { + continue; + } + let rects: Vec = cx.screen_rects(a).collect(); + let on_mark = pointer.is_some_and(|p| rects.iter().any(|r| r.expand(3.0).contains(p))); + if !(on_mark || cv.is_selected((*page, *index)) || cv.popup_hovered == Some((*page, *index))) { + continue; + } + let Some(mark) = rects.first() else { continue }; + let from = nearest_on(*mark, window.center()); + let to = nearest_on(*window, from); + if from.distance(to) < 4.0 { + continue; + } + let color = a + .color + .map_or(Color32::from_rgb(0xDC, 0x26, 0x26), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + let faded = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), WEDGE_ALPHA); + let [b0, b1] = wedge_base(*window, from, to); + let mut wedge = egui::Mesh::default(); + for p in [from, b0, b1] { + wedge.colored_vertex(p, faded); + } + wedge.add_triangle(0, 1, 2); + painter.add(egui::Shape::mesh(wedge)); + } +} + +/// How opaque a connector's wedge is (of 255). +const WEDGE_ALPHA: u8 = 90; + +/// The two ends of a connector's base on `window`: centred on `to` (the point of the window +/// nearest the comment) along the side that faces `from`, as wide as a small part of the window +/// and kept on that side. At a corner, the side is the one the comment lies further beyond. +fn wedge_base(window: Rect, from: Pos2, to: Pos2) -> [Pos2; 2] { + let half = (window.width().min(window.height()) * 0.08).clamp(5.0, 18.0); + // `half` along [lo, hi] around `c`, moved in to stay on the side (a side shorter than the + // base gives all of it). + let spread = |c: f32, lo: f32, hi: f32| { + let h = half.min((hi - lo).max(0.0) / 2.0); + let c = c.clamp(lo + h, hi - h); + (c - h, c + h) + }; + let on_horizontal = (to.y - window.top()).abs() < 0.5 || (to.y - window.bottom()).abs() < 0.5; + let on_vertical = (to.x - window.left()).abs() < 0.5 || (to.x - window.right()).abs() < 0.5; + let beyond_x = (from.x - to.x).abs() / window.width().max(1.0); + let beyond_y = (from.y - to.y).abs() / window.height().max(1.0); + if on_horizontal && !(on_vertical && beyond_x > beyond_y) { + let (a, b) = spread(to.x, window.left(), window.right()); + [pos2(a, to.y), pos2(b, to.y)] + } else { + let (a, b) = spread(to.y, window.top(), window.bottom()); + [pos2(to.x, a), pos2(to.x, b)] + } +} + +/// The point of `r`'s boundary nearest `p` (`p` inside `r` is pushed to the nearer vertical edge). +fn nearest_on(r: Rect, p: Pos2) -> Pos2 { + let x = p.x.clamp(r.left(), r.right()); + let y = p.y.clamp(r.top(), r.bottom()); + if r.left() < x && x < r.right() && r.top() < y && y < r.bottom() { + pos2(if (p.x - r.left()).abs() <= (p.x - r.right()).abs() { r.left() } else { r.right() }, y) + } else { + pos2(x, y) + } +} + +/// A pop-up window's size on screen, in points, whatever the zoom (the readers keep theirs the +/// same as the page zooms), kept within these bounds whatever size the file gives it. +const MIN_WIDTH: f32 = 200.0; +const MAX_WIDTH: f32 = 800.0; +const MIN_HEIGHT: f32 = 120.0; +const MAX_HEIGHT: f32 = 800.0; +/// The side of the grip at a window's lower right corner that resizes it. +const RESIZE_GRIP: f32 = 10.0; +/// How far a reply's panel sits in from the panel around it. +const NEST: i8 = 6; +/// Past this depth a reply's panel stops nesting and sits under the one before it. +const MAX_NESTING: usize = 8; + +/// `replies` (all filed under comment `top`) in thread order, each with its depth: a reply to +/// `top` is 1, and each reply comes right after the one it answers, followed by its own replies. +/// One whose parent isn't among them (a status reply, say) answers `top`. A cycle of `/IRT` +/// references shows each reply once. +fn thread<'a>(top: (usize, usize), replies: &[&'a Annotation]) -> Vec<(&'a Annotation, usize)> { + use std::collections::{HashMap, HashSet}; + let known: HashSet<(usize, usize)> = replies.iter().map(|r| (r.page, r.index)).collect(); + let mut answered: HashMap<(usize, usize), Vec<&'a Annotation>> = HashMap::new(); + for r in replies { + let parent = r.parent.filter(|p| known.contains(p) && *p != (r.page, r.index)).unwrap_or(top); + answered.entry(parent).or_default().push(r); + } + let mut out = Vec::with_capacity(replies.len()); + let mut seen = HashSet::new(); + // Depth first, answers in document order: the stack holds them reversed. + let below = |key: (usize, usize), depth: usize| answered.get(&key).into_iter().flatten().rev().map(move |r| (*r, depth)); + let mut stack: Vec<(&Annotation, usize)> = below(top, 1).collect(); + let mut next = 0; + loop { + while let Some((r, depth)) = stack.pop() { + if !seen.insert((r.page, r.index)) { + continue; + } + out.push((r, depth)); + stack.extend(below((r.page, r.index), depth.saturating_add(1))); + } + // Replies only a cycle reaches: shown from their first, as if they answered `top`. The + // search resumes where it stopped, so the whole of it is one pass. + while next < replies.len() && replies.get(next).is_some_and(|r| seen.contains(&(r.page, r.index))) { + next += 1; + } + match replies.get(next) { + Some(r) => stack.push((*r, 1)), + None => break, + } + } + out +} + +/// A thread as nested panels: each reply ([`thread`] order) and the positions of the replies +/// drawn inside its panel; then the positions of the replies to the comment itself. Nesting stops +/// at [`MAX_NESTING`], so drawing it recurses no deeper than that. +fn nest<'a>(order: &[(&'a Annotation, usize)]) -> (Vec<(&'a Annotation, Vec)>, Vec) { + let mut nodes: Vec<(&Annotation, Vec)> = Vec::with_capacity(order.len()); + let mut roots = Vec::new(); + // The panels open at each depth above the reply being placed. + let mut open: Vec = Vec::new(); + for (i, (r, depth)) in order.iter().enumerate() { + let depth = (*depth).clamp(1, MAX_NESTING); + open.truncate(depth - 1); + match open.last().and_then(|p| nodes.get_mut(*p)) { + Some((_, kids)) => kids.push(i), + None => roots.push(i), + } + nodes.push((r, Vec::new())); + open.push(i); + } + (nodes, roots) +} + +/// A pop-up window's colours: the comment's colour, lightened for the body and the title bar, +/// and ink that reads on them. Fixed, not from the app's theme: the window is the comment's, as +/// it looks on paper. +#[derive(Clone, Copy)] +struct Palette { + edge: Color32, + title: Color32, + title_ink: Color32, + body: Color32, + panel_edge: Color32, + ink: Color32, + faint: Color32, +} + +fn palette(color: Color32) -> Palette { + // `color` mixed into white, `f` of the way. + let mix = |f: f32| { + let c = |v: u8| (255.0 + (f32::from(v) - 255.0) * f).round().clamp(0.0, 255.0) as u8; + Color32::from_rgb(c(color.r()), c(color.g()), c(color.b())) + }; + let title = mix(0.8); + let light = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()) > 150.0; + Palette { + edge: color, + title, + title_ink: if light(title) { Color32::from_gray(20) } else { Color32::WHITE }, + body: mix(0.35), + panel_edge: mix(0.7), + ink: Color32::from_gray(20), + faint: Color32::from_gray(80), + } +} + +/// What a pop-up window's parts share as they draw. +struct Parts<'v> { + allowed: bool, + author: &'v str, + pal: Palette, + cv: &'v mut super::state::CommentView, + edits: &'v mut Vec, +} + +/// One comment's or reply's text in its box, typed in place (saved when the box loses focus); +/// read-only when comments can't be changed or it is locked. +fn text_box(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation, name: &str) { + let key = (a.page, a.index); + if parts.allowed && !a.locked { + let typed = parts.cv.popup_text.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()); + let mut text = typed.unwrap_or_else(|| a.contents.clone().unwrap_or_default()); + let field = ui + .add( + egui::TextEdit::multiline(&mut text) + .desired_rows(1) + .desired_width(f32::INFINITY) + .font(theme::regular(12.5)) + .text_color(parts.pal.ink) + .background_color(Color32::WHITE) + .margin(egui::Margin::symmetric(4, 2)) + .id_salt(("popup-text", a.page, a.index)), + ) + .accessible_name(name); + if field.changed() { + parts.cv.popup_text = Some((a.page, a.index, text.clone())); + } + if field.lost_focus() + && let Some((_, _, s)) = parts.cv.popup_text.take_if(|(p, i, _)| (*p, *i) == key) + && s.trim_end() != a.contents.as_deref().unwrap_or_default() + { + parts.edits.push(Edit::SetAnnotationContents { page: a.page, index: a.index, text: s.trim_end().to_string() }); + } + } else { + egui::Frame::NONE.fill(Color32::WHITE).inner_margin(egui::Margin::symmetric(4, 2)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(egui::RichText::new(a.contents.as_deref().unwrap_or_default()).font(theme::regular(12.5)).color(parts.pal.ink)); + }); + } +} + +/// The row under a comment's or reply's text: a Reply button at its end (the start is kept for +/// more of a reply's controls), and the box to type that reply in once it is pressed (Post, or +/// Ctrl/⌘+Enter, posts it; Enter starts a new line, as in the readers' pop-ups). +fn footer(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation) { + let key = (a.page, a.index); + // Small buttons, as the readers' footers have. + ui.spacing_mut().button_padding = egui::vec2(6.0, 1.0); + let mut opened = false; + if parts.allowed { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = egui::RichText::new(tl!("Reply")).font(theme::regular(11.0)).color(parts.pal.ink); + let button = egui::Button::new(label) + .fill(Color32::WHITE.gamma_multiply(0.6)) + .stroke(egui::Stroke::new(1.0, parts.pal.panel_edge)) + .min_size(egui::vec2(0.0, 16.0)); + if ui.add(button).clicked() { + parts.cv.popup_reply = Some((a.page, a.index, String::new())); + opened = true; + } + }); + }); + } + let Some(mut reply) = parts.cv.popup_reply.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()) else { return }; + let field = ui.add( + egui::TextEdit::multiline(&mut reply) + .desired_rows(1) + .desired_width(f32::INFINITY) + .hint_text(tl!("Add a reply")) + .font(theme::regular(12.5)) + .text_color(parts.pal.ink) + .background_color(Color32::WHITE) + .margin(egui::Margin::symmetric(4, 2)) + .id_salt(("popup-reply", a.page, a.index)), + ); + if opened { + field.request_focus(); + } + if field.changed() { + parts.cv.popup_reply = Some((a.page, a.index, reply.clone())); + } + let enter = super::submit_key(ui, &field, false); + let ok = !reply.trim().is_empty(); + let (post, cancel) = ui + .horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let small = |s: &str| egui::RichText::new(s).font(theme::regular(11.0)); + let post = ui.add_enabled(ok, egui::Button::new(small(tl!("Post"))).min_size(egui::vec2(0.0, 16.0))).clicked(); + let cancel = ui.add(egui::Button::new(small(tl!("Cancel"))).min_size(egui::vec2(0.0, 16.0))).clicked(); + (post, cancel) + }) + .inner + }) + .inner; + if (post || enter) && ok { + parts.cv.popup_reply = None; + parts.edits.push(Edit::ReplyToAnnotation { page: a.page, index: a.index, text: reply.trim().to_string(), author: parts.author.to_string() }); + } else if cancel { + parts.cv.popup_reply = None; + } +} + +/// A reply's panel: who wrote it and when, its text and footer, and the panels of its own +/// replies inside it. +fn reply_panel(ui: &mut egui::Ui, parts: &mut Parts<'_>, nodes: &[(&Annotation, Vec)], at: usize) { + let Some((r, kids)) = nodes.get(at) else { return }; + let pal = parts.pal; + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, pal.panel_edge)).corner_radius(CornerRadius::same(3)).inner_margin(egui::Margin::same(4)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 3.0; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(m) = &r.modified { + ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.faint)); + } + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let who = r.author.as_deref().unwrap_or("?"); + ui.add(egui::Label::new(egui::RichText::new(who).font(theme::semibold(11.5)).color(pal.ink)).truncate()); + }); + }); + }); + text_box(ui, parts, r, r.author.as_deref().unwrap_or("?")); + footer(ui, parts, r); + for kid in kids { + egui::Frame::NONE.inner_margin(egui::Margin { left: NEST, ..Default::default() }).show(ui, |ui| reply_panel(ui, parts, nodes, *kid)); + } + }, + ); +} + +/// The open pop-up windows of the comments on screen, laid out as the readers' are: a title bar +/// with the comment's kind and author, its date and ×; the comment's text, editable in place; its +/// replies as panels, each inside the one it answers; and a Reply button under each, which opens +/// a box to answer it. The window keeps its size on screen as the page zooms, and its text and +/// replies scroll inside it. The title bar drags the window and its lower right corner resizes +/// it, both saved with the comment; × closes it. Returns the edits to apply (one, or a batch when +/// a click both saves typed text and closes). Hidden comments show none. +pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, hidden: bool, allowed: bool) -> Option { + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let mut edits: Vec = Vec::new(); + let mut rects = Vec::new(); + let mut hovered = None; + // The comment each reply files under, as the Comments panel threads them (by reference). + let tops = pdfcraft_render::thread_tops(&info.annotations); + let xforms: Vec<(usize, PageXform)> = view.page_xforms(); + for (page, xf) in xforms { + let shown: Vec = + info.annotations.iter().filter(|a| !hidden && a.page == page && has_popup(a) && a.popup.is_some_and(|p| p.open)).cloned().collect(); + for a in shown { + let Some(p) = a.popup else { continue }; + let key = (page, a.index); + let placed = xf.user_rect(info, page, p.rect); + // Its place follows the page; its size is the pop-up's `/Rect` in points on screen, + // or the size its corner is being dragged to. + // A `/Rect` with numbers that aren't finite gives the least size. + let side = |a: f32, b: f32| Some((b - a).abs()).filter(|v| v.is_finite()).unwrap_or(0.0); + let saved = egui::vec2(side(p.rect[0], p.rect[2]), side(p.rect[1], p.rect[3])); + let clamp = |s: egui::Vec2| egui::vec2(s.x.clamp(MIN_WIDTH, MAX_WIDTH), s.y.clamp(MIN_HEIGHT, MAX_HEIGHT)); + let resizing = view.comments.popup_resize.filter(|(rp, ri, _)| (*rp, *ri) == key).map(|(_, _, size)| size); + let size = clamp(resizing.unwrap_or(saved)); + let dragging = view.comments.popup_drag.filter(|(dp, di, _)| (*dp, *di) == key).map(|(_, _, at)| at); + let pos = dragging.unwrap_or(placed.left_top()); + let id = egui::Id::unique(("comment-popup", view.id.0, page, a.index)); + let color = a + .color + .map_or(Color32::from_rgb(0xFF, 0xD6, 0x5C), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + let pal = palette(color); + // A screen rectangle on this page in user space, to save as the pop-up's `/Rect`. Only + // converts: the pointer and the redaction look are unused. + let to_user = |r: Rect| { + let cx = PageCx { + page, + xf: &xf, + ptr: &PagePointer::default(), + info, + tool: QuickTool::Select, + prefs: &CommentPrefs::default(), + allowed, + hidden, + redact_mark: ([0.0; 3], 1.0), + }; + cx.rect_to_user(r) + }; + let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let frame = egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(3)).fill(pal.body); + let window = frame.stroke(egui::Stroke::new(1.0, pal.edge)).show(ui, |ui| { + ui.set_width(size.x); + // The whole size, however short its thread. + ui.set_min_height(size.y); + ui.spacing_mut().item_spacing.y = 3.0; + // Title bar: the comment's kind and author, its date and ×, on one line; it + // drags the window. + let bar = egui::Frame::NONE.fill(pal.title).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(size.x - 12.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Closing changes the comment's /Open, which needs comment permission. + if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 14.0, false, tl!("Close"))).inner.clicked() { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) }); + } + if let Some(m) = &a.modified { + ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.title_ink)); + } + // The kind and author take the rest of the line, cut short to fit. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let kind = tl!(crate::comments_panel::kind_label(&a)); + let title = match a.author.as_deref() { + Some(who) => format!("{kind} – {who}"), + None => kind.to_string(), + }; + ui.add(egui::Label::new(egui::RichText::new(title).font(theme::semibold(12.0)).color(pal.title_ink)).truncate()); + }); + }) + }); + }); + // The bar drags the window, except where the × is: a region registered after + // the button would take its clicks. + let bar_rect = bar.response.rect; + let grip = ui.interact( + Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 24.0, bar_rect.bottom())), + id.with("grip"), + egui::Sense::drag(), + ); + if grip.hovered() || grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + if grip.drag_started_by(egui::PointerButton::Primary) { + view.comments.popup_drag = Some((page, a.index, pos)); + } + if grip.dragged() + && let Some((_, _, at)) = view.comments.popup_drag.as_mut() + { + *at += grip.drag_delta(); + } + if grip.drag_stopped() + && let Some((_, _, at)) = view.comments.popup_drag.take() + && allowed + && at.distance(placed.left_top()) >= 2.0 + { + // The same size on the page, at the new place. Only converts the + // rectangle to user space: the pointer and the redaction look are unused. + let rect = to_user(Rect::from_min_size(at, placed.size())); + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + // Replies, as the thread reads: text replies only, filed under this + // comment by `/IRT` reference as in the Comments panel, each under the + // one it answers. A review status or a checkmark (`/State`) is a reply too, + // but not one to read here, and a member of the comment's group (`/RT + // /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a reply. + let replies: Vec<&Annotation> = info + .annotations + .iter() + .filter(|r| { + r.state.is_none() + && !r.grouped + && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) + }) + .collect(); + // The comment's text. Replace Text as `main` writes it (#5688) keeps the + // replacement on the caret grouped under an empty strikeout: the window shows + // and edits the caret's text, the proposal's. Locking the comment locks that + // text too. + let mut holder = info + .annotations + .iter() + .find(|r| { + r.grouped + && r.parent == Some(key) + && a.contents.as_deref().unwrap_or("").is_empty() + && r.contents.as_deref().is_some_and(|c| !c.is_empty()) + }) + .cloned() + .unwrap_or_else(|| a.clone()); + holder.locked |= a.locked; + let order = thread(key, &replies); + let (nodes, roots) = nest(&order); + let mut parts = Parts { allowed, author: &prefs.author, pal, cv: &mut view.comments, edits: &mut edits }; + // The body keeps the window's height: the text and replies scroll in what the + // title bar leaves, with a bar that shows whenever there is more (egui's show + // only near the pointer), in the ink's colour. + egui::Frame::NONE.inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_width(size.x - 8.0); + ui.spacing_mut().scroll = egui::style::ScrollStyle { + dormant_handle_opacity: 0.4, + dormant_background_opacity: 0.1, + foreground_color: true, + ..egui::style::ScrollStyle::solid() + }; + ui.visuals_mut().override_text_color = Some(pal.ink); + let body_height = (size.y - bar_rect.height() - 8.0).max(24.0); + let scroll = + egui::ScrollArea::vertical().id_salt(("popup-body", page, a.index)).max_height(body_height).auto_shrink([false, false]); + scroll.show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 3.0; + text_box(ui, &mut parts, &holder, tl!("Add a comment")); + footer(ui, &mut parts, &a); + for root in &roots { + reply_panel(ui, &mut parts, &nodes, *root); + } + }); + }); + }); + // The lower right corner resizes the window; its new size is saved with the + // comment, its upper left corner where it was, when the drag ends. + let corner = window.response.rect.right_bottom(); + let grip = + ui.interact(Rect::from_min_max(corner - egui::vec2(RESIZE_GRIP, RESIZE_GRIP), corner), id.with("resize"), egui::Sense::drag()); + if grip.hovered() || grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + // Two short diagonal strokes mark the grip. + for inset in [3.0, 7.0] { + let stroke = egui::Stroke::new(1.0, pal.faint); + ui.painter().line_segment([corner - egui::vec2(3.0, inset), corner - egui::vec2(inset, 3.0)], stroke); + } + if grip.drag_started_by(egui::PointerButton::Primary) { + view.comments.popup_resize = Some((page, a.index, size)); + } + if grip.dragged() + && let Some((_, _, s)) = view.comments.popup_resize.as_mut() + { + *s += grip.drag_delta(); + } + if grip.drag_stopped() + && let Some((_, _, s)) = view.comments.popup_resize.take() + && allowed + { + let s = clamp(s); + // A press on the grip that ends where it began changes nothing. + // The window's size in points is the `/Rect`'s, its upper left corner kept + // where it is on screen: the rectangle of that size on the page there. A page + // turned a quarter (its `/Rotate` and the view's) shows the `/Rect`'s width + // down the screen. + let zoom = xf.zoom(); + if (s - clamp(saved)).length() >= 2.0 && zoom.is_finite() && zoom > 0.0 { + let turn = info.pages.get(page).map_or(0, |pg| u32::from(pg.rotation)) + u32::from(xf.rot); + let on_page = if turn % 180 == 90 { egui::vec2(s.y, s.x) } else { s } * zoom; + let rect = to_user(Rect::from_min_size(placed.left_top(), on_page)); + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + } + }); + let shown_at = area.response.rect; + if pointer.is_some_and(|p| shown_at.contains(p)) { + hovered = Some(key); + } + rects.push((key, shown_at)); + } + } + view.comments.popup_rects = rects; + view.comments.popup_hovered = hovered; + match edits.len() { + 0 => None, + 1 => edits.pop(), + _ => Some(Edit::Batch { label: "Edit comment".into(), edits }), + } +} diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index b686c402b1..62d6e9db36 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use super::geometry::{corner_ratio, is_markup, resized}; +use super::geometry::{corner_ratio, frame, is_markup, resized}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect}; /// The user's login name, which Acrobat uses as the default comment author. @@ -146,6 +146,10 @@ pub struct CommentView { pub notice: Option<&'static str>, /// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete). pub finish_request: bool, + /// The group subordinate a click on the page picked, with the selection (its group's primary) + /// it was picked under: Delete acts on it while that group stays selected, as Acrobat deletes + /// the member clicked ([`Self::target`]). + pub(crate) member: Option<((usize, usize), (usize, usize))>, pub gesture: Option, /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). @@ -157,6 +161,19 @@ pub struct CommentView { pub editing: Option<(usize, usize, String)>, /// The panel should scroll the selected card into view. pub reveal: bool, + /// A pop-up window's title bar being dragged: the comment, and where the window's top-left + /// corner is now on screen. + pub popup_drag: Option<(usize, usize, Pos2)>, + /// A pop-up window's corner being dragged: the comment, and the window's size on screen now. + pub popup_resize: Option<(usize, usize, egui::Vec2)>, + /// Text being typed in a pop-up window: (page, index, text). + pub popup_text: Option<(usize, usize, String)>, + /// A reply being typed in a pop-up window: the comment or reply it answers (page, index), and its text. + pub popup_reply: Option<(usize, usize, String)>, + /// Where each open pop-up was drawn last frame, for the connector lines and hit-testing. + pub popup_rects: Vec<((usize, usize), Rect)>, + /// The pop-up the pointer was over last frame. + pub popup_hovered: Option<(usize, usize)>, /// The "Add a comment" box at the top of the Comments panel. pub add_box: String, /// The Comments panel's search query (`None`: search closed). @@ -220,6 +237,7 @@ impl CommentView { pub fn select_only(&mut self, key: (usize, usize)) { self.selected = Some(key); self.also.clear(); + self.member = None; } /// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to @@ -236,6 +254,7 @@ impl CommentView { /// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the /// comment clicked last. pub fn toggle(&mut self, key: (usize, usize)) { + self.member = None; if self.selected == Some(key) { self.selected = self.also.pop(); } else if let Some(at) = self.also.iter().position(|k| *k == key) { @@ -251,6 +270,34 @@ impl CommentView { pub fn clear_selection(&mut self) { self.selected = None; self.also.clear(); + self.member = None; + } + + /// What Delete acts on: the group member clicked, while its group is still the selection, + /// else the selected comment. + pub(crate) fn target(&self) -> Option<(usize, usize)> { + match self.member { + Some((member, group)) if Some(group) == self.selected => Some(member), + _ => self.selected, + } + } + + /// Select `key` alone, or nothing, from anywhere but a press on the page ([`Self::pick`]): the + /// group member a press picked goes, so Delete and the arrow keys act on what is selected now, + /// not on a member remembered from before (after the Comments panel, a deletion or undo). + pub(crate) fn select(&mut self, key: Option<(usize, usize)>) { + match key { + Some(key) => self.select_only(key), + None => self.clear_selection(), + } + } + + /// Select `a`, the comment a press at `p` on this page selects ([`PageCx::click_target`]), + /// remembering the group subordinate under `p` when `a` is its group's primary + /// ([`PageCx::picked`]). The only place that sets [`Self::member`]. + pub(crate) fn pick(&mut self, cx: &PageCx<'_>, p: Pos2, a: &Annotation) { + self.select_only((cx.page, a.index)); + self.member = cx.picked(p, self.selected); } /// Takes back the last point of a polygon, cloud or connected lines being drawn; the first @@ -293,20 +340,64 @@ impl CommentView { pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect(); + self.member = None; self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); if self.editing.is_none() { self.editor_focus = None; } + // What is typed in a pop-up, and a window being dragged or resized, follow theirs too. + let moved = |k: (usize, usize)| after_delete(before, after, gone, k); + self.popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + self.popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + self.popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at))); + self.popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size))); } /// Undo or Redo just ran. Either may have put annotations back or taken them away, on any /// page, so a (page, index) held here may now name another one. The selection and the inline /// editor close, its unsaved text with it, rather than Save writing that text over whatever - /// took its place. + /// took its place; so do the pop-ups' unsaved text and drags. pub(crate) fn history_stepped(&mut self) { self.clear_selection(); self.editing = None; self.editor_focus = None; + self.popup_text = None; + self.popup_reply = None; + self.popup_drag = None; + self.popup_resize = None; + } + + /// The comments the pop-ups hold unsaved text, a reply or a drag for. + pub(crate) fn popup_keys(&self) -> Vec<(usize, usize)> { + let text = self.popup_text.as_ref().map(|(p, i, _)| (*p, *i)); + let reply = self.popup_reply.as_ref().map(|(p, i, _)| (*p, *i)); + let drag = self.popup_drag.map(|(p, i, _)| (p, i)); + let resize = self.popup_resize.map(|(p, i, _)| (p, i)); + [text, reply, drag, resize].into_iter().flatten().collect() + } + + /// An edit other than a deletion just ran: `before` are the comments at [`Self::popup_keys`] + /// until then, `after` the document's annotations now. When pages moved, came or went, a pop-up's (page, index) + /// may now name another comment: what was typed in that pop-up and not saved, and a window + /// being dragged or resized, are dropped rather than written to whatever took its place. + pub(crate) fn popups_after_edit(&mut self, before: &[Annotation], after: &[Annotation]) { + // The same comment: its kind, author and `/NM`, at that place before and after. + let who = |list: &[Annotation], k: (usize, usize)| { + list.iter().find(|a| (a.page, a.index) == k).map(|a| (a.subtype.clone(), a.author.clone(), a.name.clone())) + }; + let kept = |p: usize, i: usize| who(before, (p, i)).is_some_and(|w| who(after, (p, i)) == Some(w)); + if self.popup_text.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) { + self.popup_text = None; + } + if self.popup_reply.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) { + self.popup_reply = None; + } + if self.popup_drag.is_some_and(|(p, i, _)| !kept(p, i)) { + self.popup_drag = None; + } + if self.popup_resize.is_some_and(|(p, i, _)| !kept(p, i)) { + self.popup_resize = None; + } } } @@ -411,6 +502,11 @@ impl PageCx<'_> { self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation())) } + /// Where a comment's selection frame and resize handles go on screen ([`frame`]). + pub(super) fn frame_rect(&self, a: &Annotation) -> Rect { + self.xf.user_rect(self.info, self.page, frame(a, self.rotation())) + } + /// Top-level comments on this page, in paint order. pub(super) fn comments(&self) -> impl Iterator { self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.is_comment() && a.subtype != "Popup") @@ -473,6 +569,41 @@ impl PageCx<'_> { self.comments().find(|a| a.index == index) } + /// The group subordinates (`/RT /Group`, ISO 32000-2 §12.5.6.2) on this page, each with the + /// comment on this page that is its group's primary. + pub(super) fn group_members(&self) -> Vec<(&Annotation, &Annotation)> { + let all = &self.info.annotations; + // Painted every frame a comment is selected: most documents have no group to look up. + if self.hidden || !all.iter().any(|a| a.is_group_member() && a.page == self.page) { + return Vec::new(); + } + let primaries = pdfcraft_render::group_primaries(all); + all.iter() + .zip(primaries) + .filter(|(a, _)| a.is_group_member() && a.page == self.page) + .filter_map(|(a, p)| Some((a, all.get(p?).filter(|p| p.page == self.page && p.is_comment())?))) + .collect() + } + + /// The comment a click at `p` selects: the topmost one under it or, on a group subordinate, + /// the group's primary, which stands for the group. + pub(super) fn click_target(&self, p: Pos2) -> Option<&Annotation> { + self.comment_at(p).or_else(|| self.member_at(p).map(|(_, p)| p)) + } + + /// The group subordinate under `p`, where no comment is, with its group's primary. + fn member_at(&self, p: Pos2) -> Option<(&Annotation, &Annotation)> { + self.group_members().into_iter().rev().find(|(m, _)| self.screen_rects(m).any(|r| r.expand(3.0).contains(p))) + } + + /// The group member a click at `p` picked when it selects `selected`, its group's primary + /// ([`CommentView::member`]): the subordinate under the pointer. + pub(super) fn picked(&self, p: Pos2, selected: Option<(usize, usize)>) -> Option<((usize, usize), (usize, usize))> { + let (m, primary) = self.comment_at(p).is_none().then(|| self.member_at(p)).flatten()?; + let group = (primary.page, primary.index); + (Some(group) == selected).then_some(((self.page, m.index), group)) + } + pub(crate) fn rect_to_user(&self, r: Rect) -> [f64; 4] { let (a, b) = (self.to_user(r.left_top()), self.to_user(r.right_bottom())); [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] @@ -488,7 +619,7 @@ impl PageCx<'_> { shift: bool, pending: Option<&Edit>, ) -> Rect { - let r = self.screen_rect(a); + let r = self.frame_rect(a); if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending && (*page, *index) == (self.page, a.index) { @@ -497,8 +628,8 @@ impl PageCx<'_> { if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending && (*page, *index) == (self.page, a.index) { - let r = a.shown_rect(self.rotation()); - return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]); + let f = frame(a, self.rotation()); + return self.xf.user_rect(self.info, self.page, [f[0] + *dx as f32, f[1] + *dy as f32, f[2] + *dx as f32, f[3] + *dy as f32]); } match (gesture, pointer) { (Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from), diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 6e4623f818..6c3e4ea163 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -1,5 +1,11 @@ +use super::geometry::reshaped; use super::*; +/// [`reshaped`] without Shift, as `changing_points` takes it. +fn straight(points: &[[f64; 2]], i: usize, to: [f64; 2]) -> Vec<[f64; 2]> { + reshaped(points, i, to, false) +} + #[test] fn freehand_highlight_and_draw_accept_widths_up_to_100() { assert_eq!(CommentTool::FreehandHighlight.group(), CommentTool::Highlight.group()); @@ -25,21 +31,35 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)]; let moving = Gesture::Move { page: 0, index: 3, from: pos2(20.0, 10.0) }; let at = Some(pos2(25.0, 30.0)); - assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); - assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, false), None, "another comment's move"); + assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, straight), None, "another comment's move"); let released = Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }; - assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]])); let dragging = Gesture::Reshape { page: 0, index: 3, point: 1, points: line.to_vec() }; let to = Some(pos2(60.0, 10.0)); - assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); - assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); + assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, straight), Some(vec![[10.0, 10.0], [60.0, 10.0]])); + assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, straight), None, "not changing"); // Released with the rest of the selection: its own move in the batch. let together = Edit::Batch { label: "Move comments".into(), edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }], }; - assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); - assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch"); + assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, straight), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, straight), None, "not in the batch"); +} + +/// Review on #4094: a callout's text box being resized, and the resize just released, draw its +/// leader line too (to the box where it is going): its points are changing, though they stay. +#[test] +fn a_callout_being_resized_draws_its_leader_line() { + let lead = [[10.0, 10.0], [40.0, 30.0]]; + let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)]; + let resizing = Gesture::Resize { page: 0, index: 3, handle: (1, 1), from: pos2(80.0, 60.0), aspect_ratio: None }; + let at = Some(pos2(95.0, 70.0)); + assert_eq!(changing_points(Some(&resizing), None, (0, 3), &lead, to_user, at, straight), Some(lead.to_vec())); + assert_eq!(changing_points(Some(&resizing), None, (0, 4), &lead, to_user, at, straight), None, "another comment's resize"); + let released = Edit::ResizeAnnotation { page: 0, index: 3, rect: [50.0, 20.0, 110.0, 70.0] }; + assert_eq!(changing_points(None, Some(&released), (0, 3), &lead, to_user, None, straight), Some(lead.to_vec())); } /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit @@ -66,6 +86,8 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() { fill_sign: false, no_rotate, points: Vec::new(), + text_box: None, + popup: None, }; let page = PageInfo { width: 140.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation: 90 }; let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)), rot: 0, pw: 140.0, ph: 200.0 }; @@ -111,6 +133,8 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz fill_sign: false, no_rotate: false, points: Vec::new(), + text_box: None, + popup: None, } } @@ -200,6 +224,8 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat fill_sign, no_rotate: false, points: Vec::new(), + text_box: None, + popup: None, } } @@ -404,6 +430,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men intent: None, fill_sign: false, points: Vec::new(), + text_box: None, + popup: None, no_rotate: false, }], ..Default::default() @@ -526,6 +554,40 @@ fn text_boxes_fit_their_text() { assert!(long[3] - long[1] > 50.0); } +/// Review on #4358: a Replace Text group is selected by its caret, the group's primary (ISO +/// 32000-2 §12.5.6.2), and Make Current Properties Default on it updates the Replace Text tool. A +/// caret of its own (Insert Text) still updates the Insert Text tool. +#[test] +fn a_replace_text_group_makes_replace_text_defaults() { + let annot = |index: usize, subtype: &str, intent: Option<&str>, parent: Option<(usize, usize)>| Annotation { + page: 0, + subtype: subtype.into(), + author: None, + contents: None, + modified: None, + name: None, + in_reply_to: None, + parent, + grouped: parent.is_some(), + rect: [10.0, 10.0, 20.0, 20.0], + color: None, + index, + state: None, + quads: Vec::new(), + locked: false, + intent: intent.map(Into::into), + fill_sign: false, + no_rotate: false, + points: Vec::new(), + text_box: None, + popup: None, + }; + let all = + vec![annot(0, "Caret", Some("Replace"), None), annot(1, "StrikeOut", Some("StrikeOutTextEdit"), Some((0, 0))), annot(2, "Caret", None, None)]; + assert_eq!(tool_for_default(&all, &all[0], None), Some(CommentTool::ReplaceText)); + assert_eq!(tool_for_default(&all, &all[2], None), Some(CommentTool::Caret)); +} + /// The hover outline goes round the comment a press under the pointer would take (the top hit /// there): not a highlight under a form field, which takes the press. #[test] diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 3d26d77394..6bc393ab3f 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -27,8 +27,15 @@ pub struct CustomStamp { pub page: usize, #[serde(with = "b64")] pub data: Arc>, + /// A line under the picture, filled in when the stamp is placed (#1741): `{name}`, + /// `{date}` and `{time}` become the author and now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, } +/// The line a new dynamic custom stamp offers: what the built-in dynamic stamps write. +pub const DEFAULT_LINE: &str = "By {name} at {time}, {date}"; + mod b64 { use std::sync::Arc; @@ -72,8 +79,16 @@ pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, b } /// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at -/// its natural size (the engine sizes it). `true` once one is placed. -pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool { +/// its natural size (the engine sizes it), with its `line` filled in now (#1741). `true` once one +/// is placed. +pub(crate) fn place_custom( + cx: PageToolContext<'_>, + stamp: &CustomStamp, + line: &Option, + author: &str, + free: bool, + view: &mut DocView, +) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); @@ -89,6 +104,7 @@ pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: name: stamp.name.clone(), file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page }, author: author.to_owned(), + line: line.clone(), }); true } @@ -100,6 +116,9 @@ pub struct StampDraft { pub data: Arc>, pub category: String, pub name: String, + /// Add a line filled in when the stamp is placed, and its template. + pub dynamic: bool, + pub line: String, } pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -133,8 +152,15 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.add(crate::widgets::line(&mut d.name).desired_width(200.0)).labelled_by(l.id); ui.end_row(); }); + ui.add_space(8.0); + // A dynamic stamp (#1741): a line under the picture, filled in when it is placed. + ui.add(widgets::checkbox(&mut d.dynamic, tl!("Add a line filled in when the stamp is placed"))); + if d.dynamic { + ui.add(crate::widgets::line(&mut d.line).desired_width(320.0).char_limit(pdfcraft_engine::MAX_STAMP_LINE)); + ui.label(egui::RichText::new(tl!("{name}, {date} and {time} become your name, the date and the time.")).small().color(t.text_muted)); + } ui.add_space(12.0); - let ok = !d.category.trim().is_empty() && !d.name.trim().is_empty(); + let ok = !d.category.trim().is_empty() && !d.name.trim().is_empty() && (!d.dynamic || !d.line.trim().is_empty()); let (mut save, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { @@ -215,14 +241,22 @@ impl PdfCraftApp { } let name = file.rsplit_once('.').map_or(file.as_str(), |(s, _)| s).to_string(); let category = self.custom_stamps.last().map(|s| s.category.clone()).unwrap_or_else(|| "My stamps".into()); - self.stamp_draft = crate::stamps_ui::StampDraft { file, data: Arc::new(bytes), category, name }; + self.stamp_draft = crate::stamps_ui::StampDraft { file, data: Arc::new(bytes), category, name, dynamic: false, line: DEFAULT_LINE.into() }; self.dialog = Some(Dialog::CreateStamp); } /// Save the dialog's stamp in the library and choose it. pub(crate) fn save_custom_stamp(&mut self) { let d = std::mem::take(&mut self.stamp_draft); - self.custom_stamps.push(CustomStamp { category: d.category.trim().into(), name: d.name.trim().into(), file: d.file, page: 0, data: d.data }); + let line = d.dynamic.then(|| d.line.trim().to_string()).filter(|l| !l.is_empty()); + self.custom_stamps.push(CustomStamp { + category: d.category.trim().into(), + name: d.name.trim().into(), + file: d.file, + page: 0, + data: d.data, + line, + }); self.quick_tool = QuickTool::CustomStamp(self.custom_stamps.len() - 1); } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 502c1cc34e..3150e0fde2 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2240,6 +2240,9 @@ fn polygons_clouds_connected_lines_callouts_and_inserted_text() { let callout = comments(&h).into_iter().find(|a| a.subtype == "FreeText").expect("a callout"); assert_eq!(callout.contents.as_deref(), Some("Why?")); assert!(callout.rect[0] <= 60.0 && callout.rect[1] <= 160.0, "holds the leader: {:?}", callout.rect); + // Its knee line is Acrobat's: level, 12 pt out from the middle of the box's left side. + let (tb, knee) = (callout.text_box.expect("a text box"), callout.points[1]); + assert!((knee[0] - (tb[0] - 12.0)).abs() < 0.01 && (knee[1] - (tb[1] + tb[3]) / 2.0).abs() < 0.01, "{tb:?} {knee:?}"); // Insert text at a click. h.state_mut().set_option("quick", "caret").unwrap(); @@ -2730,7 +2733,9 @@ fn replacing_text_strikes_it_and_adds_a_caret() { let caret = c.iter().find(|a| a.subtype == "Caret").expect("a caret"); assert_eq!(caret.contents.as_deref(), Some("slow red")); assert_eq!((strike.color, caret.color), (Some([0.1, 0.6, 0.2]), Some([0.1, 0.6, 0.2])), "one style for the proposal"); - assert_eq!(strike.in_reply_to, caret.name, "the strikeout is grouped under the caret, which holds the text"); + // The caret is the group's primary, the strikeout its `/RT /Group` subordinate (ISO 32000-2 §12.5.6.2). + assert_eq!(strike.in_reply_to, caret.name, "the strikeout is grouped with the caret"); + assert!(strike.is_group_member() && !caret.grouped && !caret.is_reply()); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text")); assert_eq!(h.state().quick_tool, QuickTool::Select); // Both members are the Replace text tool's, not Strikethrough's or Insert text's (#3947), so @@ -2741,7 +2746,13 @@ fn replacing_text_strikes_it_and_adds_a_caret() { // nothing is named Strikethrough. h.state_mut().set_option("panel", "comments").unwrap(); h.run_steps(3); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "slow red").is_some(), "the panel shows the replacement"); + // One card, the caret's, showing the replacement text; sorted by type, its group is named + // Replace text (not Insert text, and no Strikethrough). + assert_eq!(h.query_all_by_label("slow red").count(), 1, "one comment with the replacement text"); + h.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Type; + h.run_steps(3); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Replace text").is_some(), "the panel names the comment Replace text"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Insert text").is_none(), "not Insert text"); assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Strikethrough").is_none(), "not Strikethrough"); } @@ -3556,6 +3567,806 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.run_steps(3); } +// Contributor-original PDF: a Square, a Circle grouped with it (`/RT /Group`) that keeps its own +// author, text and colour, an ordinary reply and a reply to the Circle. +const GROUP_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj +6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-sub) /T (Bob) /Contents (Subordinate) /C [0 0 1] /IRT 5 0 R /RT /Group >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [120 20 140 40] /NM (reply) /T (Carol) /Contents (Plain reply) /IRT 5 0 R >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [120 50 140 70] /NM (sub-reply) /T (Dan) /Contents (Reply to the subordinate) /IRT 6 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// ISO 32000-2 §12.5.6.2: a group is one comment, its primary's. The panel lists the Square +/// with both replies (the one to the Circle joins its thread) and not the Circle's own author +/// or text; clicking the Circle on the page selects the group, which is the Square's card. +#[test] +fn a_group_is_one_comment_in_the_panel_and_on_the_page() { + let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + h.get_by_label("Primary"); + h.get_by_label("Plain reply"); + h.get_by_label("Reply to the subordinate"); + assert_eq!(h.query_all_by_label("Subordinate").count(), 0, "the subordinate's own text is ignored"); + assert_eq!(h.query_all_by_label("Bob").count(), 0, "the subordinate's own author is ignored"); + let p = at(&h, 220.0, 150.0); + click(&mut h, p); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "clicking the Circle selects its group"); + h.state_mut().views[0].comments.selected = None; + let p = at(&h, 60.0, 50.0); + click(&mut h, p); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); + // #4358 review: a right-click on the Circle selects the group too, so the menu is the + // comment's, and a double-click there opens the group's pop-up window, which is the Square's + // (comment.popups). + h.state_mut().views[0].comments.selected = None; + let p = at(&h, 220.0, 150.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a right-click on the Circle selects its group"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.state_mut().views[0].comments.selected = None; + crate::support::input::double_click(&mut h, p); + h.run_steps(4); + let c = comments(&h); + let primary = c.iter().find(|a| a.contents.as_deref() == Some("Primary")).expect("the primary"); + assert_eq!(primary.popup.map(|w| w.open), Some(true), "a double-click on the Circle opens its group's window"); + assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window"); +} + +/// As in Acrobat desktop, Delete deletes the group member clicked and keeps the rest of its +/// group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act on the +/// entire group. The Circle goes with its reply and the Square stays; the Square goes with its +/// reply and the Circle stays, a comment of its own. +#[test] +fn delete_deletes_the_group_member_clicked() { + let shapes = |h: &Harness<'static, PdfCraftApp>| -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = comments(h).iter().filter(|a| a.subtype != "Text").map(|a| (a.subtype.clone(), a.is_comment())).collect(); + out.sort(); + out + }; + let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |_| {}); + click_pt(&mut h, (220.0, 150.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the group is selected"); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(shapes(&h), [("Square".to_string(), true)], "the Circle goes, the Square stays"); + let texts: Vec = comments(&h).iter().filter_map(|a| a.contents.clone()).collect(); + assert!(!texts.iter().any(|c| c == "Reply to the subordinate") && texts.iter().any(|c| c == "Plain reply"), "{texts:?}"); + h.state_mut().undo(); + h.run_steps(3); + + click_pt(&mut h, (60.0, 50.0)); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(shapes(&h), [("Circle".to_string(), true)], "the Square goes, the Circle stays as a comment of its own"); +} + +/// CodeRabbit on #4358: the member a click on the page picked is forgotten once the selection is +/// made anywhere else. After clicking the Circle, choosing the group's card in the Comments panel +/// selects the comment the card shows, the Square, so Delete deletes the Square, not the Circle +/// the page click remembered. +#[test] +fn choosing_the_groups_card_makes_delete_act_on_the_card_not_the_member_clicked() { + let shapes = |h: &Harness<'static, PdfCraftApp>| -> Vec { + let mut out: Vec = comments(h).iter().filter(|a| a.subtype != "Text").map(|a| a.subtype.clone()).collect(); + out.sort(); + out + }; + let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + click_pt(&mut h, (220.0, 150.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the Circle's click selects the group"); + // The selected card's header row, with its author. + h.get_by_label("Alice").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the card selects the same group"); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert_eq!(shapes(&h), ["Circle".to_string()], "the card's comment, the Square, goes; the Circle stays"); +} + +/// One 300×200 page with a callout: text box [150 100 250 140], knee (120, 120) on the box's +/// left, arrow point (60, 60). +const CALLOUT_FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [50 50 250 140] /RD [100 50 0 0] + /CL [60 60 120 120 150 120] /LE /OpenArrow /DA (/Helv 10 Tf 0 g) /Contents (Note) /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn callout(h: &Harness<'static, PdfCraftApp>) -> Annotation { + comments(h).into_iter().find(|a| a.subtype == "FreeText").expect("the callout") +} + +fn near_pt(p: [f32; 2], x: f32, y: f32) -> bool { + (p[0] - x).abs() < 1.5 && (p[1] - y).abs() < 1.5 +} + +fn last_edit(h: &Harness<'static, PdfCraftApp>) -> Option { + h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned) +} + +/// #1152: a selected callout's three parts change on their own, as in Acrobat ("Callout text +/// boxes have three parts: a text box, a knee line, and an end-point line. You can resize each +/// part by dragging a handle. The knee line can be resized in one direction only", Acrobat DC +/// Help, 11/28/2019, p. 357). +#[test] +fn a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own() { + let mut h = harness_with_bytes(CALLOUT_FIXTURE.to_vec(), |app| app.set_option("quick", "select").unwrap()); + let c = callout(&h); + assert_eq!((c.points.clone(), c.text_box), (vec![[60.0, 60.0], [120.0, 120.0]], Some([150.0, 100.0, 250.0, 140.0]))); + click_pt(&mut h, (200.0, 120.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, c.index))); + + // The knee line is level, so the knee moves sideways only, and not into the box. + drag_pt(&mut h, (120.0, 120.0), (95.0, 160.0)); + let c = callout(&h); + assert!(near_pt(c.points[1], 95.0, 120.0) && near_pt(c.points[0], 60.0, 60.0), "{:?}", c.points); + assert_eq!(last_edit(&h).as_deref(), Some("Reshape comment")); + drag_pt(&mut h, (95.0, 120.0), (200.0, 120.0)); + assert!(near_pt(callout(&h).points[1], 138.0, 120.0), "stopped 12 pt short of the box: {:?}", callout(&h).points); + drag_pt(&mut h, (138.0, 120.0), (110.0, 120.0)); + + // The arrow point moves alone. + drag_pt(&mut h, (60.0, 60.0), (40.0, 30.0)); + let c = callout(&h); + assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points); + assert_eq!(c.text_box, Some([150.0, 100.0, 250.0, 140.0])); + + // The text box resizes from its own handles (not /Rect's); its left side, where the line meets + // it, stays, so the line doesn't change. + drag_pt(&mut h, (250.0, 100.0), (270.0, 80.0)); + let c = callout(&h); + let tb = c.text_box.expect("a text box"); + assert!((tb[0] - 150.0).abs() < 1.5 && (tb[1] - 80.0).abs() < 1.5 && (tb[2] - 270.0).abs() < 1.5 && (tb[3] - 140.0).abs() < 1.5, "{tb:?}"); + assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points); + assert_eq!(last_edit(&h).as_deref(), Some("Resize comment")); + + // Dragged from inside, the box moves alone: the arrow stays, the knee comes along. + drag_pt(&mut h, (200.0, 120.0), (220.0, 130.0)); + let c = callout(&h); + let tb = c.text_box.expect("a text box"); + assert!((tb[0] - 170.0).abs() < 1.5 && (tb[3] - 150.0).abs() < 1.5, "{tb:?}"); + assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 130.0, 130.0), "{:?}", c.points); + + // Dragged by its leader line, the whole callout moves. + drag_pt(&mut h, (85.0, 75.0), (95.0, 75.0)); + let c = callout(&h); + assert!(near_pt(c.points[0], 50.0, 30.0) && near_pt(c.points[1], 140.0, 130.0), "{:?}", c.points); + assert_eq!(last_edit(&h).as_deref(), Some("Move comment")); + + // Each drag was one undo step. + h.state_mut().undo(); + h.run_steps(2); + assert!(near_pt(callout(&h).points[0], 40.0, 30.0)); +} + +/// #2137 review: a pop-up is edited under comment permission, not change permission: in a +/// document whose security allows comments but not changes, an open pop-up still takes typing +/// and closes. +#[test] +fn a_pop_up_is_editable_where_comments_are_allowed_but_changes_are_not() { + let plain = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /Popup 5 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 60 260 160] /Open true >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(plain.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + // Everything but bit 4, modifying the document. + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = harness(move |app| { + app.open_bytes("restricted.pdf", None, bytes).expect("opens"); + // Only the restricted document, so it is the first view. + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + field(&h, "Add a comment"); + let close = h.query_all_by_label("Close").find(|n| window.contains(n.rect().center())).expect("the pop-up's ×"); + close.click(); + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false), "closed"); +} + +/// #2137 review: a pop-up lists the comment's text replies only, as the Comments panel does. A +/// review status (`/StateModel /Review`) and a checkmark (`/StateModel /Marked`, the private +/// reply Mark writes) are replies too, but not ones to read in the thread. +#[test] +fn a_pop_up_lists_text_replies_but_not_status_or_checkmark_replies() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /NM (note-1) /Contents (Please check) /Popup 5 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 40 260 160] /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-1) /IRT 4 0 R /Contents (Agreed with this) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-2) /IRT 4 0 R /State /Accepted /StateModel /Review /Contents (Accepted set by Bob) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Ada) /NM (reply-3) /IRT 4 0 R /State /Marked /StateModel /Marked /Contents (Marked set by Ada) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness(move |app| { + app.open_bytes("thread.pdf", None, pdf.to_vec()).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let in_window = |text: &str| h.query_all_by(|n| shows_text(n, text)).any(|n| window.contains(n.rect().center())); + assert!(in_window("Agreed with this"), "the text reply"); + assert!(!in_window("Accepted set by Bob"), "not the review status"); + assert!(!in_window("Marked set by Ada"), "not the checkmark"); +} + +/// A Replace Text proposal: a strikeout and a caret grouped by `/IRT` and `/RT /Group`, with the +/// pop-up `open`. `caret_first` writes the caret as the group's primary, holding the replacement, +/// with the strikeout grouped under it; otherwise the strikeout is the primary and the +/// replacement is on the caret grouped under it, as PdfCraft used to write it. +fn replace_text_pdf(caret_first: bool, open: bool) -> Vec { + let primary = if caret_first { 4 } else { 5 }; + let strike = format!( + "<< /Type /Annot /Subtype /StrikeOut /Rect [40 100 140 120] /QuadPoints [40 120 140 120 40 100 140 100] /C [1 0 0] /T (Ada) /IT /StrikeOutTextEdit{} >>", + if caret_first { " /IRT 4 0 R /RT /Group" } else { " /Popup 6 0 R" } + ); + let caret = format!( + "<< /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /Contents (slow red){} >>", + if caret_first { " /IT /Replace /Popup 6 0 R" } else { " /IRT 5 0 R /RT /Group" } + ); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R 6 0 R] >> endobj +4 0 obj {caret} endobj +5 0 obj {strike} endobj +6 0 obj << /Type /Annot /Subtype /Popup /Parent {primary} 0 R /Rect [160 20 290 150] /Open {open} >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .into_bytes() +} + +/// A Replace Text proposal's window shows the replacement as the comment's text, and the other +/// member of the group is part of the comment, not a reply panel (ISO 32000-2 §12.5.6.2: a group's +/// members are one unit). Both layouts: the caret as the primary, and the strikeout as the primary +/// with the replacement on the grouped caret, as `main` writes it (#5688). +#[test] +fn a_replace_text_window_shows_the_replacement_as_its_text_not_as_a_reply() { + for caret_first in [true, false] { + let pdf = replace_text_pdf(caret_first, true); + let mut h = harness(move |app| { + app.open_bytes("replace.pdf", None, pdf).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let boxes: Vec = h + .query_all_by(|n| n.role() == Role::MultilineTextInput) + .filter(|n| window.contains(n.rect().center())) + .map(|n| n.value().unwrap_or_default()) + .collect(); + assert_eq!(boxes, ["slow red"], "caret first: {caret_first}: one box, the replacement"); + } +} + +/// A locked Replace Text proposal, as `main` writes it (the strikeout primary and locked, the +/// replacement on the grouped caret), shows the replacement in its window read-only: locking the +/// comment locks the text the window shows for it. +#[test] +fn a_locked_replace_text_window_shows_the_replacement_read_only() { + let pdf = String::from_utf8(replace_text_pdf(false, true)) + .unwrap() + .replace("/IT /StrikeOutTextEdit /Popup", "/IT /StrikeOutTextEdit /F 128 /Popup") + .into_bytes(); + let mut h = harness(move |app| { + app.open_bytes("replace.pdf", None, pdf).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let inside = |n: &egui_kittest::Node<'_>| window.contains(n.rect().center()); + let boxes = h.query_all_by(|n| n.role() == Role::MultilineTextInput).filter(inside).count(); + assert_eq!(boxes, 0, "no box to type in while the comment is locked"); + assert!(h.query_all_by_label("slow red").any(|n| inside(&n)), "the replacement is shown"); +} + +/// A double-click on a Replace Text proposal opens the group's one window, the primary's, in +/// either layout. (A double-click on the member grouped under it opens the same +/// window once members are picked on the page; main hit-tests only the primary.) +#[test] +fn a_double_click_on_a_replace_text_opens_its_one_window() { + for (caret_first, on_caret) in [(true, true), (false, false)] { + let pdf = replace_text_pdf(caret_first, false); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("replace.pdf", None, pdf.clone()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + h.run_steps(45); + let target = if on_caret { (140.0, 98.0) } else { (60.0, 110.0) }; + let p = at(&h, target.0, target.1); + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(1); + } + h.run_steps(4); + let primary_subtype = if caret_first { "Caret" } else { "StrikeOut" }; + let c = comments(&h); + let primary = c.iter().find(|a| a.subtype == primary_subtype).expect("the primary"); + assert_eq!(primary.popup.map(|w| w.open), Some(true), "caret first: {caret_first}, on the caret: {on_caret}: the group's window opened"); + assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window"); + } +} + +/// A double-click on a sticky note opens its pop-up window on the page, where its text is read +/// and edited; the title bar drags it (saved as the pop-up's place, one undo step); × closes it +/// (comment.popups). +#[test] +fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() { + // Frames a sixtieth apart, so two clicks land within egui's double-click time. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("author", "Tester").unwrap(); + app.set_option("quick", "note").unwrap(); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + click_pt(&mut h, (60.0, 160.0)); + field(&h, "Add a comment").type_text("Please check"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false)); + assert!(h.state().views[0].comments.popup_rects.is_empty(), "closed pop-ups are not shown"); + // Well after the Post click, so egui does not count the next clicks with it. + h.run_steps(45); + + // Double-click the icon. + let icon = at(&h, (note.rect[0] + note.rect[2]) / 2.0, (note.rect[1] + note.rect[3]) / 2.0); + h.hover_at(icon); + h.run_steps(1); + for _ in 0..2 { + h.drag_at(icon); + h.run_steps(1); + h.drop_at(icon); + h.run_steps(1); + } + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(true), "the pop-up opened"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Open comment pop-up")); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is on screen"); + // Its title names the kind of comment and its author. + h.get_by_label("Note – Tester"); + + // Drag the title bar 80 px right and 40 px down: the pop-up's place on the page moves. + let before = note.popup.unwrap().rect; + let bar = pos2(window.left() + 30.0, window.top() + 12.0); + drag(&mut h, bar, bar + egui::vec2(80.0, 40.0)); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + let after = note.popup.unwrap().rect; + // 80 × 40 screen pixels is about 20 × 10 pt at this zoom. + assert!(after[0] > before[0] + 10.0 && after[1] < before[1] - 5.0, "moved right and down on the page: {before:?} -> {after:?}"); + assert!(((after[2] - after[0]) - (before[2] - before[0])).abs() < 0.5, "the same width"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up")); + + // Close it. + h.get_by_label("Close").click(); + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false)); + assert!(h.state().views[0].comments.popup_rects.is_empty()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Close comment pop-up")); +} + +// Contributor-original PDF: two notes sharing one /NM (as PyMuPDF names them), both with an open +// pop-up; the second has a reply by reference and a reply to that reply, which has no /NM. +const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /NM (fitz-A0) /T (Alpha) /Contents (First note) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 110 160 190] /Parent 4 0 R /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A0) /T (Beta) /Contents (Second note) /Popup 7 0 R /Open true >> endobj +7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 -250 290 90] /Parent 6 0 R /Open true >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply by reference) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /IRT 8 0 R /T (Delta) /Contents (Reply to the reply) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A pop-up reads its comment's thread by `/IRT` reference, as the Comments panel does: the +/// replies show once, in the second note's window only, at any depth, though both notes share +/// one `/NM`. +#[test] +fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() { + let h = harness_with_bytes(POPUPS_SHARING_A_NAME.to_vec(), |_| {}); + let windows = h.state().views[0].comments.popup_rects.clone(); + assert_eq!(windows.len(), 2, "both notes' pop-ups are open: {windows:?}"); + let (_, second) = windows.iter().find(|(key, _)| *key == (0, 2)).copied().expect("the second note's pop-up"); + for text in ["Reply by reference", "Reply to the reply"] { + // The Comments panel lists the thread too; count only what the windows show. + let shown: Vec = + h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect(); + assert_eq!(shown.len(), 1, "{text:?} shows in one window: {shown:?}"); + assert!(shown.iter().all(|c| second.contains(*c)), "{text:?} is in the second note's window"); + } +} + +// Contributor-original PDF: one blue note with its pop-up open beside it. +const BLUE_NOTE_WITH_OPEN_POP_UP: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [0 0 1] /T (Alpha) /Contents (A note) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [120 20 260 120] /Parent 4 0 R /Open true >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// What the connectors of comments in colour `color` painted in the last frame: each wedge +/// (its point, the two ends of its base, its colour: `color`, faded). +fn connectors(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<(Pos2, [Pos2; 2], egui::Color32)> { + fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<(Pos2, [Pos2; 2], egui::Color32)>) { + match shape { + egui::Shape::Mesh(m) => { + if let [tip, b0, b1] = m.vertices.as_slice() + && tip.color.a() > 0 + && tip.color.a() < 255 + && tip.color.to_opaque() == color + { + out.push((tip.pos, [b0.pos, b1.pos], tip.color)); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, color, out)), + _ => {} + } + } + let mut out = Vec::new(); + for clipped in &h.output().shapes { + walk(&clipped.shape, color, &mut out); + } + out +} + +/// A comment's connector (comment.connector-lines): a faint wedge in the comment's colour from +/// the edge of the comment nearest its open pop-up to a base on the window's edge around the +/// point nearest the comment; shown +/// while the pointer is on the comment or on the window or the comment is selected, and not +/// otherwise. +#[test] +fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let blue = egui::Color32::from_rgb(0, 0, 255); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let mark = egui::Rect::from_two_pos(at(&h, 20.0, 180.0), at(&h, 40.0, 160.0)); + let on_edge = |r: egui::Rect, p: Pos2| { + r.expand(1.0).contains(p) && [p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y].iter().any(|d| d.abs() < 1.0) + }; + + // Away from both: no connector. + h.hover_at(at(&h, 280.0, 190.0)); + h.run_steps(2); + assert!(connectors(&h, blue).is_empty(), "no connector with the pointer elsewhere"); + + // On the note or on its window: one wedge, from the note's edge to the window's. + for (what, pointer) in [("the note", mark.center()), ("the window", window.center())] { + h.hover_at(pointer); + h.run_steps(2); + let found = connectors(&h, blue); + assert_eq!(found.len(), 1, "one connector with the pointer on {what}: {found:?}"); + let (tip, [b0, b1], base) = found[0]; + assert!(on_edge(mark, tip), "its point is on the note's edge {mark:?}: {tip:?}"); + // The nearest point of the note: its lower right corner. + assert!(tip.distance(mark.right_bottom()) < 1.0, "{tip:?}"); + assert!(on_edge(window, b0) && on_edge(window, b1), "its base is on the window's edge {window:?}: {b0:?} {b1:?}"); + // The base lies along one side, around the window's upper left corner, and is wider than a line. + assert!((b0.x - b1.x).abs() < 0.5 || (b0.y - b1.y).abs() < 0.5, "along one side: {b0:?} {b1:?}"); + assert!(b0.distance(b1) >= 10.0, "a base, not a line: {b0:?} {b1:?}"); + assert!(b0.distance(window.left_top()).min(b1.distance(window.left_top())) < 1.0, "at the corner: {b0:?} {b1:?}"); + // The wedge is faint, in the comment's hue. + assert!(base.a() < 255 && base.a() > 0 && base.to_opaque() == blue, "the wedge is faint blue: {base:?}"); + } + + // Selected, the note keeps its connector with the pointer elsewhere. + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.hover_at(at(&h, 280.0, 190.0)); + h.run_steps(2); + assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector"); +} + +/// A note with an open pop-up and a thread of replies: "Second" and "Third" answer the note, +/// "Second, first answer" answers "Second" (`/IRT` to a reply). +const NOTE_WITH_A_THREAD: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [1 0.85 0.2] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 -250 280 150] /Parent 4 0 R /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second) /IRT 4 0 R /F 30 >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second, first answer) /IRT 6 0 R /F 30 >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Third) /IRT 4 0 R /F 30 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A pop-up window (comment.popup-windows) is laid out as the readers' are: a title bar with the +/// comment's kind and author; the comment's text; its replies as panels in thread order, an +/// answer to a reply inside that reply's panel; and a Reply button under each, which opens a box +/// that posts an answer to that comment or reply. +#[test] +fn a_pop_up_threads_its_replies_and_takes_a_new_one() { + let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + h.get_by_label("Note – Alpha"); + // Each reply's text box, inside the window. + let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| { + let found: Vec = h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect()).filter(|r| window.contains_rect(*r)).collect(); + assert_eq!(found.len(), 1, "{text:?} once in the window: {found:?}"); + found[0] + }; + let (second, answer, third) = (rect(&h, "Second"), rect(&h, "Second, first answer"), rect(&h, "Third")); + assert!(second.bottom() < answer.top() && answer.bottom() < third.top(), "thread order: {second:?} {answer:?} {third:?}"); + assert!( + answer.left() > second.left() + 4.0 && answer.right() < second.right() - 4.0, + "the answer sits inside its reply's panel: {second:?} {answer:?}" + ); + assert!((third.left() - second.left()).abs() < 1.0, "replies to the note line up: {second:?} {third:?}"); + // The Reply buttons, top to bottom: the note's, then each reply's. + let replies = |h: &Harness<'static, PdfCraftApp>| { + let mut b: Vec = + h.query_all_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("Reply")).map(|n| n.rect()).collect(); + b.sort_by(|x, y| x.top().total_cmp(&y.top())); + b + }; + assert_eq!(replies(&h).len(), 4, "one under the note and each reply"); + + // The note's Reply: Enter starts a new line in the box, as in the readers' pop-ups; Post posts. + let before = comments(&h).len(); + let at = replies(&h)[0].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Fourth"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(comments(&h).len(), before, "Enter does not post"); + h.event(egui::Event::Text("line two".into())); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + let all = comments(&h); + let note = all.iter().find(|a| a.contents.as_deref() == Some("First")).expect("the note"); + let reply = all.iter().find(|a| a.contents.as_deref() == Some("Fourth\nline two")).expect("the reply was added, on two lines"); + assert_eq!(reply.parent, Some((note.page, note.index))); + assert_eq!(reply.author.as_deref(), Some("Tester")); + assert!(h.state().views[0].comments.popup_reply.is_none(), "the box closes"); + + // A reply's own Reply answers that reply; Ctrl+Enter posts too, as in the comment composer. + let second_key = all.iter().find(|a| a.contents.as_deref() == Some("Second")).map(|a| (a.page, a.index)).expect("the reply"); + let at = replies(&h)[1].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Fifth"); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(3); + let fifth = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("Fifth")).expect("Ctrl+Enter posts"); + assert_eq!(fifth.parent, Some(second_key), "it answers the reply"); + + // Cancel closes the box without posting. + let before = comments(&h).len(); + let at = replies(&h)[0].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Never mind"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(comments(&h).len(), before); + assert!(h.state().views[0].comments.popup_reply.is_none()); +} + +/// A note with an open pop-up and `replies` replies to it. +fn note_with_replies(replies: usize) -> Vec { + let annots: String = (0..replies + 2).map(|i| format!("{} 0 R ", i + 4)).collect(); + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [{annots}] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 10 280 150] /Parent 4 0 R /Open true >> endobj +" + ); + for i in 0..replies { + pdf.push_str(&format!( + "{} 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Reply {i}) /IRT 4 0 R /F 30 >> endobj\n", + i + 6 + )); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// A pop-up window keeps its height however long its thread grows: the text and replies scroll +/// inside it, as in the readers' pop-ups, rather than the window growing down the screen. +#[test] +fn a_long_thread_scrolls_inside_its_pop_up() { + let height = |replies: usize| { + let h = harness_with_bytes(note_with_replies(replies), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + window.height() + }; + let (short, long) = (height(1), height(25)); + assert!((short - long).abs() < 2.0, "the same height with 1 reply and with 25: {short} {long}"); + assert!(long < 600.0, "within the screen: {long}"); +} + +/// Dragging a pop-up window's lower right corner resizes it, and the new size is saved with the +/// comment (the pop-up's `/Rect`) at the same place, in one undo step; the window shows it. +#[test] +fn a_pop_ups_corner_resizes_it() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let before = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect; + let corner = window.right_bottom() - egui::vec2(3.0, 3.0); + drag(&mut h, corner, corner + egui::vec2(80.0, 60.0)); + h.run_steps(3); + let after = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect; + // Wider and taller on the page, its upper left corner where it was. + assert!(after[2] - after[0] > before[2] - before[0] + 5.0, "wider: {before:?} -> {after:?}"); + assert!(after[3] - after[1] > before[3] - before[1] + 5.0, "taller: {before:?} -> {after:?}"); + assert!((after[0] - before[0]).abs() < 0.5 && (after[3] - before[3]).abs() < 0.5, "the same place: {before:?} -> {after:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up")); + let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open"); + // The window shows the saved size, in points on screen whatever the zoom. + assert!((resized.width() - (after[2] - after[0])).abs() < 3.0, "as wide as saved: {after:?} {resized:?}"); + assert!((resized.height() - (after[3] - after[1])).abs() < 3.0, "as tall as saved: {after:?} {resized:?}"); + assert!(resized.width() > window.width() + 60.0 && resized.height() > window.height() + 40.0, "bigger on screen: {window:?} -> {resized:?}"); + assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen"); +} + +/// A pop-up window's box holding `text` (a comment's or a reply's). +fn shows_text(n: &egui_kittest::kittest::AccessKitNode<'_>, text: &str) -> bool { + n.role() == Role::MultilineTextInput && n.value().as_deref() == Some(text) +} + +/// On a page turned a quarter (View ▸ Rotate View), a resized pop-up window stays where it is on +/// screen and shows the size it was dragged to. +#[test] +fn a_pop_up_resized_on_a_turned_page_stays_in_place() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.views[0].rotation = 90); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let corner = window.right_bottom() - egui::vec2(3.0, 3.0); + drag(&mut h, corner, corner + egui::vec2(80.0, 60.0)); + h.run_steps(3); + let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open"); + assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen: {window:?} -> {resized:?}"); + assert!((resized.width() - window.width() - 80.0).abs() < 3.0, "80 px wider: {window:?} -> {resized:?}"); + // Taller by the drag, less what its text had already stretched it. + assert!(resized.height() > window.height() + 50.0, "taller: {window:?} -> {resized:?}"); +} + +/// #2137 review: the composer's Post, clicked while a pop-up's box holds unsaved text, also +/// saves that text as the box loses the focus. Both land in one frame and both are kept. +#[test] +fn posting_from_the_composer_keeps_a_pop_ups_unsaved_text() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.set_option("quick", "note").unwrap()); + // Low on the left, so the composer stays clear of the window. + click_pt(&mut h, (30.0, 40.0)); + // The composer opens with its editor focused. + h.run_steps(2); + h.event(egui::Event::Text("New note".into())); + h.run_steps(2); + let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0); + click(&mut h, box_at); + h.event(egui::Event::Text(" edited".into())); + h.run_steps(2); + assert!(h.state().views[0].comments.composer.is_some(), "the composer is still open"); + let post = h.get_by_label("Post").rect().center(); + click(&mut h, post); + h.run_steps(3); + let all = comments(&h); + assert!( + all.iter().any(|a| a.contents.as_deref() == Some("New note")), + "the composer's note: {:?}", + all.iter().map(|a| &a.contents).collect::>() + ); + assert!( + all.iter().any(|a| a.contents.as_deref() == Some("A note edited")), + "the pop-up's text: {:?}", + all.iter().map(|a| &a.contents).collect::>() + ); +} + +/// #2137 review: what a pop-up holds for a comment (a reply being typed, unsaved text) follows +/// that comment when a deletion moves it, and is dropped when an undo or a page edit leaves its +/// (page, index) naming another; other edits keep it. +#[test] +fn a_pop_ups_unsaved_work_follows_its_comment_or_is_dropped() { + use pdfcraft_engine::Edit; + let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {}); + let key = |h: &Harness<'static, PdfCraftApp>, text: &str| { + comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).map(|a| (a.page, a.index)).expect(text) + }; + let (second, third) = (key(&h, "Second"), key(&h, "Third")); + h.state_mut().views[0].comments.popup_reply = Some((third.0, third.1, "draft".into())); + + // Another edit, on another comment: the draft stays. + assert!(h.state_mut().apply_edit(Edit::SetAnnotationContents { page: second.0, index: second.1, text: "Second!".into() })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_reply, Some((third.0, third.1, "draft".into()))); + + // Deleting "Second" (and its answer) moves "Third" up: the draft goes with it. + assert!(h.state_mut().apply_edit(Edit::DeleteAnnotation { page: second.0, index: second.1 })); + h.run_steps(2); + let moved = key(&h, "Third"); + assert_ne!(moved, third, "Third moved up"); + assert_eq!(h.state().views[0].comments.popup_reply, Some((moved.0, moved.1, "draft".into())), "the draft is still Third's"); + + // Undo puts the thread back: the draft is dropped rather than left on another reply. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_reply, None); + + // A page inserted before it: the comment's (page, index) now names nothing of it. + let note = key(&h, "First"); + h.state_mut().views[0].comments.popup_text = Some((note.0, note.1, "unsaved".into())); + assert!(h.state_mut().apply_edit(Edit::InsertBlankPage { at: 0, width: 300.0, height: 200.0 })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_text, None); +} + +/// #2137 review: text typed into a pop-up and not yet saved is saved when × closes the window: +/// the click takes the focus from the box and closes it in one frame, one undo step for both. +#[test] +fn closing_a_pop_up_saves_its_typed_text() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0); + click(&mut h, box_at); + h.event(egui::Event::Text(" for you".into())); + h.run_steps(2); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let close = h.query_all_by_label("Close").map(|n| n.rect().center()).find(|c| window.contains(*c)).expect("the window's ×"); + click(&mut h, close); + h.run_steps(3); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.contents.as_deref(), Some("A note for you"), "the typed text is saved"); + assert_eq!(note.popup.map(|p| p.open), Some(false), "and the window closed"); + assert!(h.state().views[0].comments.popup_text.is_none()); + h.state_mut().undo(); + h.run_steps(2); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!((note.contents.as_deref(), note.popup.map(|p| p.open)), (Some("A note"), Some(true)), "one undo step for both"); +} + /// The selection bar stays inside the pages' area: its place was worked out for a 520 pt bar, /// so longer labels (German here) pushed it over the side panel next to a selection on the right. #[test] @@ -4009,7 +4820,25 @@ fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) { }; support::input::double_click(&mut h, point); h.run_steps(3); - let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer"); + if kind == 2 { + // A double-click on a sticky note opens its pop-up window, as in Acrobat; its edit + // composer is the page menu's "Edit text…". + let popup_open = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap().popup.is_some_and(|p| p.open) + }; + assert!(h.state().views[0].comments.composer.is_none(), "a note's double-click opens no composer"); + assert!(popup_open(&h), "a note's double-click opens its pop-up"); + h.hover_at(point); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_all_by_label("Edit text…").last().unwrap().click(); + h.run_steps(3); + } + let c = h.state().views[0].comments.composer.as_ref().expect("the edit composer is open"); assert_eq!(c.page, 1); assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0)); h.input_mut().events.push(egui::Event::Text(" edited\n\n".into())); diff --git a/crates/ui-egui/tests/it/stamps.rs b/crates/ui-egui/tests/it/stamps.rs index 767cc7eb4c..1cea1cf638 100644 --- a/crates/ui-egui/tests/it/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -140,3 +140,64 @@ fn creating_placing_and_keeping_custom_stamps() { assert_eq!((again.custom_stamps[0].category.as_str(), again.custom_stamps[0].name.as_str()), ("Company", "logo")); assert_eq!(again.custom_stamps[0].data, s.custom_stamps[0].data); } + +/// A custom stamp with a line filled in when it is placed (#1741): chosen in the Create dialog, +/// kept in the library, and placed with the band under the picture. +#[test] +fn a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app.set_option("author", "Ada").unwrap(); + app + }); + h.run_steps(6); + assert!(h.state_mut().execute("comment.stamp")); + h.run_steps(2); + let mut png = Vec::new(); + image::RgbImage::from_pixel(60, 20, image::Rgb([20, 120, 220])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().start_custom_stamp("logo.png".into(), png); + h.run_steps(2); + h.get_by_label("Add a line filled in when the stamp is placed").click(); + h.run_steps(2); + h.get_by_label_contains("become your name, the date and the time"); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().custom_stamps[0].line.as_deref(), Some("By {name} at {time}, {date}")); + let p = { + let r = h.state().views[0].page_screen_rect(0).unwrap(); + r.min + egui::vec2(r.width() * 0.5, r.height() * 0.85) + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); + let s = h.state(); + let saved = s.session.save_bytes(s.views[0].id).unwrap(); + let pdf = pdfcraft_cos::Document::open(saved).unwrap(); + let line = pdf + .scan_objects() + .find_map(|(_, object)| match object.as_ref() { + pdfcraft_cos::Object::Dict(dict) => dict.get(b"PCByLine").and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text), + _ => None, + }) + .expect("placed custom stamp stores PCByLine in the PDF"); + assert!(line.starts_with("By Ada at "), "expanded stamp line: {line:?}"); + assert!( + !line.contains("{name}") && !line.contains("{date}") && !line.contains("{time}"), + "the saved stamp line must not contain unexpanded placeholders: {line:?}" + ); + + let doc = s.session.get(s.views[0].id).unwrap(); + let r = doc.info.annotations.iter().find(|a| a.subtype == "Stamp").expect("placed").rect; + let aspect = (r[2] - r[0]) / (r[3] - r[1]); + // A band under the 3:1 picture (so less wide than 3:1), at least as wide as the picture. + assert!(aspect > 3.0 * (1.0 - 0.22) - 0.05 && aspect < 3.0 - 0.05, "a band under the 3:1 picture: {r:?}"); + let mut again = PdfCraftApp::new(); + again.restore(&s.persist()); + assert_eq!(again.custom_stamps[0].line, s.custom_stamps[0].line, "kept with the library"); +} diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 11d4b8a5dc..906e3e1c40 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -143,9 +143,10 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } -/// #2666: Replace Text writes a Caret and a StrikeOut grouped under it. XFDF names the link by -/// the `/NM` of the annotation it points at, so when both carried one id the `inreplyto` was the -/// strikeout's own `name`: importing it found a comment replying to itself and the pair was lost. +/// #2666: Replace Text writes a Caret and a StrikeOut grouped with it (`/IRT`, `/RT /Group`). XFDF +/// names the `/IRT` annotation by its `/NM`, so when both carried one id the strikeout's +/// `inreplyto` was its own `name`: importing it found a comment replying to itself and the pair +/// was lost. #[test] fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); @@ -170,7 +171,7 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let xfdf = export_xfdf(&src, true, false, "a.pdf"); assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); - assert!(xfdf.contains("inreplyto=\"caret\""), "the strikeout is grouped under the caret: {xfdf}"); + assert!(xfdf.contains("inreplyto=\"caret\""), "the strikeout is grouped with the caret: {xfdf}"); assert!(!xfdf.contains("inreplyto=\"strike\""), "never a reply to itself"); let mut dst = blank(); @@ -180,6 +181,7 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); + assert!(found.iter().any(|s| s.subtype == "StrikeOut" && s.is_group_member()), "still grouped (/RT /Group) with the caret: {found:?}"); // Review on #5689 (CodeRabbit): the intents travel too, so the imported pair is still // Replace Text, and deleting its strikeout takes the caret and its replacement with it. assert!(xfdf.contains("intent=\"Replace\"") && xfdf.contains("intent=\"StrikeOutTextEdit\""), "{xfdf}"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 508ec10ca6..a7613ec6b6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::text_markup_reach_reads_quads_like_the_drawing", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/annot/src/tests.rs::indirect_border_style_and_colour_are_drawn", "crates/annot/src/tests.rs::callout_without_rd_is_drawn", "crates/forms/src/tests.rs::normal_appearances_that_give_no_stream_are_drawn"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::text_markup_reach_reads_quads_like_the_drawing", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/annot/src/tests.rs::indirect_border_style_and_colour_are_drawn", "crates/annot/src/tests.rs::callout_without_rd_is_drawn", "crates/forms/src/tests.rs::normal_appearances_that_give_no_stream_are_drawn", "crates/engine/src/tests.rs::text_boxes_and_redaction_marks_without_appearances_show_their_outlines", "crates/annot/src/tests.rs::free_text_da_fonts_draw_in_their_standard_face", "crates/annot/src/tests.rs::free_text_ds_style_is_drawn", "crates/annot/src/tests.rs::redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c", "crates/annot/src/tests.rs::unapplied_redaction_marks_follow_their_oc_outline_colour"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text boxes without /BS or /Border get the /Border default 1-pt border (ISO 32000-2 Table 166), their /DA font when /DR or its resource name makes it one of the twelve standard text faces (others stay Helvetica), and /DS font, size and colour over /DA (no /RC rich text yet); unapplied redaction marks without /AP are outlined in an array /OC colour (three finite numbers) when they have one, with or without /QuadPoints; without one, marks with /QuadPoints keep /C (else red) and marks without /QuadPoints (Table 195: /Rect is the region) draw nothing; an /OC dictionary stays optional content (Table 166). Basis: ISO 32000-2 §12.5.6.23 says only that before redaction is applied the user can see, move and redefine these annotations, and Table 195 has no outline-colour entry, so the colour comes from black-box observation of two synthetic fixtures. noap3.pdf (generator attached to #4364), rows J/K/L, unapplied, without /QuadPoints: Adobe Acrobat Pro J nothing, K red outline, L nothing; Chrome (PDFium) and Edge nothing on all three; PDF-XChange Editor and Right PDF Editor a red outline on all three. oc.pdf, six marks without /AP (1 /OC blue; 2 /C green; 3 /OC blue + /C green; 4 /OC blue + /IC black; 5 no colour; 6 /OC blue + /QuadPoints; rebuilt inline in unapplied_redaction_marks_follow_their_oc_outline_colour): Acrobat blue on 1, 3, 4 and 6 and nothing on 2 and 5; Chrome and Edge nothing on all six; PDF-XChange Editor green on 2 and 3, nothing on the others; Right PDF Editor red on all six. The three readers tested that support redaction marks disagree, so PdfCraft follows the /OC colour, the one Acrobat uses; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -3662,8 +3662,8 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages"] -notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages", "crates/ui-egui/tests/it/comments.rs::a_group_is_one_comment_in_the_panel_and_on_the_page", "crates/automation/tests/it/automation.rs::a_group_lists_as_its_primary", "crates/automation/tests/it/automation.rs::a_group_member_on_another_page_is_a_comment_of_its_own", "crates/automation/tests/it/automation.rs::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect/tests.rs::group_primaries_follow_irt_to_the_first_non_subordinate", "crates/annot/src/tests.rs::deleting_a_primary_releases_its_subordinate", "crates/engine/src/tests.rs::deleting_a_group_primary_releases_its_member_in_one_undo_step"] +notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet. Annotation groups (`/RT /Group`, ISO 32000-2 §12.5.6.2) list as one comment with the primary's author, text and colour (the subordinate's own are ignored), a reply to a subordinate joins the primary's thread, `comment_list` names the members in `group`, clicking a subordinate on the page selects the group, and Delete deletes the member clicked alone, the primary included, as Acrobat desktop does (one undo step; a subordinate left without its primary is a comment of its own and loses its `/IRT`, keeping `/RT /Group` as Acrobat does, so a full save leaves the deleted primary out; clearing redaction marks does the same for a mark's group member; Replace Text's strikeout and caret delete together). ISO 32000-2 §12.5.6.2 has deletion act on the whole group, as PDF-XChange and Foxit do on one page; Acrobat's is followed as the more practical. A group is one page's: a subordinate whose primary is on another page is a comment of its own, listed, selected, moved and deleted on its own, as Acrobat (desktop and iPad), PDF-XChange and Foxit read it. Missing: restyling a member does not restyle its group as Acrobat does, a subordinate can't be dragged, resized or reshaped on its own, and it has no hover tooltip" [[feature]] id = "comment.appearance-generation" @@ -3746,8 +3746,8 @@ milestone = "M5" status = "shipped" commands = ["comment.replace"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/it/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "a caret holding the replacement, the group's primary (/IT /Replace), with the strikeout (/IT /StrikeOutTextEdit) grouped under it (/IRT, /RT /Group), as ISO 32000-2 §12.5.6.2 reads a group's text from its primary; deleting either deletes both. Missing: /RT /Group is not read yet, so the Comments panel and comment_list show the strikeout as a reply of the caret (#4357)." +evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/it/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/automation/tests/it/automation.rs::replace_text_lists_its_replacement", "crates/annot/src/tests.rs::restyling_a_replace_text_pair_restyles_both_members", "crates/xfdf/src/tests.rs::a_replace_text_pair_survives_an_xfdf_round_trip"] +notes = "a caret holding the replacement (/IT /Replace) is the group's primary and a strikeout (/IT /StrikeOutTextEdit) its /RT /Group subordinate (ISO 32000-2 §12.5.6.2), so the pair lists, selects and deletes as one comment with the replacement text. Groups saved by earlier builds the other way round (strikeout primary with no text) still list the caret's text, without rewriting the file. Restyling either member restyles both (colour and opacity, one undo step), and XFDF keeps both intents. Missing: moving the caret does not move the strikeout" [[feature]] id = "comment.insert-text" @@ -3805,9 +3805,9 @@ tier = "P1" milestone = "M5" status = "shipped" commands = ["comment.callout"] -tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text" +tools = ["comment_add", "comment_edit", "comment_list"] +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/annot/src/tests.rs::a_callouts_arrow_point_and_knee_move_on_their_own", "crates/annot/src/tests.rs::a_callouts_box_moves_around_its_arrow_and_keeps_the_knee_beside_it", "crates/annot/src/tests.rs::a_callout_without_a_knee_keeps_its_line", "crates/annot/src/tests.rs::a_callout_is_laid_out_again_when_its_end_leaves_the_box", "crates/annot/src/tests.rs::a_new_callouts_knee_line_is_twelve_points", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/automation/tests/it/automation.rs::a_callouts_parts_are_listed_and_changed_through_the_tools"] +notes = "drag from the target to the box; a new callout's knee line is 12 pt and level (or upright), out from the middle of the side facing the arrow point. Selected, its three parts change on their own (#1152), as seen in Acrobat Pro DC (a 2021 Continuous release) with synthetic files: the arrow point and the knee have handles, the knee moving along its knee line only and no nearer the box than 12 pt; the text box has its own resize handles, and resizing it with the side the line meets left in place keeps the line as it is (editing the text too); dragged from inside, the box moves alone and the knee comes along, the arrow point staying (also seen in Acrobat Pro 2026.002.22031; Acrobat DC Help 11/28/2019 p. 357: 'The text box moves around a stationary anchor point'); dragged by its leader line, the whole callout moves. When the side the line meets is moved, or the knee is dragged into the box, Acrobat lays the line out again with a 12 pt knee line and also moves the box next to the arrow point; PdfCraft lays the line out the same way but leaves the box where it was put. Only the page handles hold the knee line to one direction; comment_edit takes any knee, as ISO 32000-2 Table 177 allows" [[feature]] id = "comment.pencil" @@ -4235,11 +4235,11 @@ title = "Open and reposition comment pop-ups" area = "E" tier = "P1" milestone = "M5" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["comment_popup", "comment_list"] +evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/ui-egui/tests/it/comments.rs::a_pop_up_threads_its_replies_and_takes_a_new_one", "crates/ui-egui/tests/it/comments.rs::a_long_thread_scrolls_inside_its_pop_up", "crates/ui-egui/tests/it/comments.rs::a_pop_ups_corner_resizes_it", "crates/ui-egui/tests/it/comments.rs::a_pop_up_resized_on_a_turned_page_stays_in_place", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/it/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect/tests.rs::a_comments_pop_up_is_read_with_its_place_and_state"] +notes = "double-click a comment to open its pop-up window on the page (its /Popup /Open and /Rect, so other viewers show it the same), at the size its /Rect gives in points whatever the zoom; a title bar with the comment's kind, author and date; its text typed in place; its replies as panels, each inside the one it answers (/IRT), their text typed in place too; a Reply button under the comment and under each reply opens a box that answers it (Enter starts a new line, Post or Ctrl+Enter posts); the text and replies scroll inside the window; the title bar drags it, its lower right corner resizes it, × closes it; each is an undo step. Not yet: the window's options menu (status, mark, lock, delete from the window), collapsing a reply's answers, resizing from the other corners and sides, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)" [[feature]] id = "comment.connector-lines" @@ -4247,11 +4247,11 @@ title = "Connector lines between markups and pop-ups" area = "E" tier = "P2" milestone = "M5" -status = "planned" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/comments.rs::a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either"] +notes = "a faint wedge in the comment's colour from the comment's edge nearest its open pop-up, widening to the window's side that faces it, while the pointer is on either or the comment is selected (Acrobat's roll-over lines). Not yet: the preference to turn them off" [[feature]] id = "comment.review-history" @@ -4298,8 +4298,8 @@ milestone = "M6" status = "shipped" commands = ["comment.stamp"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] -notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)" +evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages", "crates/engine/src/tests.rs::stamp_lines_fill_in_the_name_date_and_time", "crates/automation/tests/it/automation.rs::a_custom_stamp_line_is_filled_in_when_placed", "crates/ui-egui/tests/it/stamps.rs::a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed"] +notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398). A custom stamp (image or PDF page) can carry a line under its picture, a template with {name}, {date} and {time} filled in when it is placed and fixed afterwards (#1741; Create Custom Stamp, and stamp_custom's line). Not yet: Acrobat's dynamic stamps from PDF stamp files with form fields and JavaScript, date formats other than the built-in one, and text outside WinAnsi in the line (drawn in Helvetica, so it shows as ?)" [[feature]] id = "comment.stamps-custom"