From ff886283f836baae0ecc246e108407cfbb454a61 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:56:16 +0800 Subject: [PATCH 01/48] M5: text boxes and redaction marks without an appearance draw their style and outline, ported onto main A single squashed port of the branch's five commits onto current main, which split annot's lib.rs into modules (create.rs, modify.rs, info.rs, draw.rs) and appearance::build into per-subtype drawing functions. The behaviour and tests are the branch's (ISO 32000-2 Tables 166, 177, 195): - text boxes without /AP draw their default 1-pt border, their /DA face when it is a standard text face, and /DS font, size and colour (text_style.rs); - unapplied redaction marks are outlined in an array /OC colour, with or without /QuadPoints, and keep /C otherwise; - a text box's colour or size change keeps the face it is drawn in (restyle_text), and edited text boxes re-fit in their drawn face and size. On main, the branch's set_style test calls use StyleChange, and the parity entry keeps main's evidence paths (render raster/tests.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 64 +++-- crates/annot/src/draw.rs | 3 +- crates/annot/src/info.rs | 12 +- crates/annot/src/lib.rs | 1 + crates/annot/src/modify.rs | 59 +++-- crates/annot/src/tests.rs | 307 +++++++++++++++++++++++- crates/annot/src/text_style.rs | 420 +++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 38 +++ crates/fonts/src/lib.rs | 2 +- parity/acrobat-features.toml | 4 +- 10 files changed, 870 insertions(+), 40 deletions(-) create mode 100644 crates/annot/src/text_style.rs diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..99536fe023 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -6,9 +6,10 @@ //! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line //! endings, indirect geometry), so callers never replace an appearance with a worse one. //! -//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard -//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of -//! Helvetica's proportions by character class (no font program or metrics file is bundled). +//! 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`]); lines break at that face's standard-14 widths (no font program is +//! bundled). use pdfcraft_cos::{Dict, Object, PdfString, Stream}; use pdfcraft_fonts::{first_undrawable, literal, win_ansi}; @@ -326,13 +327,19 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. fn helvetica_resource() -> Object { + font_resource(pdfcraft_fonts::Std14::Helvetica) +} + +/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name +/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica). +fn font_resource(face: pdfcraft_fonts::Std14) -> Object { 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)); Object::Dict(fonts) } @@ -406,13 +413,19 @@ impl AppearanceDrawing { /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + build_in(d, None) +} + +/// [`build`], with the face a text box's `/DA` font names in the form's `/DR` +/// ([`crate::text_style::dr_face`]). +pub(crate) fn build_in(d: &Dict, dr_face: Option) -> Option { let context = appearance_context(d)?; let drawing = AppearanceDrawing::new(&context); match context.subtype { b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), b"Stamp" => return draw_stamp(&context, drawing), - b"FreeText" => return draw_free_text(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing, dr_face), _ => {} } let mut drawing = drawing; @@ -532,8 +545,27 @@ fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawi let stroke = context.stroke; let c = &mut drawing.content; // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + // 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(_) => (nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?, 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.as_chunks::<8>().0.iter() { c.push_str(&format!( @@ -889,16 +921,17 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - Some(form(rect, c.as_bytes(), res)) } -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 rect = context.rect; let stroke = context.stroke; let mut c = std::mem::take(&mut drawing.content); let mut res = std::mem::take(&mut drawing.resources); let _tail = std::mem::take(&mut drawing.tail); - let (text_color, size) = parse_da(d); + let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face); 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 @@ -965,21 +998,22 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); let q = d.int(b"Q").unwrap_or(0); c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + "{} {} {} {} 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 mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { + for line in pdfcraft_fonts::wrap_with(&text, size, width, |s, size| face.text_width(s, size)) { if y < rect[1] - size { break; } - let lw = text_width(&line, size); + let lw = face.text_width(&line, size); let x = match q { 1 => rect[0] + pad + (width - lw) / 2.0, 2 => rect[2] - pad - lw, @@ -991,7 +1025,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + res.set(b"Font".to_vec(), font_resource(face)); let mut stream = form(full, &out, res); if let Some(m) = matrix { stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 5e698332da..d717bf8159 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -1,6 +1,7 @@ use super::create::{finite, num_array}; use super::lookup::{annot_dict, annots, page_refs}; use super::modify::refuse_foreign_text; +use super::text_style; use super::types::NOTE_SIZE; use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; @@ -40,7 +41,7 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot /// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. /// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(d)?; + let mut stream = appearance::build_in(d, text_style::dr_face(doc, d))?; // Picture-stamp restyling must retain the placement's page-axis correction. if d.get(b"PCPicture").is_some() { let matrix = d.get(b"AP").and_then(|ap| { diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..9590435f43 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -2,6 +2,7 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; +use super::text_style; use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; @@ -223,10 +224,15 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let d = obj.as_dict()?; let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + // 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 => (c.len() == 3).then(|| [c[0], c[1], c[2]]), + }; 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) } @@ -243,7 +249,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), subtype, endings: line_endings_of(d), }) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index e9e0359ce0..343aa7aea6 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -46,6 +46,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 fc81348075..6cefc3eca1 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -4,11 +4,13 @@ use super::create::{ 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::text_style::{self, TextStyle}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, StyleChange, TextReplacement, }; use super::{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. @@ -34,11 +36,28 @@ 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) 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 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)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{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, @@ -53,13 +72,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)); } @@ -129,12 +150,15 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int /// new text: the wrap width and top edge stay, the height fits the wrapped lines. pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; - let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + let before = annot_dict(doc, r); + let free_text = before.name(b"Subtype") == Some(b"FreeText"); + // 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"); } @@ -159,7 +183,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| face.text_width(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]]; @@ -176,10 +202,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 lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; + let lines = pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure).len().max(1) as f64; Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) } @@ -522,14 +548,13 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } 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)); let before = d; doc.update_dict(r, |d| { if let Some(c) = color { if free_text { - let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - rich_text(d); + restyle_text(d, TextStyle { color: c, ..drawn }); } else { d.set(b"C".to_vec(), rgb(c)); } @@ -577,11 +602,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); } refuse_foreign_text(doc, &d)?; + // 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(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); - 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(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index eb8d1a0f15..f8bc881fd0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1575,7 +1575,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())); }) @@ -2166,3 +2167,307 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +/// 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}"); + } +} + +/// `/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}"); + } +} + +/// `/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")); +} diff --git a/crates/annot/src/text_style.rs b/crates/annot/src/text_style.rs new file mode 100644 index 0000000000..fc3ad87f86 --- /dev/null +++ b/crates/annot/src/text_style.rs @@ -0,0 +1,420 @@ +//! 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). +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 = toks.get(tf.checked_sub(2)?)?.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); + } + + #[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/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a68916dff..b7f140eb9c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7076,6 +7076,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); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 61061f5509..6f402e3439 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -30,7 +30,7 @@ pub use script::{ pub use shaping::{ ShapedCluster, ShapedScript, arabic_glyph, arabic_has, 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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..7a5dad5bee 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"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; 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 has no /N (#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::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", "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"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; 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; 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 has no /N (#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" From adc0b91b1ec77a79493deee462c1e2e2f498afd4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:50:06 +0800 Subject: [PATCH 02/48] M5: callouts reshape by their arrow point, knee and text box, ported onto main (#1152) A single squashed port of the branch's seven commits onto current main, which split the files this touched into modules: annot (create.rs, modify.rs, info.rs), render inspect (model.rs, annotations.rs), engine comments.rs and ui-egui tools/comments/*. The behaviour and tests are the branch's: - a callout's arrow point and knee have handles and move on their own; its line follows the box as Acrobat's does; its text box resizes; - comment_list gives a callout's points and text box, comment_edit reshapes it; - /CL is read all or nothing, and a box sent back as f32 still moves or keeps its line. On main, select_input's knee cursor moved into point_cursor to stay under the cognitive-complexity limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/create.rs | 80 +++++- crates/annot/src/info.rs | 20 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 115 +++++++- crates/annot/src/tests.rs | 258 +++++++++++++++++- crates/automation/src/comments.rs | 22 +- crates/automation/src/tools.rs | 6 +- crates/automation/tests/it/automation.rs | 46 ++++ crates/engine/src/comments.rs | 1 + crates/engine/src/lib.rs | 5 +- crates/render/src/inspect/annotations.rs | 27 ++ crates/render/src/inspect/model.rs | 7 +- crates/render/src/inspect/tests.rs | 1 + crates/ui-egui/src/tools/comments/composer.rs | 9 +- crates/ui-egui/src/tools/comments/geometry.rs | 63 ++++- crates/ui-egui/src/tools/comments/input.rs | 40 ++- crates/ui-egui/src/tools/comments/paint.rs | 56 +++- crates/ui-egui/src/tools/comments/state.rs | 13 +- crates/ui-egui/src/tools/comments/tests.rs | 20 +- crates/ui-egui/tests/it/comments.rs | 81 ++++++ parity/acrobat-features.toml | 6 +- 21 files changed, 780 insertions(+), 98 deletions(-) diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..3d33bff324 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -190,14 +190,86 @@ 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); + 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. +pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side { + let [x0, _, x1, y1] = normalize(rect); if knee[0] < x0 { - [x0, cy] + Side::Left } else if knee[0] > x1 { - [x1, cy] + Side::Right } else if knee[1] > y1 { - [cx, y1] + 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), } } diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..8ae1a12c31 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -1,7 +1,7 @@ use super::create::finite; use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; -use super::modify::{fillable, touch, unlocked}; +use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked}; use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; @@ -35,9 +35,12 @@ 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]>, } pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -114,12 +117,20 @@ pub fn summaries(doc: &Document) -> Vec { } } let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); - // 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()); @@ -145,6 +156,7 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, + text_box, }); } } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..e777f2d144 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -27,7 +27,7 @@ mod types; #[cfg(test)] use create::num_array; -pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use create::{CALLOUT_KNEE_LINE, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, 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}; #[cfg(test)] diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..43b28c146f 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,6 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, + CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow, normalize, + num_array, on_side, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -394,10 +395,12 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m /// 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)?; @@ -408,6 +411,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). + 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); + } let style = Style { width: border_width_of(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); @@ -483,6 +495,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())); @@ -491,17 +540,53 @@ 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(); + 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()) } - None => (rect, None), + (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), }; doc.update_dict(r, |d| { d.set(b"Rect".to_vec(), num_array(&outer)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..47b2c866da 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -478,10 +478,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)); @@ -2194,3 +2194,255 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +/// 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 text_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!(text_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!(text_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!(text_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 = text_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!(text_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 exactly on +/// a side's line (or inside the box's width) is above or below it. +#[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:?}"); + } +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..3de3ae90b3 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -189,8 +189,8 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); - // A 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; @@ -198,6 +198,10 @@ 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: @@ -372,18 +376,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, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..fb66442f2e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -739,7 +739,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.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( @@ -906,7 +906,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, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), 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(), @@ -916,7 +916,7 @@ 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." }, })), &["doc"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..763ae4eb97 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10190,3 +10190,49 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } write_full(&doc, &SaveOptions::default()).unwrap() } + +/// #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}"); +} diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..5e16046102 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -24,6 +24,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec 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), + }; let annotation = Annotation { page, subtype, @@ -180,6 +186,7 @@ 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, }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { @@ -192,6 +199,26 @@ impl<'a> Inspector<'a> { link_replies(&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)) + } + fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..3fbf895339 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -338,9 +338,12 @@ 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]>, } impl Annotation { diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..c812cef6a6 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -304,6 +304,7 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { fill_sign: false, no_rotate: true, points: Vec::new(), + text_box: None, }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index fa2d7f9b1a..02d17a0d77 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -132,14 +132,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, view.comments.tool_done = true; // A callout's leader and box stay in user-space axes (its appearance isn't turned). let rect = text_box_rect(c.at, &text, 10.0, 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: 10.0, 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 051cf09dba..9f467a135a 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -5,21 +5,72 @@ pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") } -/// 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` picks up 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 5e74fdf56e..3bb8c5c0e8 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,8 +1,8 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, grabs_points, handle_pos, is_markup, point_handle, reshapable, reshaped, resizable, - resized, snapped_caret, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, frame, grabs_points, handle_pos, is_callout, is_markup, point_handle, reshapable, + reshape_for, resizable, resized, snapped_caret, }; -use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; +use super::paint::{drawn_shape, edit_anchor, erase, moves_box_only, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -77,10 +77,10 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { && 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, @@ -194,7 +194,7 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu 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 }); @@ -223,13 +223,19 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu let (a, b) = (cx.to_user(from), cx.to_user(p)); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); if from.distance(p) >= 2.0 { - view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); + view.pending_edit = Some(match cx.get(index).filter(|a| moves_box_only(cx, a, from)) { + Some(a) => { + let f = frame(a, cx.rotation()).map(f64::from); + Edit::ResizeAnnotation { page, index, rect: [f[0] + dx, f[1] + dy, f[2] + dx, f[3] + dy] } + } + None => Edit::MoveAnnotation { page, index, dx, dy }, + }); } } Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { cv.gesture = None; if let Some(a) = cx.get(index) { - let from_rect = cx.screen_rect(a); + let 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()); @@ -242,8 +248,8 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu cv.gesture = None; let shift = ui.input(|i| i.modifiers.shift); let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); - if moved { - view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + 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) }); } } _ => {} @@ -656,3 +662,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/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..787da6f3f8 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,4 +1,4 @@ -use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; +use super::geometry::{HANDLES, grabs_points, handle_pos, is_callout, is_markup, reshapable, reshape_for, resizable}; use super::state::{Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -45,7 +45,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 && grabs_points(cx.tool) && reshapable(a) { + let points = cx.allowed && grabs_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); @@ -56,15 +57,29 @@ 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_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>, @@ -72,12 +87,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)); @@ -92,17 +107,30 @@ 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])]; + } 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/state.rs b/crates/ui-egui/src/tools/comments/state.rs index ab24352256..eda11f423c 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. @@ -343,6 +343,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") @@ -406,7 +411,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) { @@ -415,8 +420,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 91e796e43a..0989ee1642 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) +} + /// A selected line is drawn where it is going: moved by its body (both ends follow the /// pointer), with an end dragged (that end alone), and as released until the page is drawn. #[test] @@ -8,14 +14,14 @@ 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"); } /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit @@ -42,6 +48,7 @@ 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, }; 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 }; @@ -87,6 +94,7 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz fill_sign: false, no_rotate: false, points: Vec::new(), + text_box: None, } } @@ -176,6 +184,7 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat fill_sign, no_rotate: false, points: Vec::new(), + text_box: None, } } @@ -380,6 +389,7 @@ 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, no_rotate: false, }], ..Default::default() diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 664f61733e..fa98691215 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1919,6 +1919,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(); @@ -3022,3 +3025,81 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.drop_at(from + delta); h.run_steps(3); } + +/// 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)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..a8bb234be8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3726,9 +3726,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" From a36dcbb3bc82fd23c09e116e5e95ba60ad18c8b0 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:50:06 +0800 Subject: [PATCH 03/48] M5: a callout's leader line follows its text box while the box is resized (#1152) Review on #4094: resizing a callout's text box moved its frame, but changing_points had no Resize branch, so no leader line was drawn to the box until the page was redrawn. A Resize gesture, and the ResizeAnnotation edit just released, now count as changing the callout: its points stay and its line is drawn to where the box is going. A box moved alone and released keeps its knee beside it, as while it was dragged. Test: a_callout_being_resized_draws_its_leader_line, which fails with the fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/tools/comments/paint.rs | 17 +++++++++++++++++ crates/ui-egui/src/tools/comments/tests.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 787da6f3f8..0a0ef1ebd9 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -100,6 +100,10 @@ pub(super) fn changing_points( }), (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + // 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, } } @@ -119,6 +123,19 @@ fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &An { *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); diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 0989ee1642..5150cf6d4b 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -24,6 +24,20 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, straight), None, "not changing"); } +/// 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 /// composer stays open while the drawn note is partly in view, even with `/Rect` out of it. #[test] From 24ac313428846fe9cecf224623fa1115d7877120 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:02:40 +0800 Subject: [PATCH 04/48] M5: a callout given its arrow point alone keeps its knee (#1152) comment_edit with one point on a callout whose /CL has six numbers built the new line from that point and the end only, so the knee was dropped without a word. An agent moving just the arrow lost the knee. place_text_box now keeps the old knee when only the arrow point is given; two points still set both, and a four-number /CL still gains a knee only when one is sent. The points schema says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/modify.rs | 11 ++++++++++- crates/annot/src/tests.rs | 14 ++++++++++++++ crates/automation/src/tools.rs | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 43b28c146f..07a34100fe 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -413,7 +413,7 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; } 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). + // 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())); } @@ -545,6 +545,15 @@ fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[ 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[..]) + } + _ => lead, + }; let line: Option> = match (lead, old_line.as_deref()) { (Some(lead @ [.., toward]), _) => { let side = callout_side(rect, *toward); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 47b2c866da..47c956640f 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2446,3 +2446,17 @@ fn summaries_give_no_callout_points_without_a_text_box() { 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"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fb66442f2e..4ca1ad31ec 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -916,7 +916,7 @@ 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": 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." }, + "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"], )), From 492011348e818294c924f00de019bf1d96e28cf2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:18:30 +0800 Subject: [PATCH 05/48] M5: comments in an /RT /Group are one comment, with the primary's attributes, ported onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single squashed port of the branch's four commits onto current main, which split annot's lib.rs (info.rs, modify.rs), render's inspect.rs (model.rs, annotations.rs, tests.rs), engine's lib.rs (comments.rs, edit/run.rs) and ui-egui's comments.rs (tools/comments/*). The behaviour and tests are the branch's (ISO 32000-2 §12.5.6.2): - a group subordinate is part of its primary's comment: listed, selected, exported and deleted with it, with the primary's attributes; - Replace Text writes the caret as the primary and the strikeout as its subordinate; earlier groups (strikeout primary, text on the caret) still list their text, through a map; - deleting a group refuses a locked member, and a subordinate whose primary is past the depth limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 44 ++++- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 64 +++++-- crates/annot/src/tests.rs | 138 +++++++++++++-- crates/automation/src/comments.rs | 24 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 175 +++++++++++++++++++- crates/engine/src/comments.rs | 19 ++- crates/engine/src/edit/run.rs | 4 +- crates/engine/src/tests.rs | 15 ++ crates/render/src/inspect.rs | 4 +- crates/render/src/inspect/annotations.rs | 5 +- crates/render/src/inspect/model.rs | 71 +++++++- crates/render/src/inspect/tests.rs | 128 ++++++++++++++ crates/render/src/lib.rs | 5 +- crates/ui-egui/src/panels/comments_panel.rs | 32 +++- crates/ui-egui/src/tools/comments/input.rs | 10 +- crates/ui-egui/src/tools/comments/paint.rs | 6 + crates/ui-egui/src/tools/comments/state.rs | 23 +++ crates/ui-egui/tests/it/comments.rs | 43 ++++- crates/xfdf/src/tests.rs | 12 +- parity/acrobat-features.toml | 8 +- 22 files changed, 768 insertions(+), 66 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..0266cc0cd9 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -23,7 +23,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]>, @@ -40,6 +41,15 @@ pub struct Summary { pub points: Vec<[f32; 2]>, } +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() + } +} + pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { let o = doc.resolve(d.get(key)?); let s = match &*o { @@ -70,6 +80,38 @@ 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() +} + +/// How far a group subordinate's `/IRT` chain is followed to its primary. +pub(crate) const MAX_GROUP_DEPTH: usize = 64; + +/// The primary of the group `r` belongs to: up the `/IRT` chain while it leads to another +/// subordinate in `list` (one page's `/Annots`). `r` itself when it is not a subordinate, or +/// when its primary is not on the page; the chain stops at a loop. `None` when it is still +/// on a subordinate after [`MAX_GROUP_DEPTH`] annotations: the group's primary is unknown, as +/// `pdfcraft_render::inspect::group_primaries` reads it (keep the two in step). +pub(crate) fn group_primary(doc: &Document, list: &[Object], r: ObjRef) -> Option { + let mut seen = vec![r]; + let mut at = r; + for _ in 0..MAX_GROUP_DEPTH { + let obj = doc.get(at); + let Some(next) = obj.as_dict().filter(|d| is_group_subordinate(d)).and_then(|d| d.reference(b"IRT")) else { return Some(at) }; + if seen.contains(&next) || !list.iter().any(|e| e.as_ref() == Some(next)) { + return Some(at); + } + seen.push(next); + at = next; + } + None +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..43b9d098e1 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -29,7 +29,7 @@ mod types; use create::num_array; pub use create::{add_annotation, callout_attach, 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::{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}; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..006bb14273 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -2,7 +2,7 @@ use super::create::{ add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{line_endings_of, text_value}; +use super::info::{MAX_GROUP_DEPTH, group_primary, is_group_subordinate, line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, @@ -70,33 +70,57 @@ 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 comment in a group +/// (`/RT /Group`) takes the whole group with it: deleting a subordinate deletes its primary, and +/// with it every member (ISO 32000-2 §12.5.6.2). A subordinate whose primary is too far up its +/// `/IRT` chain to find is refused rather than deleted with only part of its group, and so is a +/// group with a locked member. 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) } /// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without /// 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. +/// annotation is locked, or a reply (or group member) 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 group's primary for +/// a group subordinate, 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)?; + let Some(primary) = group_primary(doc, &list, r) else { + return Err(AnnotError::Invalid(format!( + "this comment can't be deleted: its group's primary is more than {MAX_GROUP_DEPTH} comments up its chain" + ))); + }; + if primary != r + && let Some((i, e)) = list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(primary)) + { + unlocked(doc, primary)?; + (target, index) = (e, i); + } } let doomed = with_replies_and_popups(doc, &list, seed(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. + // answers either; nor does a locked group member keep its place while its group goes + // (ISO 32000-2 §12.5.3, Table 167: Locked). Pop-ups follow their comment. for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(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 what = if is_group_subordinate(d) { "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 @@ -709,11 +733,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`, grouped with the strikeout (`/IRT` + `/RT /Group`) 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 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 { @@ -731,7 +761,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> meta, )?; let (_, sr) = annot_ref(doc, page, s)?; - doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; // The caret sits on the baseline at the end of the last line, its point at the text. let xs = [last[0], last[2], last[4], last[6]]; let ys = [last[1], last[3], last[5], last[7]]; @@ -746,11 +775,16 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { - d.set(b"IRT".to_vec(), Object::Ref(sr)); - d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"IT".to_vec(), Object::name("Replace")); d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); })?; - Ok(s) + doc.update_dict(sr, |d| { + 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(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 a3dc14e262..ff0daba407 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -891,22 +891,34 @@ 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(); - assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); - assert!(caret.reference(b"IRT").is_some()); + // 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 = caret.reference(b"IRT").unwrap(); - assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); + 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 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 the strikeout takes the caret with it (the group). + 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:?}"); + // Deleting either member deletes the group. + 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 mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); @@ -928,6 +940,110 @@ 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 subordinate deletes the whole group, with the primary's replies. +#[test] +fn a_group_reads_as_one_and_deletes_as_one() { + 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 doc = doc; + delete_annotation(&mut doc, 0, member).unwrap(); + let left = list(&doc, 0); + assert_eq!(left.len(), 1, "the group, its primary and the primary's replies are gone; the unrelated Square stays"); + assert_eq!(left[0].name(b"Subtype"), Some(&b"Square"[..])); + assert!(left[0].get(b"NM").is_none()); +} + +/// A subordinate whose `/IRT` chain reaches its primary within the 64 annotations the listing +/// follows deletes the whole group; one whose chain runs past them is refused, and the file +/// keeps every member, rather than losing only the part of the group below the cut +/// (ISO 32000-2 §12.5.6.2). +#[test] +fn a_group_chain_past_the_depth_limit_is_not_deleted_in_part() { + for (subordinates, deletes) in [(63, true), (64, false), (65, false)] { + 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(); + let result = delete_annotation(&mut doc, 0, last); + let left = list(&doc, 0).len(); + if deletes { + result.unwrap(); + assert_eq!(left, 1, "{subordinates}: the group is gone, the fixture's Square stays"); + } else { + assert!(matches!(result, Err(AnnotError::Invalid(_))), "{subordinates}: {result:?}"); + assert_eq!(left, before, "{subordinates}: nothing is deleted"); + } + } +} + +/// Deleting an unlocked subordinate takes every member of its group, so one locked member, +/// even one that is neither the subordinate nor the primary, refuses the whole deletion +/// (ISO 32000-2 §12.5.3, Table 167: Locked). +#[test] +fn a_locked_group_member_keeps_its_group() { + 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 result = delete_annotation(&mut doc, 0, members[0]); + assert!(matches!(result, Err(AnnotError::Invalid(_))), "{result:?}"); + assert_eq!(list(&doc, 0).len(), before, "nothing is deleted"); + + set_locked(&mut doc, 0, members[1], false).unwrap(); + delete_annotation(&mut doc, 0, members[0]).unwrap(); + assert_eq!(list(&doc, 0).len(), 1, "unlocked, the group goes; the fixture's Square stays"); +} + #[test] fn files_attach_as_comments() { let mut doc = fixture(); diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..554325dd03 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -177,7 +177,23 @@ 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(); + let mut primary_of: HashMap<(usize, usize), (usize, usize)> = 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); + primary_of.insert((m.page, m.index), (p.page, p.index)); + } + } 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, @@ -188,6 +204,7 @@ 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, }); // A line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. @@ -204,13 +221,14 @@ impl Automation { // 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. A status or // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the - // Comments panel: one set on a reply is not the comment's. + // Comments panel: one set on a reply is not the comment's. A group subordinate is in no + // thread, and a reply to one (status included) 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 { + for r in all.iter().filter(|r| !r.is_group_member()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; let key = match r.parent { - Some(parent) if r.state.is_some() => parent, + Some(parent) if r.state.is_some() => primary_of.get(&parent).copied().unwrap_or(parent), _ => top, }; threads.entry(key).or_default().push(r); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..1329e548d2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -739,7 +739,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). Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..34c837056b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2332,6 +2332,172 @@ 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 the group (one undo step), as deleting the primary does. + 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"], 1, "only the new note is left: {left}"); + assert!(!left.to_string().contains("sq-primary") && !left.to_string().contains("circ-sub"), "{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 its loop with it. + 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 info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0, "{info}"); +} + +// 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}"); + } +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -6173,8 +6339,13 @@ fn drawing_comments_through_tools() { // Replace Text: a strikeout over the found text and a grouped caret. ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page" })); let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 })); - let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone(); - assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout"); + let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); + // 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}"); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_)))); // A file attached as a comment. std::fs::write(dir.join("notes.txt"), b"remember").unwrap(); diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..2f8fa07ef8 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -27,6 +27,7 @@ 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("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", @@ -105,19 +107,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(); @@ -156,7 +165,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/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..bd2153e3bf 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -361,8 +361,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). + // 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( doc, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 833fa16b9f..a6ced87376 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4413,6 +4413,21 @@ 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}"); +} + #[test] fn reading_colors_set_and_invalidated() { let (mut s, id) = session_with(1); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..554fbbdd8a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -36,8 +36,8 @@ pub use core::{check_pages, inspect, pretty_date}; #[cfg(test)] use core::{inspect_structure, spend}; pub use model::{ - Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH, OutlineItem, + PageInfo, PageTransition, RemoteDestination, 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 603aa88110..2265d78e7f 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,5 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; -use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; +use super::model::{ + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, fill_group_text, link_replies, +}; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -190,6 +192,7 @@ 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); } fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..3686af8438 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -315,8 +315,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], @@ -343,6 +344,65 @@ pub struct Annotation { pub points: Vec<[f32; 2]>, } +/// 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 per primary wins, 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()) + && 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); + } + } +} + impl Annotation { /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. pub fn is_mark(&self) -> bool { @@ -361,6 +421,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)) diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..b17c155662 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1870,3 +1870,131 @@ trailer << /Root 1 0 R >> assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); 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(), + } + } + + /// `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"); + } + } + + 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"), + ] + ); + } + + /// 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}"); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 954823ef09..f623945a7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,8 +17,9 @@ pub mod text; pub mod transition; pub use inspect::{ - Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH, + OutlineItem, PageInfo, PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, fill_group_text, group_primaries, + inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index c549ed8e3e..58506efcda 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -40,7 +40,9 @@ 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() { - Some("StrikeOutTextEdit") => "Replace text", + // 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", Some("FreeTextTypeWriter") => "Typewriter", @@ -95,10 +97,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 { @@ -181,11 +183,29 @@ pub(crate) fn show( // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the - // reply). Page badges count roots before filtering. + // reply). Page badges count 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 primary_of: HashMap<(usize, usize), (usize, usize)> = if info.annotations.iter().any(Annotation::is_group_member) { + let all = &info.annotations; + all.iter() + .zip(pdfcraft_render::group_primaries(all)) + .filter_map(|(a, p)| all.get(p?).map(|p| ((a.page, a.index), (p.page, p.index)))) + .collect() + } else { + HashMap::new() + }; for a in &info.annotations { + if a.is_group_member() { + continue; + } if let Some(parent) = a.parent { - let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; + let key = if a.state.is_none() { + tops.get(&(a.page, a.index)).copied().unwrap_or(parent) + } else { + primary_of.get(&parent).copied().unwrap_or(parent) + }; replies.entry(key).or_default().push(a); } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5e74fdf56e..6e948bcf21 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -91,7 +91,13 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon)); } } - let a = cx.hit(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.hit(p) { + Some(a) => (a, false), + None => (cx.click_hit(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(cx, p) { return None; @@ -102,7 +108,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { // page, over a form field; text markup lies on the text, under one. let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment }; // 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, icon) }) + Some(Hit { declines_drag: markup || member, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) } /// Whether the comments take the press on this page (or, with no button down, would take one diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..93941a3814 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -34,6 +34,12 @@ 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); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index ab24352256..304c687b54 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -391,6 +391,29 @@ 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_hit(&self, p: Pos2) -> Option<&Annotation> { + self.hit(p) + .or_else(|| self.group_members().into_iter().rev().find(|(m, _)| self.screen_rects(m).any(|r| r.expand(3.0).contains(p))).map(|(_, p)| p)) + } + 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])] diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 664f61733e..3e078b6d49 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2205,7 +2205,9 @@ fn replacing_text_strikes_it_and_adds_a_caret() { let strike = c.iter().find(|a| a.subtype == "StrikeOut").expect("a strikeout"); let caret = c.iter().find(|a| a.subtype == "Caret").expect("a caret"); assert_eq!(caret.contents.as_deref(), Some("slow red")); - assert_eq!(caret.in_reply_to, strike.name, "grouped with the strikeout"); + // 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); // The strikeout is the Replace text tool's, not Strikethrough's (#3947): the panel names it @@ -2213,7 +2215,13 @@ fn replacing_text_strikes_it_and_adds_a_caret() { assert_eq!(pdfcraft_ui_egui::comments::tool_for(strike), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText)); h.state_mut().set_option("panel", "comments").unwrap(); h.run_steps(3); + // 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"); } @@ -3022,3 +3030,36 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.drop_at(from + delta); 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))); +} diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 0da17c1e67..c758635938 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -142,9 +142,10 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } -/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by -/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its -/// 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(); @@ -169,8 +170,8 @@ 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=\"strike\""), "the caret replies to the strikeout: {xfdf}"); - assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + 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(); let r = import(&mut dst, xfdf.as_bytes()).unwrap(); @@ -179,6 +180,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:?}"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..86d86d0a22 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3595,8 +3595,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::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect.rs::group_primaries_follow_irt_to_the_first_non_subordinate"] +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 deleting any member deletes the group (one undo step). Missing: moving or restyling a member does not move or restyle the group, a subordinate has no hover tooltip, and a subordinate whose primary is on another page is selected on neither" [[feature]] id = "comment.appearance-generation" @@ -3679,8 +3679,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 = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement" +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"] +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. Missing: moving the caret does not move the strikeout" [[feature]] id = "comment.insert-text" From 6085c9110eda60a17589f8693070469459399469 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:18:41 +0800 Subject: [PATCH 06/48] M5: a Replace Text group makes Replace Text defaults; an empty caret lends no text Review on #4358: - Make Current Properties Default on a Replace Text group selected the caret, the group's primary, and updated the Insert Text tool. A caret whose group holds a grouped /IT /StrikeOutTextEdit strikeout now updates the Replace Text tool; a caret of its own still updates Insert Text. Test: a_replace_text_group_makes_replace_text_defaults. - fill_group_text let an empty caret take the primary's slot, so a later caret with the replacement text was not listed. Only a caret with text lends it. Test: an_empty_caret_does_not_hide_the_next_carets_text. Both tests fail with their fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/render/src/inspect/model.rs | 5 ++-- crates/render/src/inspect/tests.rs | 13 +++++++++ crates/ui-egui/src/dialogs/comment_props.rs | 4 +-- crates/ui-egui/src/tools/comments.rs | 13 +++++++-- crates/ui-egui/src/tools/comments/tests.rs | 32 +++++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 3686af8438..8c9d75eb45 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -386,11 +386,12 @@ pub fn fill_group_text(all: &mut [Annotation]) { } 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 per primary wins, and each primary is - // written once, so the order of the map does not matter. + // 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()); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index b17c155662..afde323a7c 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1978,6 +1978,19 @@ mod group_tests { ); } + /// 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] diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 4702e63f9e..a4a9892ac0 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -99,7 +99,7 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; - 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; @@ -180,7 +180,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let (_, id) = app.active_ids()?; let doc = app.session.get(id)?; let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; - crate::comments::tool_for_default(a, d.edited.endings.as_deref()) + crate::comments::tool_for_default(&doc.info.annotations, a, d.edited.endings.as_deref()) }); 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/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index e9956e5f95..482dd85113 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -357,14 +357,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/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 91e796e43a..62cadb7298 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -482,3 +482,35 @@ fn text_boxes_fit_their_text() { assert_eq!(long[2], 300.0); 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(), + }; + 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)); +} From f43a8af538a52efb86b9bbe532a08270e19a5e61 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:51:24 +0800 Subject: [PATCH 07/48] M5: a right-click on a group member selects its group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #4358: the secondary-click path still selected with cx.hit, which misses a group subordinate lying outside its primary's rectangle and cleared the selection, so the menu offered page actions instead of the comment's. It now uses the group-aware cx.click_hit, as the click does (ISO 32000-2 §12.5.6.2). Double-click editing goes through the same group-aware hit as the click since the rebase onto main, where the press owner comes from the module's hit test; the test covers both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/tools/comments/input.rs | 3 ++- crates/ui-egui/tests/it/comments.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 6e948bcf21..ff1d42a309 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -31,7 +31,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, && let Some(p) = pointer.filter(|p| page_rect.contains(*p)) { cv.context_at = Some((cx.page, cx.to_user(p))); - cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); + // On a group subordinate, its group (the primary), so the menu offers the comment's actions. + cv.selected = cx.click_hit(p).map(|a| (cx.page, a.index)); } let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 3e078b6d49..dc64694db6 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3062,4 +3062,20 @@ fn a_group_is_one_comment_in_the_panel_and_on_the_page() { 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 edits the group's text, which is the Square's. + 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); + let composer = h.state().views[0].comments.composer.as_ref().map(|c| c.text.clone()); + assert_eq!(composer.as_deref(), Some("Primary"), "a double-click on the Circle edits its group's text"); } From 87a1778c826a35dd5c4ffc64b6c014a866f211ca Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:30:33 +0800 Subject: [PATCH 08/48] M5: parity evidence names the group test where main moved it The render inspect tests moved to crates/render/src/inspect/tests.rs on main; `cargo xtask parity` failed on the old path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 86d86d0a22..e4f2151558 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3595,7 +3595,7 @@ 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", "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::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect.rs::group_primaries_follow_irt_to_the_first_non_subordinate"] +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::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect/tests.rs::group_primaries_follow_irt_to_the_first_non_subordinate"] 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 deleting any member deletes the group (one undo step). Missing: moving or restyling a member does not move or restyle the group, a subordinate has no hover tooltip, and a subordinate whose primary is on another page is selected on neither" [[feature]] From 528357a38e384d26fab10fa89811590ab1d5a29c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:40:50 +0800 Subject: [PATCH 09/48] M6: custom stamps can carry a line filled in when they are placed (#1741) Create Custom Stamp offers an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed, in the built-in dynamic stamps' format, and kept with the stamp library. The picture keeps its shape above the line and the stamp widens to fit the line. stamp_custom takes the same line. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 57 ++++++++++++++++++++---- crates/annot/src/create.rs | 21 ++++++++- crates/annot/src/lib.rs | 2 +- crates/annot/src/types.rs | 6 ++- crates/automation/src/comments.rs | 12 ++++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 33 ++++++++++++++ crates/engine/src/edit.rs | 2 + crates/engine/src/edit/run.rs | 9 ++-- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session/view.rs | 24 ++++++++-- crates/engine/src/signature_image.rs | 1 + crates/engine/src/tests.rs | 44 +++++++++++++++++- crates/ui-egui/src/canvas.rs | 6 +++ crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/tools/stamps_ui.rs | 31 +++++++++++-- crates/ui-egui/tests/it/stamps.rs | 46 +++++++++++++++++++ parity/acrobat-features.toml | 4 +- 39 files changed, 322 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 56f6237e8d..a0d7599be4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,6 +431,7 @@ 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-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/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..3eb962f45d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -316,7 +316,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { b"FreeText" => ("the comment's text", 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, @@ -783,19 +785,58 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0); + // 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 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(sz)) => { + let k = (w / sz[0]).min(h / sz[1]).max(0.0); + let (pw, ph) = (sz[0] * k, sz[1] * 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)) + } + } + _ if image => format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0 + band)), + _ => { + let sz = size?; + format!("{} 0 0 {} {} {} cm", n(w / sz[0]), n(h / sz[1]), n(x0), n(y0 + band)) + } }; 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 mut stream = form(rect, &content, res); let turn = picture_turn(d); if image && turn != 0 { // The page's view matrix without its offset: display axes in user space. diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..522e5ec8b1 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -277,12 +277,15 @@ 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])); + if let Some(l) = line.as_deref().map(str::trim).filter(|l| !l.is_empty()) { + 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") { @@ -456,6 +459,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/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..8d615acae6 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -27,7 +27,7 @@ mod types; #[cfg(test)] use create::num_array; -pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use create::{CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, add_annotation, callout_attach, 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}; #[cfg(test)] diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..67eb18d605 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -391,13 +391,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 f87fd0b58e..68cbba2bbe 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -726,6 +726,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, @@ -735,7 +736,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/tools.rs b/crates/automation/src/tools.rs index c6c200cece..b6251c182a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -446,7 +446,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!({ @@ -458,6 +458,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"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..a769eb83d1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9414,6 +9414,39 @@ 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 line past the limit is cut, not refused. + let long = "x".repeat(500); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 3, "path": "b.pdf", "file_page": 2, "at": [100, 150], "line": long })); + assert!(page_text(&mut a, doc)[2].matches('x').count() <= 200); +} + /// 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. diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..83cbfba40a 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -150,12 +150,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, }, /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. DeleteAnnotation { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..4e1020ead5 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -325,9 +325,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddAnnotation(a) => { pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; } - Edit::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. @@ -339,7 +342,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())?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b49cfd4e1..635eb33f11 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -166,5 +166,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 c0e7417c8e..d581fc81bd 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -62,20 +62,36 @@ 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(); + let filled = template.trim().replace("{name}", author.trim()).replace("{date}", &date).replace("{time}", &time); + 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 ae8842ddfb..001bb33381 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -235,6 +235,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 333d0fd498..8891fe18c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -73,6 +73,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(); @@ -408,6 +409,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(); @@ -460,6 +462,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(); @@ -8463,7 +8466,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(); @@ -9409,3 +9413,41 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); 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"); + 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"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..10c22eaba1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -845,6 +845,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, @@ -1110,6 +1112,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< can_fill, can_modify, comments_hidden, + custom_line, custom_stamp, date_text, doc, @@ -1212,6 +1215,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< name: cs.name.clone(), file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, author: author.clone(), + line: custom_line.clone(), }); frame.out.stamp_placed = true; } @@ -3149,6 +3153,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; // The table being set up in its window, shown on its own document's page. let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); @@ -3187,6 +3192,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, diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 56f62b7734..00592becd3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -800,6 +800,8 @@ initials أحرف أولى signature توقيع Create Custom Stamp إنشاء ختم مخصص + Add a line filled in when the stamp is placed إضافة سطر يُملأ عند وضع الختم + {name}, {date} and {time} become your name, the date and the time. ‏{name} و{date} و{time} تصبح اسمك والتاريخ والوقت. From {file} من {file} Category: الفئة: e.g. My stamps مثلًا: أختامي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ceccdf9582..ac1db8523f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -463,6 +463,8 @@ initials инициали signature подпис Create Custom Stamp Създаване на персонализиран печат + Add a line filled in when the stamp is placed Добавяне на ред, попълван при поставяне на печата + {name}, {date} and {time} become your name, the date and the time. {name}, {date} и {time} стават вашето име, датата и часът. From {file} От {file} Category: Категория: e.g. My stamps напр. Моите печати diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8bfde72a85..62815c8469 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -880,6 +880,8 @@ key Space Mezerník initials iniciály signature podpis Create Custom Stamp Vytvořit vlastní razítko + Add a line filled in when the stamp is placed Přidat řádek vyplněný při umístění razítka + {name}, {date} and {time} become your name, the date and the time. {name}, {date} a {time} se nahradí vaším jménem, datem a časem. From {file} Ze souboru {file} Category: Kategorie: e.g. My stamps např. Moje razítka diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 83684c08ea..951a724987 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -778,6 +778,8 @@ initials Initialen signature Unterschrift Create Custom Stamp Eigenen Stempel erstellen + Add a line filled in when the stamp is placed Eine Zeile hinzufügen, die beim Platzieren des Stempels ausgefüllt wird + {name}, {date} and {time} become your name, the date and the time. {name}, {date} und {time} werden zu Ihrem Namen, dem Datum und der Uhrzeit. From {file} Aus {file} Category: Kategorie: e.g. My stamps z. B. Meine Stempel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 576cdfcd6d..2880446454 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2090,6 +2090,8 @@ initials μονογραφή signature υπογραφή Create Custom Stamp Δημιουργία προσαρμοσμένης σφραγίδας + Add a line filled in when the stamp is placed Προσθήκη γραμμής που συμπληρώνεται όταν τοποθετείται η σφραγίδα + {name}, {date} and {time} become your name, the date and the time. Τα {name}, {date} και {time} γίνονται το όνομά σας, η ημερομηνία και η ώρα. From {file} Από το {file} Category: Κατηγορία: e.g. My stamps π.χ. Οι σφραγίδες μου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3216e4e448..bd26a3a43a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -794,6 +794,8 @@ initials iniciales signature firma Create Custom Stamp Crear sello personalizado + Add a line filled in when the stamp is placed Añadir una línea que se rellena al colocar el sello + {name}, {date} and {time} become your name, the date and the time. {name}, {date} y {time} se convierten en su nombre, la fecha y la hora. From {file} De {file} Category: Categoría: e.g. My stamps p. ej., Mis sellos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6092231df3..b01e0b694a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -774,6 +774,8 @@ initials initiales signature signature Create Custom Stamp Créer un tampon personnalisé + Add a line filled in when the stamp is placed Ajouter une ligne remplie au moment de poser le tampon + {name}, {date} and {time} become your name, the date and the time. {name}, {date} et {time} deviennent votre nom, la date et l’heure. From {file} Depuis {file} Category: Catégorie : e.g. My stamps p. ex. Mes tampons diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53b81062bb..5c068bf8ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -771,6 +771,8 @@ initials monogram signature aláírás Create Custom Stamp Egyéni bélyegző létrehozása + Add a line filled in when the stamp is placed A bélyegző elhelyezésekor kitöltött sor hozzáadása + {name}, {date} and {time} become your name, the date and the time. A {name}, a {date} és a {time} helyére a neve, a dátum és az időpont kerül. From {file} Ebből: {file} Category: Kategória: e.g. My stamps pl. Saját bélyegzők diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a3dc999d6d..3e1e8d94d5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -797,6 +797,8 @@ initials iniziali signature firma Create Custom Stamp Crea timbro personalizzato + Add a line filled in when the stamp is placed Aggiungi una riga compilata quando il timbro viene posizionato + {name}, {date} and {time} become your name, the date and the time. {name}, {date} e {time} diventano il tuo nome, la data e l’ora. From {file} Da {file} Category: Categoria: e.g. My stamps es. I miei timbri diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e64a1a8ce..94f1971e97 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -801,6 +801,8 @@ initials イニシャル signature 署名 Create Custom Stamp カスタムスタンプを作成 + Add a line filled in when the stamp is placed スタンプを配置したときに埋められる行を追加 + {name}, {date} and {time} become your name, the date and the time. {name}、{date}、{time} はあなたの名前、日付、時刻になります。 From {file} 元ファイル: {file} Category: カテゴリ: e.g. My stamps 例: マイスタンプ diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 5c8e594759..b09316c0d9 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -757,6 +757,8 @@ initials инициалдар signature қолтаңба Create Custom Stamp Өзіндік мөр жасау + Add a line filled in when the stamp is placed Мөр қойылғанда толтырылатын жол қосу + {name}, {date} and {time} become your name, the date and the time. {name}, {date} және {time} сіздің атыңызға, күнге және уақытқа айналады. From {file} {file} файлынан Category: Санаты: e.g. My stamps мысалы, Менің мөрлерім diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 61fd9dc9a2..83200b06c4 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -413,6 +413,8 @@ Counterclockwise 90 degrees 90 laipsnių prieš laikrodžio rodyklę Country/Region Šalis / regionas Create Custom Stamp Kurti pasirinktinį antspaudą + Add a line filled in when the stamp is placed Pridėti eilutę, užpildomą uždedant spaudą + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ir {time} tampa jūsų vardu, data ir laiku. Create Link Kurti saitą Create PDF Comment Summary Kurti PDF komentarų suvestinę Create PDF comment summary… Kurti PDF komentarų suvestinę… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 203cad58d8..6c67d19e39 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -793,6 +793,8 @@ initials inicjały signature podpis Create Custom Stamp Utwórz własny stempel + Add a line filled in when the stamp is placed Dodaj wiersz wypełniany przy umieszczaniu stempla + {name}, {date} and {time} become your name, the date and the time. {name}, {date} i {time} zostaną zastąpione Twoim imieniem, datą i godziną. From {file} Z {file} Category: Kategoria: e.g. My stamps np. Moje stemple diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 45f827171b..d7897ebf91 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -418,6 +418,8 @@ initials iniciais signature assinatura Create Custom Stamp Criar carimbo personalizado + Add a line filled in when the stamp is placed Adicionar uma linha preenchida quando o carimbo for colocado + {name}, {date} and {time} become your name, the date and the time. {name}, {date} e {time} se tornam seu nome, a data e a hora. From {file} De {file} Category: Categoria: e.g. My stamps ex.: Meus carimbos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59c483526a..8e4f6ed31f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -794,6 +794,8 @@ initials инициалы signature подпись Create Custom Stamp Создать свой штамп + Add a line filled in when the stamp is placed Добавить строку, заполняемую при размещении штампа + {name}, {date} and {time} become your name, the date and the time. {name}, {date} и {time} заменяются вашим именем, датой и временем. From {file} Из {file} Category: Категория: e.g. My stamps например, Мои штампы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 83cb594158..0e85b93af5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -484,6 +484,8 @@ Country/Region Država/regija Create Ustvari Create Custom Stamp Ustvari lasten žig + Add a line filled in when the stamp is placed Dodaj vrstico, ki se izpolni ob postavitvi žiga + {name}, {date} and {time} become your name, the date and the time. {name}, {date} in {time} postanejo vaše ime, datum in čas. Create Link Ustvari povezavo Create PDF Comment Summary Ustvari povzetek komentarjev PDF Create PDF comment summary… Ustvari povzetek komentarjev PDF… diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0bb56f4793..0f6813c975 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -775,6 +775,8 @@ comment menu Edit సవరించు initials పొడి అక్షరాలు signature సంతకం Create Custom Stamp అనుకూల స్టాంప్‌ను సృష్టించు + Add a line filled in when the stamp is placed స్టాంప్ ఉంచినప్పుడు నింపబడే పంక్తిని జోడించండి + {name}, {date} and {time} become your name, the date and the time. {name}, {date} మరియు {time} మీ పేరు, తేదీ మరియు సమయంగా మారతాయి. From {file} {file} నుండి Category: వర్గం: e.g. My stamps ఉదా. నా స్టాంప్‌లు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b52688606b..04594d55f1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -756,6 +756,8 @@ initials paraf signature imza Create Custom Stamp Özel Damga Oluştur + Add a line filled in when the stamp is placed Damga yerleştirildiğinde doldurulan bir satır ekle + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ve {time} adınız, tarih ve saat olur. From {file} {file} dosyasından Category: Kategori: e.g. My stamps ör. Damgalarım diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dc65faf7a2..dea4558114 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -798,6 +798,8 @@ initials ініціали signature підпис Create Custom Stamp Створити власний штамп + Add a line filled in when the stamp is placed Додати рядок, що заповнюється під час розміщення штампа + {name}, {date} and {time} become your name, the date and the time. {name}, {date} і {time} стають вашим ім’ям, датою та часом. From {file} Із {file} Category: Категорія: e.g. My stamps наприклад Мої штампи diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5f08576b8e..9df1035ac3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -469,6 +469,8 @@ initials 姓名首字母 signature 签名 Create Custom Stamp 创建自定义图章 + Add a line filled in when the stamp is placed 添加一行,在放置图章时填入 + {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 会变成你的姓名、日期和时间。 From {file} 来自 {file} Category: 类别: e.g. My stamps 例如:我的图章 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2570f12f1c..d019b5a373 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,6 +422,9 @@ initials 姓名首字母 signature 簽名 Create Custom Stamp 建立自訂戳記 + Create Custom Stamp 建立自定義戳記 + Add a line filled in when the stamp is placed 新增一行,在放置圖章時填入 + {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 會變成你的姓名、日期與時間。 From {file} 來自 {file} Category: 類別: e.g. My stamps 例如:我的戳記 diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..ba520fe257 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -23,8 +23,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; @@ -48,6 +55,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) { @@ -81,8 +91,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() { @@ -163,14 +180,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/stamps.rs b/crates/ui-egui/tests/it/stamps.rs index 767cc7eb4c..c161488778 100644 --- a/crates/ui-egui/tests/it/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -140,3 +140,49 @@ 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 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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..f8aab00584 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4195,8 +4195,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" From 68f7ef9d10ed84d797cefc253283253048557c2a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:25:30 +0800 Subject: [PATCH 10/48] M6: Vietnamese for the custom stamp line; one Traditional Chinese entry for Create Custom Stamp (#1741) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/vi.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e9a9a73f2b..a100af2a11 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2845,3 +2845,7 @@ key Space Space # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất + +# AI-assisted additions for dynamic lines on custom stamps (#1741) (2026-10-16); not reviewed by a native speaker. + Add a line filled in when the stamp is placed Thêm một dòng được điền khi đặt con dấu + {name}, {date} and {time} become your name, the date and the time. {name}, {date} và {time} sẽ thành tên của bạn, ngày và giờ. diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d019b5a373..54ed8a3122 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,7 +422,6 @@ initials 姓名首字母 signature 簽名 Create Custom Stamp 建立自訂戳記 - Create Custom Stamp 建立自定義戳記 Add a line filled in when the stamp is placed 新增一行,在放置圖章時填入 {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 會變成你的姓名、日期與時間。 From {file} 來自 {file} From 00566d74ea5a9995bcc89771f6fa1439d77935c5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:46:08 +0800 Subject: [PATCH 11/48] i18n: translate the custom-stamp line strings into the newer catalogues (#1741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalan, Sorani, Finnish, Hindi, Indonesian, Korean, Lao, Norwegian Bokmål, Romanian, Albanian and Thai joined main after this branch was written. AI-assisted, as noted in each catalogue. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/ckb.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/lo.tsv | 2 ++ crates/ui-egui/src/i18n/nb.tsv | 2 ++ crates/ui-egui/src/i18n/ro.tsv | 2 ++ crates/ui-egui/src/i18n/sq.tsv | 2 ++ crates/ui-egui/src/i18n/th.tsv | 2 ++ 11 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 83750ba124..fae4b88a79 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2815,6 +2815,8 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + Add a line filled in when the stamp is placed Afegeix una línia que s'emplena en col·locar el segell + {name}, {date} and {time} become your name, the date and the time. {name}, {date} i {time} es converteixen en el vostre nom, la data i l'hora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index f6d91e3f42..5b91cf8009 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2743,6 +2743,8 @@ certificate Version وەشان No tools match your search. هیچ ئامرازێک لەگەڵ گەڕانەکەت ناگونجێت. Expand all tools فراوانکردنی هەموو ئامرازەکان Collapse all tools کۆکردنەوەی هەموو ئامرازەکان + Add a line filled in when the stamp is placed هێڵێک زیاد بکە کە لە کاتی دانانی مۆرەکەدا پڕ دەکرێتەوە + {name}, {date} and {time} become your name, the date and the time. {name}، {date} و {time} دەبنە ناوت، بەروار و کات. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 80d2a0f859..78d0792b6c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2824,6 +2824,8 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + Add a line filled in when the stamp is placed Lisää rivi, joka täytetään, kun leima sijoitetaan + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ja {time} korvataan nimelläsi, päivämäärällä ja kellonajalla. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index dfaa94b5d6..b6f17f734b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2786,6 +2786,8 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + Add a line filled in when the stamp is placed स्टाम्प लगाते समय भरी जाने वाली एक पंक्ति जोड़ें + {name}, {date} and {time} become your name, the date and the time. {name}, {date} और {time} आपके नाम, दिनांक और समय में बदल जाते हैं। # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0eb02928f1..a35f33b4f0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2787,6 +2787,8 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + Add a line filled in when the stamp is placed Tambahkan baris yang diisi saat stempel ditempatkan + {name}, {date} and {time} become your name, the date and the time. {name}, {date}, dan {time} menjadi nama Anda, tanggal, dan waktu. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1e461bbdff..eb20e0d6e4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2786,6 +2786,8 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + Add a line filled in when the stamp is placed 스탬프를 배치할 때 채워지는 줄 추가 + {name}, {date} and {time} become your name, the date and the time. {name}, {date}, {time}은(는) 이름, 날짜, 시간으로 바뀝니다. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index c4d45c90d1..6e0678c528 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2459,6 +2459,8 @@ animation effects Off ປິດ No tools match your search. ບໍ່ມີເຄື່ອງມືທີ່ກົງກັບການຄົ້ນຫາຂອງທ່ານ. Expand all tools ຂະຫຍາຍເຄື່ອງມືທັງໝົດ Collapse all tools ຫຍໍ້ເຄື່ອງມືທັງໝົດເຂົ້າ + Add a line filled in when the stamp is placed ເພີ່ມແຖວທີ່ຈະຖືກຕື່ມເມື່ອວາງກາປະທັບ + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ແລະ {time} ຈະກາຍເປັນຊື່ຂອງທ່ານ, ວັນທີ ແລະ ເວລາ. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index e81b84e341..5b59d45084 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2724,6 +2724,8 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + Add a line filled in when the stamp is placed Legg til en linje som fylles ut når stempelet plasseres + {name}, {date} and {time} become your name, the date and the time. {name}, {date} og {time} blir navnet ditt, datoen og klokkeslettet. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today I dag diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 6b23515b8e..bce146d530 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2815,6 +2815,8 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + Add a line filled in when the stamp is placed Adaugă un rând completat la plasarea ștampilei + {name}, {date} and {time} become your name, the date and the time. {name}, {date} și {time} devin numele dvs., data și ora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index e5a8f5b037..253e2c0351 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2541,6 +2541,8 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + Add a line filled in when the stamp is placed Shto një rresht që plotësohet kur vendoset vula + {name}, {date} and {time} become your name, the date and the time. {name}, {date} dhe {time} bëhen emri juaj, data dhe ora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 0c2b58a812..c108623fdc 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2711,6 +2711,8 @@ key Space Space Collapse all tools ยุบเครื่องมือทั้งหมด The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + Add a line filled in when the stamp is placed เพิ่มบรรทัดที่กรอกเมื่อวางตราประทับ + {name}, {date} and {time} become your name, the date and the time. {name}, {date} และ {time} จะกลายเป็นชื่อของคุณ วันที่ และเวลา # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today วันนี้ From f5575cf78279a9e8b424a8d10a4737edd9c5092c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:25:30 +0800 Subject: [PATCH 12/48] M6: a custom stamp's line fills in the template's placeholders only (#1741) Review on #2345 (gabglus): the line was filled by chained replaces, so a placeholder inside the author's name ("Ada {date}") was filled in too. stamp_line now walks the template once and expands only the {name}, {date} and {time} it finds there; the author's text is kept as written. Trimming and the MAX_STAMP_LINE cut are unchanged. The loop advances by whole characters (strip_prefix, or get() past the next char), so it cannot panic. stamp_lines_fill_in_the_name_date_and_time gains the review's case and a name made only of a placeholder; both fail with the chained replaces. The UI test checks the saved /PCByLine has no placeholder left (from gabglus's commit f6760b37). Co-Authored-By: gabglus Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session/view.rs | 20 +++++++++++++++++++- crates/engine/src/tests.rs | 3 +++ crates/ui-egui/tests/it/stamps.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index d581fc81bd..afb38f3d65 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -74,7 +74,25 @@ impl Session { /// 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(); - let filled = template.trim().replace("{name}", author.trim()).replace("{date}", &date).replace("{time}", &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() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8891fe18c3..fc22d78d0a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9420,6 +9420,9 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() 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); diff --git a/crates/ui-egui/tests/it/stamps.rs b/crates/ui-egui/tests/it/stamps.rs index c161488778..1cea1cf638 100644 --- a/crates/ui-egui/tests/it/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -177,6 +177,21 @@ fn a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed() { 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]); From 5408fc83211b441f81a2fe8c568402a1833b3abd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:36:02 +0800 Subject: [PATCH 13/48] M6: main's fitted-stamp test builds AddCustomStamp with no line (#1741) main's #4077 test (a custom stamp fitted inside a box) builds Edit::AddCustomStamp without the line field this branch adds, so the engine tests stopped compiling after the rebase. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fc22d78d0a..aa8a57b85e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -492,6 +492,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]); From 22d0e4cc4e035c7d41a3014a9ebb1a42b21cf1d6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:40:14 +0800 Subject: [PATCH 14/48] M6: a lined custom stamp keeps its layout on quarter-turned pages (#1741) On a page turned 90 or 270 degrees the stamp's rectangle has its sides swapped in user space, and the appearance is counter-rotated so viewers fit its turned bounding box to /Rect (ISO 32000-2 12.5.5). The lined layout was computed in user-space axes, so that fit scaled the two displayed axes differently: the picture came out squashed and the band stretched. The layout is now made in the displayed box (the rectangle with its sides swapped) and the stamp records the page's turn in /PCPictureRotate, so the appearance carries the counter-rotation itself and a later restyle rebuilds it the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 16 ++++++++---- crates/annot/src/create.rs | 8 +++--- crates/engine/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 3eb962f45d..dc22103d54 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -780,16 +780,23 @@ 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) { - let [x0, y0, x1, y1] = rect; let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0); // 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) { @@ -836,9 +843,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - 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, &content, 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]; diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 522e5ec8b1..9d2e70b390 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -283,12 +283,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R 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])); - if let Some(l) = line.as_deref().map(str::trim).filter(|l| !l.is_empty()) { + 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)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index aa8a57b85e..260825201c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9455,3 +9455,48 @@ fn a_custom_stamp_line_keeps_accented_letters() { }); 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"); + } +} From a26f8db0bcc13bc81a0e59200d20a348565d331e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:40:14 +0800 Subject: [PATCH 15/48] M6: stamp_custom's line test uses a template the schema accepts (#1741) The test sent a 500-character line, past the schema's maxLength of 200, and only checked an upper bound on the page text, which also passes when the line is missing. It now sends a 198-character template whose filled-in line runs to 264 characters, and checks the saved /PCByLine holds exactly 200. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/tests/it/automation.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a769eb83d1..3eabcfee13 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9441,10 +9441,20 @@ fn a_custom_stamp_line_is_filled_in_when_placed() { // 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 line past the limit is cut, not refused. - let long = "x".repeat(500); + // 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 })); - assert!(page_text(&mut a, doc)[2].matches('x').count() <= 200); + 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 From 665b361d5cc00191c23aba76a655fa26058d348e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:04:57 +0800 Subject: [PATCH 16/48] M5: a group is one page's, as Acrobat reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subordinate (/RT /Group) whose /IRT primary is on another page is now a comment of its own, listed with its own author and text, rather than part of that group. ISO 32000-2 §12.5.6.2 doesn't limit a group to a page, but Acrobat (desktop and iPad) and PDF-XChange all keep such a subordinate when the primary is deleted, and Acrobat desktop lists it on its own; tested on a synthetic two-page file. pdfcraft_render::link_replies and pdfcraft_annot::summaries both apply the rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 8 ++++- crates/automation/tests/it/automation.rs | 40 ++++++++++++++++++++++++ crates/render/src/inspect/model.rs | 12 +++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 0266cc0cd9..da3316a755 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -165,6 +165,12 @@ pub fn summaries(doc: &Document) -> Vec { 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, @@ -177,7 +183,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, state: text_value(doc, d, b"State"), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 34c837056b..686f91d811 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10361,3 +10361,43 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } write_full(&doc, &SaveOptions::default()).unwrap() } + +// 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}"); +} diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 8c9d75eb45..056c9a7009 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -517,6 +517,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 From bcbc122de58678b00c51054979dc5e0cd62d926b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:29:12 +0800 Subject: [PATCH 17/48] M5: the group parity note says what deleting does across readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting any member deletes its group on the page, as ISO 32000-2 §12.5.6.2 has it and PDF-XChange and Foxit do; a subordinate whose primary is on another page is a comment of its own and stays. The note's Missing list no longer says such a subordinate can't be selected, and the cross-page test is its evidence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e4f2151558..c4f8250727 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3595,8 +3595,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", "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::looping_groups_end_and_chained_groups_reach_their_primary", "crates/render/src/inspect/tests.rs::group_primaries_follow_irt_to_the_first_non_subordinate"] -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 deleting any member deletes the group (one undo step). Missing: moving or restyling a member does not move or restyle the group, a subordinate has no hover tooltip, and a subordinate whose primary is on another page is selected on neither" +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"] +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 deleting any member deletes its group on the page (one undo step), as ISO 32000-2 §12.5.6.2 has it and PDF-XChange and Foxit do (Acrobat desktop deletes only the member clicked). A group is one page's: a subordinate whose primary is on another page is a comment of its own, listed, selected and deleted on its own, and it stays when the primary is deleted, as Acrobat (desktop and iPad) and PDF-XChange read it. Missing: moving or restyling a member does not move or restyle the group, and a subordinate has no hover tooltip" [[feature]] id = "comment.appearance-generation" From 8845066ab7e7f9c97fa228b2db4d511430728ecf Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:59:47 +0800 Subject: [PATCH 18/48] M5: Delete deletes the group member clicked, as Acrobat does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acrobat desktop deletes only the member of a group (/RT /Group) that was clicked: deleting the primary keeps its subordinates, on its page or another, and deleting a subordinate keeps the rest of its group. ISO 32000-2 §12.5.6.2 lists deletion among the operations that act on the entire group; this follows Acrobat. A subordinate left without its primary is a comment of its own. Replace Text stays one comment: its strikeout and caret go together from either, in both orientations (the caret as primary, and the strikeout as primary, as earlier PdfCraft builds wrote it), as Acrobat deletes the pair. On the page, a click on a subordinate still selects its group, and Delete (the key or the menu) acts on the member clicked. The group-primary lookup that deletion used is gone with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 23 ------- crates/annot/src/modify.rs | 78 ++++++++++++++-------- crates/annot/src/tests.rs | 65 +++++++++--------- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 33 +++++++-- crates/redact/src/lib.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 4 +- crates/ui-egui/src/tools/comments/menu.rs | 8 ++- crates/ui-egui/src/tools/comments/state.rs | 29 +++++++- crates/ui-egui/tests/it/comments.rs | 28 ++++++++ parity/acrobat-features.toml | 2 +- 11 files changed, 173 insertions(+), 101 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index da3316a755..7f58ec299a 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -89,29 +89,6 @@ pub fn is_group_subordinate(d: &Dict) -> bool { d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some() } -/// How far a group subordinate's `/IRT` chain is followed to its primary. -pub(crate) const MAX_GROUP_DEPTH: usize = 64; - -/// The primary of the group `r` belongs to: up the `/IRT` chain while it leads to another -/// subordinate in `list` (one page's `/Annots`). `r` itself when it is not a subordinate, or -/// when its primary is not on the page; the chain stops at a loop. `None` when it is still -/// on a subordinate after [`MAX_GROUP_DEPTH`] annotations: the group's primary is unknown, as -/// `pdfcraft_render::inspect::group_primaries` reads it (keep the two in step). -pub(crate) fn group_primary(doc: &Document, list: &[Object], r: ObjRef) -> Option { - let mut seen = vec![r]; - let mut at = r; - for _ in 0..MAX_GROUP_DEPTH { - let obj = doc.get(at); - let Some(next) = obj.as_dict().filter(|d| is_group_subordinate(d)).and_then(|d| d.reference(b"IRT")) else { return Some(at) }; - if seen.contains(&next) || !list.iter().any(|e| e.as_ref() == Some(next)) { - return Some(at); - } - seen.push(next); - at = next; - } - None -} - /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 006bb14273..ace7938cd4 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -2,7 +2,7 @@ use super::create::{ add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{MAX_GROUP_DEPTH, group_primary, is_group_subordinate, line_endings_of, text_value}; +use super::info::{is_group_subordinate, line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, @@ -70,11 +70,11 @@ 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. A comment in a group -/// (`/RT /Group`) takes the whole group with it: deleting a subordinate deletes its primary, and -/// with it every member (ISO 32000-2 §12.5.6.2). A subordinate whose primary is too far up its -/// `/IRT` chain to find is refused rather than deleted with only part of its group, and so is a -/// group with a locked member. +/// 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. 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> { let index = deleted_index(doc, page, index)?; lift_annotation(doc, page, index) @@ -82,41 +82,42 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul /// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without /// changing anything: what a Delete command asks before it is offered. It is refused while the -/// annotation is locked, or a reply (or group member) that would go with it is. +/// 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 group's primary for -/// a group subordinate, else `index`. +/// [`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 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)?; - let Some(primary) = group_primary(doc, &list, r) else { - return Err(AnnotError::Invalid(format!( - "this comment can't be deleted: its group's primary is more than {MAX_GROUP_DEPTH} comments up its chain" - ))); - }; - if primary != r - && let Some((i, e)) = list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(primary)) - { - unlocked(doc, primary)?; - (target, index) = (e, i); - } + } + // 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(target)); // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it - // answers either; nor does a locked group member keep its place while its group goes - // (ISO 32000-2 §12.5.3, Table 167: Locked). 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 { - let what = if is_group_subordinate(d) { "a comment in its group is locked" } else { "a reply to it is locked" }; + 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())); } } @@ -131,7 +132,7 @@ 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(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(); Ok(set_annots(doc, p, kept)?) } @@ -145,16 +146,35 @@ fn seed(target: &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. @@ -166,7 +186,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); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index ff0daba407..11e32c1791 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -913,7 +913,8 @@ fn replace_text_groups_a_strikeout_and_a_caret() { assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); 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:?}"); - // Deleting either member deletes the group. + // 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(); @@ -942,9 +943,11 @@ 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 subordinate deletes the whole group, with the primary's replies. +/// 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_as_one() { +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(); @@ -972,21 +975,23 @@ fn a_group_reads_as_one_and_deletes_as_one() { [("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, member).unwrap(); - let left = list(&doc, 0); - assert_eq!(left.len(), 1, "the group, its primary and the primary's replies are gone; the unrelated Square stays"); - assert_eq!(left[0].name(b"Subtype"), Some(&b"Square"[..])); - assert!(left[0].get(b"NM").is_none()); + 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 subordinate whose `/IRT` chain reaches its primary within the 64 annotations the listing -/// follows deletes the whole group; one whose chain runs past them is refused, and the file -/// keeps every member, rather than losing only the part of the group below the cut -/// (ISO 32000-2 §12.5.6.2). +/// 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_group_chain_past_the_depth_limit_is_not_deleted_in_part() { - for (subordinates, deletes) in [(63, true), (64, false), (65, false)] { +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(); @@ -1002,23 +1007,15 @@ fn a_group_chain_past_the_depth_limit_is_not_deleted_in_part() { up = r; } let before = list(&doc, 0).len(); - let result = delete_annotation(&mut doc, 0, last); - let left = list(&doc, 0).len(); - if deletes { - result.unwrap(); - assert_eq!(left, 1, "{subordinates}: the group is gone, the fixture's Square stays"); - } else { - assert!(matches!(result, Err(AnnotError::Invalid(_))), "{subordinates}: {result:?}"); - assert_eq!(left, before, "{subordinates}: nothing is deleted"); - } + 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"); } } -/// Deleting an unlocked subordinate takes every member of its group, so one locked member, -/// even one that is neither the subordinate nor the primary, refuses the whole deletion -/// (ISO 32000-2 §12.5.3, Table 167: Locked). +/// 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_keeps_its_group() { +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(); @@ -1035,13 +1032,13 @@ fn a_locked_group_member_keeps_its_group() { } set_locked(&mut doc, 0, members[1], true).unwrap(); let before = list(&doc, 0).len(); - let result = delete_annotation(&mut doc, 0, members[0]); - assert!(matches!(result, Err(AnnotError::Invalid(_))), "{result:?}"); - assert_eq!(list(&doc, 0).len(), before, "nothing is deleted"); - - set_locked(&mut doc, 0, members[1], false).unwrap(); - delete_annotation(&mut doc, 0, members[0]).unwrap(); - assert_eq!(list(&doc, 0).len(), 1, "unlocked, the group goes; the fixture's Square stays"); + 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"); } #[test] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1329e548d2..a9ce3b3033 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -739,7 +739,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). Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`.") + 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). 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( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 686f91d811..11043e7b33 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2382,11 +2382,17 @@ fn a_group_lists_as_its_primary() { check(&json!({ "count": 1, "comments": [primary] }), "edited"); assert_eq!(after["count"], 2, "{after}"); - // Deleting a member deletes the group (one undo step), as deleting the primary does. + // 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"], 1, "only the new note is left: {left}"); - assert!(!left.to_string().contains("sq-primary") && !left.to_string().contains("circ-sub"), "{left}"); + 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(); @@ -2426,12 +2432,18 @@ fn looping_groups_end_and_chained_groups_reach_their_primary() { 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 its loop with it. + // 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 info = ok(&mut a, "doc_info", json!({ "doc": doc })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0, "{info}"); + 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 @@ -2495,6 +2507,15 @@ fn replace_text_lists_its_replacement() { 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 })); + } } } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..f71400daef 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -752,7 +752,7 @@ 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(); // 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() { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ff1d42a309..7bb03b5eb8 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -33,6 +33,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.context_at = Some((cx.page, cx.to_user(p))); // On a group subordinate, its group (the primary), so the menu offers the comment's actions. cv.selected = cx.click_hit(p).map(|a| (cx.page, a.index)); + cv.member = cx.picked(p, cv.selected); } let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { @@ -264,6 +265,7 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu match owner { Some((a, _)) => { cv.selected = Some((cx.page, a.index)); + cv.member = pointer.and_then(|p| cx.picked(p, cv.selected)); cv.reveal = true; consumed = true; } @@ -599,7 +601,7 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } if allowed - && let Some((page, index)) = cv.selected + && let Some((page, index)) = cv.target() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { cv.selected = None; diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 52ea8d707d..77f0a33870 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -92,10 +92,12 @@ pub(crate) fn context_menu( ui.close(); } ui.separator(); - // The engine's own check: a locked comment, or one with a locked reply, stays. - if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() { + // The engine's own check: a locked comment, or one with a 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)); + if ui.add_enabled(allowed && deletable(dpage, dindex), egui::Button::new(tl!("Delete"))).clicked() { view.comments.selected = None; - action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); + action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page: dpage, index: dindex }))); ui.close(); } if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 304c687b54..5c15154291 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -126,6 +126,10 @@ impl CommentView { pub struct CommentView { /// The selected comment: (page, index in `/Annots`). pub selected: Option<(usize, usize)>, + /// 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). @@ -186,6 +190,15 @@ pub(crate) enum EditorFocus { } impl CommentView { + /// 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, + } + } + /// Takes back the last point of a polygon, cloud or connected lines being drawn; the first /// point's removal cancels the drawing. Returns whether there was such a drawing. pub fn undo_point(&mut self) -> bool { @@ -410,8 +423,20 @@ impl PageCx<'_> { /// 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_hit(&self, p: Pos2) -> Option<&Annotation> { - self.hit(p) - .or_else(|| self.group_members().into_iter().rev().find(|(m, _)| self.screen_rects(m).any(|r| r.expand(3.0).contains(p))).map(|(_, p)| p)) + self.hit(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.hit(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] { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index dc64694db6..88f357fb5e 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3079,3 +3079,31 @@ fn a_group_is_one_comment_in_the_panel_and_on_the_page() { let composer = h.state().views[0].comments.composer.as_ref().map(|c| c.text.clone()); assert_eq!(composer.as_deref(), Some("Primary"), "a double-click on the Circle edits its group's text"); } + +/// 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c4f8250727..3c0d3e6649 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3596,7 +3596,7 @@ 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", "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"] -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 deleting any member deletes its group on the page (one undo step), as ISO 32000-2 §12.5.6.2 has it and PDF-XChange and Foxit do (Acrobat desktop deletes only the member clicked). A group is one page's: a subordinate whose primary is on another page is a comment of its own, listed, selected and deleted on its own, and it stays when the primary is deleted, as Acrobat (desktop and iPad) and PDF-XChange read it. Missing: moving or restyling a member does not move or restyle the group, and a subordinate has no hover tooltip" +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; 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" From d1e1357775b18550a8c32f11e4dc144e8166ad4f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:05:22 +0800 Subject: [PATCH 19/48] M5: comment pop-up windows on the page, with connector lines A double-click on a comment opens its pop-up window as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it, x closes it, each an undo step saved in /Popup (/Open, /Rect) and a note's own /Open. A connector line in the comment's colour joins the comment to its open window on roll-over or selection. comment_popup does the same headlessly; comment_list reports the pop-up. Three undo labels in 22 catalogues (Vietnamese included). Ported onto main's split modules: set_popup lives in annot's modify.rs and PopupInfo in info.rs; the pop-up windows are their own module, ui-egui tools/comments/popups.rs, listed with the page modules that read the pointer and set the cursor themselves; the hover tip filter is in comments::describe. PageCx's pointer and redaction look, the tests/it evidence paths and the Annotation test fixtures follow main (formerly fcd33e131, c68e5d902, 0a7fb530b, 8af2c338e, 345c1573d), and labels are logical text now that egui shapes bidi itself. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- ROADMAP.md | 1 + crates/annot/README.md | 4 +- crates/annot/src/info.rs | 24 +++ crates/annot/src/lib.rs | 4 +- crates/annot/src/modify.rs | 73 ++++++- crates/annot/src/tests.rs | 45 ++++ crates/automation/src/comments.rs | 13 ++ crates/automation/src/dispatch.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/it/automation.rs | 26 +++ crates/engine/src/comments.rs | 1 + crates/engine/src/edit.rs | 8 + crates/engine/src/edit/labels.rs | 6 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/render/src/inspect.rs | 2 +- crates/render/src/inspect/annotations.rs | 10 +- crates/render/src/inspect/model.rs | 11 + crates/render/src/inspect/tests.rs | 13 ++ crates/render/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 5 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 13 +- crates/ui-egui/src/canvas/xform.rs | 5 + crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/tools/comments.rs | 2 + crates/ui-egui/src/tools/comments/input.rs | 13 +- crates/ui-egui/src/tools/comments/paint.rs | 2 + crates/ui-egui/src/tools/comments/popups.rs | 196 ++++++++++++++++++ crates/ui-egui/src/tools/comments/state.rs | 9 + crates/ui-egui/src/tools/comments/tests.rs | 4 + crates/ui-egui/tests/it/comments.rs | 65 ++++++ parity/acrobat-features.toml | 14 +- 54 files changed, 629 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/tools/comments/popups.rs diff --git a/ROADMAP.md b/ROADMAP.md index 14419f9857..0854b65f96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -432,6 +432,7 @@ 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 (M5, comment pop-ups):** A double-click on a comment opens its pop-up window on the page, as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it and × closes it, each an undo step saved in the file's `/Popup` so other viewers agree. A connector line 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-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 999baec20a..48f137b07c 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -21,7 +21,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/info.rs b/crates/annot/src/info.rs index b56e0de20d..bb4cc238a0 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -38,6 +38,29 @@ pub struct Summary { /// 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. pub points: Vec<[f32; 2]>, + /// The comment's pop-up window (`/Popup`), when it has one. + pub popup: Option, +} + +/// 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 `annot` names, as [`PopupInfo`]: `None` when it has none or it is unreadable. +fn popup_info(doc: &Document, annot: &Dict) -> Option { + let r = annot.reference(b"Popup")?; + 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(); + let [x0, y0, x1, y1] = rect[..] else { 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 { @@ -145,6 +168,7 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, + popup: popup_info(doc, d), }); } } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..017ee3c802 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -29,7 +29,7 @@ mod types; use create::num_array; pub use create::{add_annotation, callout_attach, 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, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; @@ -39,7 +39,7 @@ pub use lookup::{ pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + set_fill, set_font_size, set_locked, set_marked, set_points, set_popup, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..126897e509 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -2,8 +2,8 @@ use super::create::{ add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, 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, line_endings_of, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, StyleChange, TextReplacement, @@ -286,6 +286,75 @@ 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; + match d.reference(b"Popup").filter(|pr| doc.get(*pr).as_dict().is_some()) { + Some(pr) => 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 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], + }; + 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> { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..19973781c8 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,48 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +/// 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); + + // 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 { .. }))); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..9f7fd4a86f 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -188,6 +188,8 @@ 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), + // 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 line's ends, or the corners of connected lines, a polygon or a cloud, as // comment_edit's `points` takes them. @@ -611,6 +613,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); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 36e0d00454..4cd6e17643 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -805,6 +805,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 c6c200cece..fe4f699d1a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -923,6 +923,11 @@ pub fn tools() -> Vec { t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("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( "doc_protect", "Protect with passwords", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..ee868206b3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10190,3 +10190,29 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } write_full(&doc, &SaveOptions::default()).unwrap() } + +/// 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); +} diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..43ab3b6b83 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -24,6 +24,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, }, + /// 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 c3339453af..fbfc33d2f2 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -84,6 +84,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}", @@ -217,6 +220,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 { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { Label::of("Change comment properties") diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..aa55a90105 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -58,6 +58,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 { .. } @@ -384,6 +385,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 fafa65b4c2..fc813fbe19 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -64,6 +64,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/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..25940ed032 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + PageTransition, Popup, RemoteDestination, TransitionStyle, Xfa, 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 603aa88110..fcb9dd77bb 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,5 +1,5 @@ use super::core::{limited_pdf_string, pretty_date, spend}; -use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; +use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, Popup, RemoteDestination, link_replies}; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -158,6 +158,13 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; + let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).map(|p| { + let [x0, y0, x1, y1] = rect4(self.resolve(p.get(b"Rect").unwrap_or(&Object::Null))); + 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, @@ -180,6 +187,7 @@ impl<'a> Inspector<'a> { fill_sign, no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), points, + popup, }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..d966e23132 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -341,6 +341,17 @@ pub struct Annotation { /// 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. pub points: Vec<[f32; 2]>, + /// The comment's pop-up window (`/Popup`): where it sits and whether it is open. + pub popup: Option, +} + +/// 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 { diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..79b938719f 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -304,6 +304,7 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { fill_sign: false, no_rotate: true, points: Vec::new(), + popup: None, }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { @@ -1850,6 +1851,18 @@ 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"); +} + #[test] fn dates_are_prettified() { assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..7e437fef27 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, Popup, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5625587a69..399783f41a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2712,7 +2712,7 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc /// 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, 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); @@ -2731,6 +2731,9 @@ 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, comments_hidden, can_modify) { + view.pending_edit = Some(e); + } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2cbde2b995..e21c38a259 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,7 +150,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; 35] = [ +const PAGE_MODULES: [&str; 36] = [ "canvas.rs", "canvas/area.rs", "canvas/bars.rs", @@ -167,6 +167,7 @@ const PAGE_MODULES: [&str; 35] = [ "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", @@ -216,7 +217,7 @@ 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); 11] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 12] = [ ("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"), @@ -224,6 +225,7 @@ const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ ("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/input.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), + ("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/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), @@ -251,10 +253,15 @@ fn declares(line: &str) -> Option<&str> { 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 diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 91a3739d62..6c41645f43 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -20,6 +20,11 @@ impl DocView { pub(crate) 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/editing.rs b/crates/ui-egui/src/editing.rs index 8def569f58..b26e2aed42 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -809,6 +809,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/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6e06c424df..54801f7fec 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1980,6 +1980,9 @@ Lock comment قفل التعليق Unlock comment إلغاء قفل التعليق Move comment نقل التعليق + Open comment pop-up فتح النافذة المنبثقة للتعليق + Close comment pop-up إغلاق النافذة المنبثقة للتعليق + Move comment pop-up نقل النافذة المنبثقة للتعليق Resize comment تغيير حجم التعليق Change comment properties تغيير خصائص التعليق Add field إضافة حقل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8fe56514df..60b3457ca5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2069,6 +2069,9 @@ Microsoft Word (.docx) Microsoft Word (.docx) Move bookmark Премести отметката Move comment Премести коментара + Open comment pop-up Отваряне на изскачащия прозорец на коментара + Close comment pop-up Затваряне на изскачащия прозорец на коментара + Move comment pop-up Преместване на изскачащия прозорец на коментара Move image Премести изображението Move page Премести страницата Move pages Премести страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8ecd80d30b..7b8f3731ce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2030,6 +2030,9 @@ key Space Mezerník Lock comment Zamknout komentář Unlock comment Odemknout komentář Move comment Přesunout komentář + Open comment pop-up Otevřít okno komentáře + Close comment pop-up Zavřít okno komentáře + Move comment pop-up Přesunout okno komentáře Resize comment Změnit velikost komentáře Change comment properties Změnit vlastnosti komentáře Add field Přidat pole diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d6afea2c8..234e448f48 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1916,6 +1916,9 @@ Lock comment Kommentar sperren Unlock comment Kommentar entsperren Move comment Kommentar verschieben + Open comment pop-up Kommentar-Pop-up öffnen + Close comment pop-up Kommentar-Pop-up schließen + Move comment pop-up Kommentar-Pop-up verschieben Resize comment Kommentargröße ändern Change comment properties Kommentareigenschaften ändern Add field Feld hinzufügen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3c2f2e2050..85144efa79 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1345,6 +1345,9 @@ Lock comment Κλείδωμα σχολίου Unlock comment Ξεκλείδωμα σχολίου Move comment Μετακίνηση σχολίου + Open comment pop-up Άνοιγμα αναδυόμενου παραθύρου σχολίου + Close comment pop-up Κλείσιμο αναδυόμενου παραθύρου σχολίου + Move comment pop-up Μετακίνηση αναδυόμενου παραθύρου σχολίου Resize comment Αλλαγή μεγέθους σχολίου Change comment properties Αλλαγή ιδιοτήτων σχολίου Add field Προσθήκη πεδίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f5f1b4b6ff..ba9b3b64b7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1995,6 +1995,9 @@ action wizard Start Iniciar Lock comment Bloquear comentario Unlock comment Desbloquear comentario Move comment Mover comentario + Open comment pop-up Abrir la ventana emergente del comentario + Close comment pop-up Cerrar la ventana emergente del comentario + Move comment pop-up Mover la ventana emergente del comentario Resize comment Cambiar tamaño del comentario Change comment properties Cambiar propiedades del comentario Add field Añadir campo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f31b3741b6..6a75a5fb49 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1911,6 +1911,9 @@ Lock comment Verrouiller le commentaire Unlock comment Déverrouiller le commentaire Move comment Déplacer le commentaire + Open comment pop-up Ouvrir la fenêtre du commentaire + Close comment pop-up Fermer la fenêtre du commentaire + Move comment pop-up Déplacer la fenêtre du commentaire Resize comment Redimensionner le commentaire Change comment properties Modifier les propriétés du commentaire Add field Ajouter un champ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e74944e33e..79275bf6f2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1901,6 +1901,9 @@ Lock comment Megjegyzés zárolása Unlock comment Megjegyzés feloldása Move comment Megjegyzés áthelyezése + Open comment pop-up Megjegyzés előugró ablakának megnyitása + Close comment pop-up Megjegyzés előugró ablakának bezárása + Move comment pop-up Megjegyzés előugró ablakának áthelyezése Resize comment Megjegyzés méretezése Change comment properties Megjegyzés tulajdonságainak módosítása Add field Mező hozzáadása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82973756fa..d97720d519 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1986,6 +1986,9 @@ Lock comment Blocca commento Unlock comment Sblocca commento Move comment Sposta commento + Open comment pop-up Apri il pop-up del commento + Close comment pop-up Chiudi il pop-up del commento + Move comment pop-up Sposta il pop-up del commento Resize comment Ridimensiona commento Change comment properties Modifica proprietà commento Add field Aggiungi campo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ef055d436f..d4ae187d24 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1993,6 +1993,9 @@ Lock comment 注釈をロック Unlock comment 注釈のロックを解除 Move comment 注釈を移動 + Open comment pop-up 注釈のポップアップを開く + Close comment pop-up 注釈のポップアップを閉じる + Move comment pop-up 注釈のポップアップを移動 Resize comment 注釈のサイズを変更 Change comment properties 注釈のプロパティを変更 Add field フィールドを追加 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 47061d3a25..74b0bc50e7 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1902,6 +1902,9 @@ Lock comment Түсініктемені құлыптау Unlock comment Түсініктеменің құлпын ашу Move comment Түсініктемені жылжыту + Open comment pop-up Пікірдің қалқымалы терезесін ашу + Close comment pop-up Пікірдің қалқымалы терезесін жабу + Move comment pop-up Пікірдің қалқымалы терезесін жылжыту Resize comment Түсініктеме өлшемін өзгерту Change comment properties Түсініктеме сипаттарын өзгерту Add field Өріс қосу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 9385c27907..334a9aacfa 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2209,6 +2209,9 @@ comment menu Edit Redaguoti Mouse Up Atleidus pelės mygtuką Move bookmark Perkelti žymelę Move comment Perkelti komentarą + Open comment pop-up Atidaryti komentaro iškylantįjį langą + Close comment pop-up Uždaryti komentaro iškylantįjį langą + Move comment pop-up Perkelti komentaro iškylantįjį langą Move image Perkelti paveikslėlį Move page Perkelti puslapį Move pages Perkelti puslapius diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e333b1075e..46443462f5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1974,6 +1974,9 @@ Lock comment Zablokuj komentarz Unlock comment Odblokuj komentarz Move comment Przenieś komentarz + Open comment pop-up Otwórz okienko komentarza + Close comment pop-up Zamknij okienko komentarza + Move comment pop-up Przenieś okienko komentarza Resize comment Zmień rozmiar komentarza Change comment properties Zmień właściwości komentarza Add field Dodaj pole diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cd6d8f3e2f..54d290711e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1942,6 +1942,9 @@ Microsoft Word (.docx) Microsoft Word (.docx) Move bookmark Mover marcador Move comment Mover comentário + Open comment pop-up Abrir o pop-up do comentário + Close comment pop-up Fechar o pop-up do comentário + Move comment pop-up Mover o pop-up do comentário Move image Mover imagem Move page Mover página Move pages Mover páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 047c01228e..618f0ef75c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1981,6 +1981,9 @@ Lock comment Заблокировать комментарий Unlock comment Разблокировать комментарий Move comment Переместить комментарий + Open comment pop-up Открыть всплывающее окно комментария + Close comment pop-up Закрыть всплывающее окно комментария + Move comment pop-up Переместить всплывающее окно комментария Resize comment Изменить размер комментария Change comment properties Изменить свойства комментария Add field Добавить поле diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 63c3a3f77d..4679a8bd63 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1153,6 +1153,9 @@ Mouse Up Miška navzgor Move bookmark Premakni zaznamek Move comment Premakni komentar + Open comment pop-up Odpri pojavno okno komentarja + Close comment pop-up Zapri pojavno okno komentarja + Move comment pop-up Premakni pojavno okno komentarja Move down Premakni navzdol Move earlier Premakni prej Move image Premakni sliko diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 6186eb1713..3cc3403618 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1900,6 +1900,9 @@ action wizard Start ప్రారంభించు Lock comment వ్యాఖ్యను లాక్ చేయి Unlock comment వ్యాఖ్యను అన్‌లాక్ చేయి Move comment వ్యాఖ్యను తరలించు + Open comment pop-up వ్యాఖ్య పాప్-అప్ తెరవండి + Close comment pop-up వ్యాఖ్య పాప్-అప్ మూసివేయండి + Move comment pop-up వ్యాఖ్య పాప్-అప్ తరలించండి Resize comment వ్యాఖ్య పరిమాణం మార్చు Change comment properties వ్యాఖ్య లక్షణాలను మార్చు Add field ఫీల్డ్ జోడించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8a993af80..d6bdb24a1f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1874,6 +1874,9 @@ Lock comment Yorumu kilitle Unlock comment Yorumun kilidini aç Move comment Yorumu taşı + Open comment pop-up Yorum açılır penceresini aç + Close comment pop-up Yorum açılır penceresini kapat + Move comment pop-up Yorum açılır penceresini taşı Resize comment Yorumu yeniden boyutlandır Change comment properties Yorum özelliklerini değiştir Add field Alan ekle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 566e673176..348359b93a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1978,6 +1978,9 @@ Lock comment Заблокувати коментар Unlock comment Розблокувати коментар Move comment Перемістити коментар + Open comment pop-up Відкрити спливаюче вікно коментаря + Close comment pop-up Закрити спливаюче вікно коментаря + Move comment pop-up Перемістити спливаюче вікно коментаря Resize comment Змінити розмір коментаря Change comment properties Змінити властивості коментаря Add field Додати поле diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 41063cc0da..a4a016865e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2846,3 +2846,8 @@ key Space Space Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất Show Page Edge Shadows Hiển thị bóng viền trang + +# AI-assisted additions for comment pop-up windows (2026-10-16); not reviewed by a native speaker. + Open comment pop-up Mở cửa sổ bật lên của bình luận + Close comment pop-up Đóng cửa sổ bật lên của bình luận + Move comment pop-up Di chuyển cửa sổ bật lên của bình luận diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6089c942fd..c0645a0c6d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2079,6 +2079,9 @@ Microsoft Word (.docx) Microsoft Word(.docx) Move bookmark 移动书签 Move comment 移动注释 + Open comment pop-up 打开注释弹出窗口 + Close comment pop-up 关闭注释弹出窗口 + Move comment pop-up 移动注释弹出窗口 Move image 移动图像 Move page 移动页面 Move pages 移动页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 292e399579..6455101d38 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1980,6 +1980,9 @@ Lock comment 鎖定註解 Unlock comment 解鎖註解 Move comment 移動註解 + Open comment pop-up 開啟註解彈出視窗 + Close comment pop-up 關閉註解彈出視窗 + Move comment pop-up 移動註解彈出視窗 Resize comment 調整註解大小 Change comment properties 變更註解內容 Add field 新增欄位 diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index e9956e5f95..fc0236bba7 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 @@ pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls}; 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; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5e74fdf56e..1a44f656da 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -3,6 +3,7 @@ use super::geometry::{ resized, snapped_caret, }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; +use super::popups::has_popup; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -149,6 +150,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); @@ -263,12 +266,20 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu None => cv.selected = None, } } - if resp.double_clicked() + if (resp.double_clicked() || resp.triple_clicked()) && on_page && cx.allowed && let Some((a, _)) = owner { cv.selected = Some((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), diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..77d6315255 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, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; +use super::popups::paint_connectors; use super::state::{Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -57,6 +58,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> } } } + paint_connectors(painter, cx, view, pointer); paint_gesture(painter, cx, view); } 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..12fef6ca5f --- /dev/null +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -0,0 +1,196 @@ +use super::state::PageCx; +use super::{ + Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, Stroke, Tokens, pos2, + theme, +}; + +/// A comment with a pop-up window of its own (not a text box, a Fill & Sign mark or a reply). +pub(super) fn has_popup(a: &Annotation) -> bool { + a.subtype != "FreeText" && !a.fill_sign && a.in_reply_to.is_none() +} + +/// The line 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 runs from the point of the comment nearest the window to the +/// point of the window nearest the comment. +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.selected == Some((*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)); + painter.line_segment([from, to], Stroke::new(1.5, color)); + } +} + +/// 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) + } +} + +/// The open pop-up windows of the comments on screen: author, date and the comment's text, +/// editable in place; the title bar drags the window (its new place is 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, hidden: bool, allowed: bool) -> Option { + let t = Tokens::get(ctx); + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let mut edits: Vec = Vec::new(); + let mut rects = Vec::new(); + let mut hovered = None; + 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); + // The window keeps a readable size whatever the zoom: its place follows the page, + // its width stays between what a note needs and what fits. + let width = placed.width().clamp(220.0, 420.0); + 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 area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(6)).show(ui, |ui| { + ui.set_width(width); + // Title bar: the comment's colour, author and date; drag to move, × to close. + let bar = egui::Frame::NONE.fill(color.gamma_multiply(0.35)).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_width(width - 20.0); + ui.horizontal(|ui| { + let who = a.author.clone().unwrap_or_else(|| crate::comments_panel::kind_label(&a).to_string()); + ui.label(egui::RichText::new(who.as_str()).font(theme::semibold(13.0)).color(t.text)); + if let Some(m) = &a.modified { + ui.label(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "x", 18.0, false, tl!("Close")).clicked() { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) }); + } + }); + }); + }); + // 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() - 30.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() { + 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 + { + // The same size on the page, at the new place. + let moved = Rect::from_min_size(at, placed.size()); + // Only converts the rectangle to user space: the pointer and the + // redaction look are unused. + 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), + }; + let rect = cx.rect_to_user(moved); + if at.distance(placed.left_top()) >= 2.0 { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + } + // Body: the text, typed in place; saved when the field loses focus. + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| { + ui.set_width(width - 20.0); + if allowed && !a.locked { + let typed = view.comments.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) + .hint_text(tl!("Add a comment")) + .desired_rows(3) + .desired_width(f32::INFINITY) + .id_salt(("popup-text", page, a.index)), + ); + if field.changed() { + view.comments.popup_text = Some((page, a.index, text.clone())); + } + if field.lost_focus() + && let Some((_, _, s)) = view.comments.popup_text.take_if(|(p, i, _)| (*p, *i) == key) + && s.trim_end() != a.contents.as_deref().unwrap_or_default() + { + edits.push(Edit::SetAnnotationContents { page, index: a.index, text: s.trim_end().to_string() }); + } + } else { + ui.label(a.contents.as_deref().unwrap_or_default()); + } + // Replies, as the thread reads. + let replies: Vec<&Annotation> = + info.annotations.iter().filter(|r| r.page == page && r.in_reply_to.is_some() && r.in_reply_to == a.name).collect(); + for r in replies { + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("?")).font(theme::semibold(12.0))); + ui.label(egui::RichText::new(r.contents.as_deref().unwrap_or_default()).font(theme::regular(12.0))); + }); + } + }); + }); + }); + 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 ab24352256..bc1564aad9 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -139,6 +139,15 @@ 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)>, + /// Text being typed in a pop-up window: (page, index, text). + pub popup_text: 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). diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 91e796e43a..0eb2ba4456 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -42,6 +42,7 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() { fill_sign: false, no_rotate, points: Vec::new(), + 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 }; @@ -87,6 +88,7 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz fill_sign: false, no_rotate: false, points: Vec::new(), + popup: None, } } @@ -176,6 +178,7 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat fill_sign, no_rotate: false, points: Vec::new(), + popup: None, } } @@ -380,6 +383,7 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men intent: None, fill_sign: false, points: Vec::new(), + popup: None, no_rotate: false, }], ..Default::default() diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 664f61733e..6b479ec8c4 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3022,3 +3022,68 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.drop_at(from + delta); h.run_steps(3); } + +/// 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, comment.connector-lines). +#[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"); + h.get_by_label("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")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 18498399c9..2e2a141772 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4132,11 +4132,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/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); author, date, the text typed in place and the replies; the title bar drags it, × closes it; each is an undo step. Not yet: Acrobat's pop-up options menu (reply, status, delete from the window), replying from the window, resizing the window, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)" [[feature]] id = "comment.connector-lines" @@ -4144,11 +4144,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_double_click_opens_a_notes_pop_up_which_drags_and_closes"] +notes = "a line in the comment's colour from the comment to its open pop-up 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" From 7a916ae7039a50a3e8c99881edcd594f146b2a00 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:07:46 +0800 Subject: [PATCH 20/48] M6: pop-ups: a /Popup that isn't the comment's own is replaced, edits follow comment permission, selected markup shows its connector Review follow-ups on #2137. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/annot/src/modify.rs | 7 +++- crates/annot/src/tests.rs | 11 ++++++ crates/ui-egui/src/canvas.rs | 5 +-- crates/ui-egui/src/tools/comments/paint.rs | 2 ++ crates/ui-egui/src/tools/comments/popups.rs | 3 +- crates/ui-egui/tests/it/comments.rs | 40 +++++++++++++++++++++ 6 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 126897e509..d594689e7e 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -310,7 +310,12 @@ pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f6 None => None, }; let _ = meta; - match d.reference(b"Popup").filter(|pr| doc.get(*pr).as_dict().is_some()) { + // 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. + let own_popup = |pr: &ObjRef| { + doc.get(*pr).as_dict().is_some_and(|pd| pd.name(b"Subtype") == Some(b"Popup") && pd.reference(b"Parent").is_none_or(|parent| parent == r)) + }; + match d.reference(b"Popup").filter(own_popup) { Some(pr) => doc.update_dict(pr, |p| { if let Some(rect) = rect { p.set(b"Rect".to_vec(), num_array(&rect)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 19973781c8..10e8069681 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2230,6 +2230,17 @@ fn pop_ups_open_close_and_move_and_are_reported() { 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)); + // 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(_)))); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 399783f41a..05a977d9cc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2712,7 +2712,8 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc /// 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, comments_hidden, 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); @@ -2731,7 +2732,7 @@ 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, comments_hidden, can_modify) { + if let Some(e) = comments::popups(ui.ctx(), view, info, comments_hidden, allowed) { view.pending_edit = Some(e); } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 77d6315255..14870dd2f8 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -39,6 +39,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> 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)) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index 12fef6ca5f..ae35c3b69d 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -92,7 +92,8 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi ui.label(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::icons::button(ui, "x", 18.0, false, tl!("Close")).clicked() { + // Closing changes the comment's /Open, which needs comment permission. + if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 18.0, false, tl!("Close"))).inner.clicked() { edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) }); } }); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6b479ec8c4..62eb98fe49 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3023,6 +3023,46 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.run_steps(3); } +/// #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"); +} + /// 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, comment.connector-lines). From a7fd7f27ae790c0342ec45127d3184ba7cfad087 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:09:04 +0800 Subject: [PATCH 21/48] M4: a pop-up counts as a comment's only when it is a pop-up of that comment (#2137) Review on #2137. set_popup accepted a pop-up whose /Parent was present but not a reference, and the comment list (pdfcraft_annot::summaries and the inspector) reported whatever /Popup named: another annotation's rectangle and open state. One check, own_popup, now decides for both: /Subtype /Popup, and /Parent absent or a reference to the comment (ISO 32000-2:2020 12.5.6.14, Table 186: "Optional; shall be an indirect reference"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/annot/src/info.rs | 23 ++++++++++--- crates/annot/src/modify.rs | 7 ++-- crates/annot/src/tests.rs | 41 ++++++++++++++++++++++++ crates/render/src/inspect/annotations.rs | 9 +++++- crates/render/src/inspect/tests.rs | 14 ++++++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index bb4cc238a0..c4b385935e 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -52,9 +52,24 @@ pub struct PopupInfo { pub open: bool, } -/// The pop-up `annot` names, as [`PopupInfo`]: `None` when it has none or it is unreadable. -fn popup_info(doc: &Document, annot: &Dict) -> Option { - let r = annot.reference(b"Popup")?; +/// 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| Some(parent) == 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. +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(); @@ -168,7 +183,7 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, - popup: popup_info(doc, d), + popup: popup_info(doc, entry.as_ref(), d), }); } } diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index d594689e7e..8e5a297ab3 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -2,7 +2,7 @@ use super::create::{ add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{is_comment_subtype, line_endings_of, text_value}; +use super::info::{is_comment_subtype, line_endings_of, own_popup, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, @@ -312,10 +312,7 @@ pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f6 let _ = meta; // 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. - let own_popup = |pr: &ObjRef| { - doc.get(*pr).as_dict().is_some_and(|pd| pd.name(b"Subtype") == Some(b"Popup") && pd.reference(b"Parent").is_none_or(|parent| parent == r)) - }; - match d.reference(b"Popup").filter(own_popup) { + match own_popup(doc, Some(r), &d) { Some(pr) => doc.update_dict(pr, |p| { if let Some(rect) = rect { p.set(b"Rect".to_vec(), num_array(&rect)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 10e8069681..c8e71c68d6 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2240,6 +2240,12 @@ fn pop_ups_open_close_and_move_and_are_reported() { 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(); @@ -2250,3 +2256,38 @@ fn pop_ups_open_close_and_move_and_are_reported() { 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: 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"); +} diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index fcb9dd77bb..e16680119a 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -158,7 +158,14 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).map(|p| { + // 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 = a.as_reference().ok(); + 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().ok().is_some_and(|id| Some(id) == own)) + }; + let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).map(|p| { let [x0, y0, x1, y1] = rect4(self.resolve(p.get(b"Rect").unwrap_or(&Object::Null))); Popup { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 79b938719f..5258c4f682 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1861,6 +1861,20 @@ fn a_comments_pop_up_is_read_with_its_place_and_state() { 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}"); + } } #[test] From 479390cd50880c65aafbbed8977ca38c344d68e8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:10:59 +0800 Subject: [PATCH 22/48] M4: a pop-up lists a comment's text replies, not its status or checkmark replies (#2137) The pop-up's reply list kept every annotation in reply to the comment, so a review status (/StateModel /Review) and the private checkmark reply Mark writes (/StateModel /Marked) showed as replies with their generated text. It now lists replies without a /State, as the Comments panel does (comments_panel.rs). Raised by CodeRabbit on #2137. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ --- crates/ui-egui/src/tools/comments/popups.rs | 11 +++++--- crates/ui-egui/tests/it/comments.rs | 28 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index ae35c3b69d..494b6350d3 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -167,9 +167,14 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi } else { ui.label(a.contents.as_deref().unwrap_or_default()); } - // Replies, as the thread reads. - let replies: Vec<&Annotation> = - info.annotations.iter().filter(|r| r.page == page && r.in_reply_to.is_some() && r.in_reply_to == a.name).collect(); + // Replies, as the thread reads: text replies only, as in the Comments + // panel. A review status or a checkmark (`/State`) is a reply too, but + // not one to read here. + let replies: Vec<&Annotation> = info + .annotations + .iter() + .filter(|r| r.page == page && r.in_reply_to.is_some() && r.in_reply_to == a.name && r.state.is_none()) + .collect(); for r in replies { ui.add_space(4.0); ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 62eb98fe49..dc0f29fcbc 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3063,6 +3063,34 @@ trailer << /Root 1 0 R >> 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 = |label: &str| h.query_all_by_label(label).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 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, comment.connector-lines). From 34871b61a55c7a90424ffb677a0e9132eb2c7f37 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:12:33 +0800 Subject: [PATCH 23/48] M5: Catalan, Finnish and Romanian for the pop-up undo labels (#2137) These catalogues were added on main after this branch began. AI-assisted, marked as not reviewed by a native speaker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/i18n/ca.tsv | 5 +++++ crates/ui-egui/src/i18n/fi.tsv | 5 +++++ crates/ui-egui/src/i18n/ro.tsv | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 83750ba124..764d3b5bcb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2838,3 +2838,8 @@ theme Appearance Aspecte # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcions d'aquesta resposta + +# AI-assisted additions for comment pop-up windows (#2137); not reviewed by a native speaker. + Open comment pop-up Obre la finestra emergent del comentari + Close comment pop-up Tanca la finestra emergent del comentari + Move comment pop-up Mou la finestra emergent del comentari diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 80d2a0f859..f23b478c2e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2847,3 +2847,8 @@ theme Appearance Teema # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tämän vastauksen asetukset + +# AI-assisted additions for comment pop-up windows (#2137); not reviewed by a native speaker. + Open comment pop-up Avaa kommentin ponnahdusikkuna + Close comment pop-up Sulje kommentin ponnahdusikkuna + Move comment pop-up Siirrä kommentin ponnahdusikkunaa diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 6b23515b8e..183effb6b0 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2838,3 +2838,8 @@ theme Appearance Aspect # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opțiuni pentru acest răspuns + +# AI-assisted additions for comment pop-up windows (#2137); not reviewed by a native speaker. + Open comment pop-up Deschidere fereastră pop-up a comentariului + Close comment pop-up Închidere fereastră pop-up a comentariului + Move comment pop-up Mutare fereastră pop-up a comentariului From 45f5a3c591f2d937d3b9b6a4c560e7b9d35db069 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:14:44 +0800 Subject: [PATCH 24/48] M5: a pop-up reads its thread by /IRT reference, as the Comments panel does (#2137) Rebased onto main's reference-based threading (#4061, #4566). The pop-up matched replies by /NM (in_reply_to == name), which need not be unique: two notes sharing PyMuPDF's fitz-A0 both showed one note's replies, and a reply to a reply was missing. It now files replies under a comment with thread_tops over the settled parent links, as comments_panel does, and has_popup asks Annotation::is_comment rather than in_reply_to (a reply whose parent has no /NM read as a comment of its own). Regression test a_pop_up_lists_its_replies_by_reference_when_names_repeat, seen to fail with the /NM filter restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/ui-egui/src/tools/comments/popups.rs | 18 +++++++---- crates/ui-egui/tests/it/comments.rs | 33 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index 494b6350d3..e71139a775 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -4,9 +4,10 @@ use super::{ theme, }; -/// A comment with a pop-up window of its own (not a text box, a Fill & Sign mark or a reply). +/// 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.in_reply_to.is_none() + a.subtype != "FreeText" && !a.fill_sign && a.is_comment() } /// The line from a comment to its open pop-up, in the comment's colour, while the pointer is on @@ -62,6 +63,8 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi 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 = @@ -167,13 +170,16 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi } else { ui.label(a.contents.as_deref().unwrap_or_default()); } - // Replies, as the thread reads: text replies only, as in the Comments - // panel. A review status or a checkmark (`/State`) is a reply too, but - // not one to read here. + // Replies, as the thread reads: text replies only, at any depth, filed + // under this comment by `/IRT` reference as in the Comments panel. A + // review status or a checkmark (`/State`) is a reply too, but not one to + // read here. let replies: Vec<&Annotation> = info .annotations .iter() - .filter(|r| r.page == page && r.in_reply_to.is_some() && r.in_reply_to == a.name && r.state.is_none()) + .filter(|r| { + r.state.is_none() && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) + }) .collect(); for r in replies { ui.add_space(4.0); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index dc0f29fcbc..6e96ffe260 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3155,3 +3155,36 @@ fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() { 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 10 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_label(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"); + } +} From 65eb3257c5443d828191d2f64480668ff2e7468f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:15:42 +0800 Subject: [PATCH 25/48] M5: a pop-up is its comment's when /Annots lists the comment through a reference to it (#2137) Review on #2137: when /Annots holds 5 0 R and object 5 is only a reference to the comment 6 0 R, the pop-up's /Parent names 6 0 R, but the ownership check compared it with 5 0 R. The comment's pop-up was then missing from both the on-open listing (render inspect) and the summaries after an edit (annot). Both now compare the references they resolve to. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 7 +++++-- crates/annot/src/tests.rs | 9 +++++++++ crates/render/src/inspect/annotations.rs | 5 +++-- crates/render/src/inspect/tests.rs | 7 +++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index c4b385935e..6a90541437 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -60,7 +60,10 @@ pub struct PopupInfo { 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| Some(parent) == own)) + 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) } @@ -183,7 +186,7 @@ pub fn summaries(doc: &Document) -> Vec { fill_sign: is_fill_sign(doc, d), no_rotate: drawn_upright(doc, d), points, - popup: popup_info(doc, entry.as_ref(), d), + popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d), }); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c8e71c68d6..e20f001a65 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2255,6 +2255,15 @@ fn pop_ups_open_close_and_move_and_are_reported() { 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 diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index e16680119a..71e95024ab 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -160,10 +160,11 @@ impl<'a> Inspector<'a> { }; // 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 = a.as_reference().ok(); + 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().ok().is_some_and(|id| Some(id) == own)) + && p.get(b"Parent") + .map_or(true, |parent| parent.as_reference().is_ok() && self.resolved_id(parent).is_some_and(|id| Some(id) == own)) }; let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).map(|p| { let [x0, y0, x1, y1] = rect4(self.resolve(p.get(b"Rect").unwrap_or(&Object::Null))); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 5258c4f682..94794b698b 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1875,6 +1875,13 @@ fn a_comments_pop_up_is_read_with_its_place_and_state() { 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] From 6aea7318f3b8378f0d20d9af4a97e403888fc61b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:17:05 +0800 Subject: [PATCH 26/48] M5: comment.connector-lines cites a test that reads the painted line (#2137) Review on #2137: the cited test opened, moved and closed a pop-up but never looked at the connector line. The new test reads the frame's shapes: a line in the comment's colour from the comment's edge to the window's, nearest points at each end, shown with the pointer on the comment or the window or with the comment selected, and not otherwise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/tests/it/comments.rs | 67 ++++++++++++++++++++++++++++- parity/acrobat-features.toml | 2 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6e96ffe260..bd74a70faf 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3093,7 +3093,7 @@ trailer << /Root 1 0 R >> /// 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, comment.connector-lines). +/// (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. @@ -3188,3 +3188,68 @@ fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() { 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"; + +/// The line segments of colour `color` the last frame painted. +fn line_segments(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<[Pos2; 2]> { + fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<[Pos2; 2]>) { + match shape { + egui::Shape::LineSegment { points, stroke } if stroke.color == color => out.push(*points), + 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 line (comment.connector-lines): in the comment's colour, from the +/// edge of the comment nearest its open pop-up to the edge of the window nearest that point, +/// 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 line. + h.hover_at(at(&h, 280.0, 190.0)); + h.run_steps(2); + assert!(line_segments(&h, blue).is_empty(), "no line with the pointer elsewhere"); + + // On the note or on its window: one line, 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 lines = line_segments(&h, blue); + assert_eq!(lines.len(), 1, "one line with the pointer on {what}: {lines:?}"); + let [from, to] = lines[0]; + assert!(on_edge(mark, from), "starts on the note's edge {mark:?}: {from:?}"); + assert!(on_edge(window, to), "ends on the window's edge {window:?}: {to:?}"); + // The nearest points: the note's lower right corner, the window's upper left one. + assert!(from.distance(mark.right_bottom()) < 1.0 && to.distance(window.left_top()) < 1.0, "{from:?} -> {to:?}"); + } + + // Selected, the note keeps its line 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!(line_segments(&h, blue).len(), 1, "a selected comment shows its line"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e2a141772..a120d1ad17 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4147,7 +4147,7 @@ milestone = "M5" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes"] +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 line in the comment's colour from the comment to its open pop-up 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]] From 002b7bf4ff3cc1eb46f281645014cbd2bb3caeae Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:17:14 +0800 Subject: [PATCH 27/48] M5: opening, closing or moving a pop-up keeps the page's raster (#2137) Review on #2137 (nitpick): a SetAnnotationPopup edit marked its page stale, so its tiles were rendered again, its text cache dropped and its selection cleared, though nothing in the raster changes: the window is drawn over the page and a /Popup annotation paints nothing. Edits that also change the comment (a batch) still invalidate as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/editing.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index b26e2aed42..f1c9ba7949 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -146,8 +146,12 @@ impl PdfCraftApp { view.move_background.committed(&edit, doc.edit_generation()); view.text_background.committed(&edit); if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) { - // Comment and content edits change one page: keep every other raster. - view.page_changed(page); + // Comment and content edits change one 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). + if !matches!(edit, Edit::SetAnnotationPopup { .. }) { + view.page_changed(page); + } } else if let Some(form_before) = form_before && doc.info.pages.len() == page_count_before && doc.xfa.is_some() == had_xfa From 1b9930e6123e9ac25be99c992d8c9c6c6d9fa314 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:15:29 +0800 Subject: [PATCH 28/48] M5: a comment's connector is a faint wedge to its pop-up (comment.connector-lines) The connector from a comment to its open pop-up window was a one-pixel line. It is now a faint wedge in the comment's colour: a point on the comment's edge nearest the window, widening to a base on the window's side that faces the comment, as the common readers draw it. Shown, as before, while the pointer is on the comment or the window, or the comment is selected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/tools/comments/popups.rs | 49 +++++++++++++++--- crates/ui-egui/tests/it/comments.rs | 56 +++++++++++++-------- parity/acrobat-features.toml | 2 +- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index e71139a775..db34e1098e 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -1,7 +1,6 @@ use super::state::PageCx; use super::{ - Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, Stroke, Tokens, pos2, - theme, + Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, Tokens, pos2, theme, }; /// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a @@ -10,10 +9,11 @@ pub(super) fn has_popup(a: &Annotation) -> bool { a.subtype != "FreeText" && !a.fill_sign && a.is_comment() } -/// The line 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 runs from the point of the comment nearest the window to the -/// point of the window nearest the 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 { @@ -38,7 +38,42 @@ pub(super) fn paint_connectors(painter: &egui::Painter, cx: &PageCx<'_>, view: & 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)); - painter.line_segment([from, to], Stroke::new(1.5, color)); + 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)] } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index bd74a70faf..d6db68403b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3199,11 +3199,20 @@ const BLUE_NOTE_WITH_OPEN_POP_UP: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; -/// The line segments of colour `color` the last frame painted. -fn line_segments(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<[Pos2; 2]> { - fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<[Pos2; 2]>) { +/// 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::LineSegment { points, stroke } if stroke.color == color => out.push(*points), + 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)), _ => {} } @@ -3215,10 +3224,11 @@ fn line_segments(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec out } -/// A comment's connector line (comment.connector-lines): in the comment's colour, from the -/// edge of the comment nearest its open pop-up to the edge of the window nearest that point, -/// shown while the pointer is on the comment or on the window or the comment is selected, and -/// not otherwise. +/// 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(), |_| {}); @@ -3229,27 +3239,33 @@ fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_e 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 line. + // Away from both: no connector. h.hover_at(at(&h, 280.0, 190.0)); h.run_steps(2); - assert!(line_segments(&h, blue).is_empty(), "no line with the pointer elsewhere"); + assert!(connectors(&h, blue).is_empty(), "no connector with the pointer elsewhere"); - // On the note or on its window: one line, from the note's edge to the window's. + // 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 lines = line_segments(&h, blue); - assert_eq!(lines.len(), 1, "one line with the pointer on {what}: {lines:?}"); - let [from, to] = lines[0]; - assert!(on_edge(mark, from), "starts on the note's edge {mark:?}: {from:?}"); - assert!(on_edge(window, to), "ends on the window's edge {window:?}: {to:?}"); - // The nearest points: the note's lower right corner, the window's upper left one. - assert!(from.distance(mark.right_bottom()) < 1.0 && to.distance(window.left_top()) < 1.0, "{from:?} -> {to:?}"); + 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 line with the pointer elsewhere. + // 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!(line_segments(&h, blue).len(), 1, "a selected comment shows its line"); + assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a120d1ad17..3c856087fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4148,7 +4148,7 @@ status = "shipped" commands = [] tools = [] 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 line in the comment's colour from the comment to its open pop-up while the pointer is on either or the comment is selected (Acrobat's roll-over lines). Not yet: the preference to turn them off" +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" From b1c2c4cafe37213d79c9357c1d08d3cd3154da96 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:15:42 +0800 Subject: [PATCH 29/48] M5: comment pop-up windows laid out as reply threads, fixed in size, scrolling and resizable (comment.popups) The pop-up window now reads as the common readers' do: - a one-line title bar with the comment's kind and author, its date and x, in the comment's colour; the body in a lighter tint, with fixed ink so it reads in either app theme; - the comment's text, and each reply's, in a box typed in place; - the replies as panels in thread order, each inside the panel of the reply it answers (/IRT), nesting at most eight deep; - a Reply button under the comment and each reply, opening a box that answers that comment or reply (Enter starts a new line; Post or Ctrl/Cmd+Enter posts; Cancel closes it). The window keeps the size of its pop-up's /Rect in points on screen as the page zooms, instead of growing with the zoom; the text and replies scroll inside it, with a scroll bar shown whenever there is more. Dragging its lower right corner resizes it; the new size is saved as the pop-up's /Rect, the window's upper left corner kept in place on screen (on a page turned a quarter too), in one undo step. A /Rect with non-finite numbers gives the least size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/tools/comments/popups.rs | 468 ++++++++++++++++---- crates/ui-egui/src/tools/comments/state.rs | 4 + crates/ui-egui/tests/it/comments.rs | 178 +++++++- parity/acrobat-features.toml | 4 +- 6 files changed, 564 insertions(+), 94 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0854b65f96..a7f274ea62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -432,7 +432,7 @@ 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 (M5, comment pop-ups):** A double-click on a comment opens its pop-up window on the page, as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it and × closes it, each an undo step saved in the file's `/Popup` so other viewers agree. A connector line 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-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-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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 05a977d9cc..7d9187270b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2732,7 +2732,7 @@ 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, comments_hidden, allowed) { + if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { view.pending_edit = Some(e); } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index db34e1098e..fc902708af 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -1,7 +1,5 @@ use super::state::PageCx; -use super::{ - Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, Tokens, pos2, theme, -}; +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`]). @@ -88,12 +86,257 @@ fn nearest_on(r: Rect, p: Pos2) -> Pos2 { } } -/// The open pop-up windows of the comments on screen: author, date and the comment's text, -/// editable in place; the title bar drags the window (its new place is 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, hidden: bool, allowed: bool) -> Option { - let t = Tokens::get(ctx); +/// 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(); @@ -108,40 +351,74 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi let Some(p) = a.popup else { continue }; let key = (page, a.index); let placed = xf.user_rect(info, page, p.rect); - // The window keeps a readable size whatever the zoom: its place follows the page, - // its width stays between what a note needs and what fits. - let width = placed.width().clamp(220.0, 420.0); + // 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| { - egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(6)).show(ui, |ui| { - ui.set_width(width); - // Title bar: the comment's colour, author and date; drag to move, × to close. - let bar = egui::Frame::NONE.fill(color.gamma_multiply(0.35)).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { - ui.set_width(width - 20.0); + 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| { - let who = a.author.clone().unwrap_or_else(|| crate::comments_panel::kind_label(&a).to_string()); - ui.label(egui::RichText::new(who.as_str()).font(theme::semibold(13.0)).color(t.text)); - if let Some(m) = &a.modified { - ui.label(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)); - } 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", 18.0, false, tl!("Close"))).inner.clicked() { + 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() - 30.0, bar_rect.bottom())), + Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 24.0, bar_rect.bottom())), id.with("grip"), egui::Sense::drag(), ); @@ -159,72 +436,91 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi 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. - let moved = Rect::from_min_size(at, placed.size()); - // Only converts the rectangle to user space: the pointer and the - // redaction look are unused. - 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), - }; - let rect = cx.rect_to_user(moved); - if at.distance(placed.left_top()) >= 2.0 { - edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); - } + // 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 }); } - // Body: the text, typed in place; saved when the field loses focus. - egui::Frame::NONE.inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| { - ui.set_width(width - 20.0); - if allowed && !a.locked { - let typed = view.comments.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) - .hint_text(tl!("Add a comment")) - .desired_rows(3) - .desired_width(f32::INFINITY) - .id_salt(("popup-text", page, a.index)), - ); - if field.changed() { - view.comments.popup_text = Some((page, a.index, text.clone())); + // 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. + let replies: Vec<&Annotation> = info + .annotations + .iter() + .filter(|r| { + r.state.is_none() && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) + }) + .collect(); + 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, &a, tl!("Add a comment")); + footer(ui, &mut parts, &a); + for root in &roots { + reply_panel(ui, &mut parts, &nodes, *root); } - if field.lost_focus() - && let Some((_, _, s)) = view.comments.popup_text.take_if(|(p, i, _)| (*p, *i) == key) - && s.trim_end() != a.contents.as_deref().unwrap_or_default() - { - edits.push(Edit::SetAnnotationContents { page, index: a.index, text: s.trim_end().to_string() }); - } - } else { - ui.label(a.contents.as_deref().unwrap_or_default()); - } - // Replies, as the thread reads: text replies only, at any depth, filed - // under this comment by `/IRT` reference as in the Comments panel. A - // review status or a checkmark (`/State`) is a reply too, but not one to - // read here. - let replies: Vec<&Annotation> = info - .annotations - .iter() - .filter(|r| { - r.state.is_none() && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) - }) - .collect(); - for r in replies { - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("?")).font(theme::semibold(12.0))); - ui.label(egui::RichText::new(r.contents.as_deref().unwrap_or_default()).font(theme::regular(12.0))); - }); - } + }); }); }); + // 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() { + 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)) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index bc1564aad9..32ef995f8f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -142,8 +142,12 @@ pub struct CommentView { /// 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. diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index d6db68403b..e2f6b0412b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3085,7 +3085,7 @@ trailer << /Root 1 0 R >> }); h.run_steps(6); let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); - let in_window = |label: &str| h.query_all_by_label(label).any(|n| window.contains(n.rect().center())); + 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"); @@ -3134,7 +3134,8 @@ fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() { 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"); - h.get_by_label("Tester"); + // 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; @@ -3165,7 +3166,7 @@ const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7 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 10 290 90] /Parent 6 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 >> @@ -3183,7 +3184,7 @@ fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() { 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_label(text).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect(); + 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"); } @@ -3269,3 +3270,172 @@ fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_e 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:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c856087fb..b87804573a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4135,8 +4135,8 @@ milestone = "M5" status = "shipped" commands = [] 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/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); author, date, the text typed in place and the replies; the title bar drags it, × closes it; each is an undo step. Not yet: Acrobat's pop-up options menu (reply, status, delete from the window), replying from the window, resizing the window, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)" +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" From f508fdf053c4f3d66aeeb79bd6bb79668945edf7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:22:55 +0800 Subject: [PATCH 30/48] M5: pop-up rectangles read alike, edits in one frame kept, pop-up drafts follow their comment (#2137 review) - The render path reads a pop-up as none unless its /Rect is four numbers, as pdfcraft_annot::summaries does; set_popup gives a comment's own pop-up without one the place a new pop-up gets, so opening it shows it again. - The composer's edit and a pop-up's, landing in one frame, are queued together in one batch instead of the pop-up's replacing the composer's. - A pop-up's unsaved text, reply draft, drag and resize follow their comment when a deletion moves it, are dropped on undo/redo, and are dropped after any other edit that leaves their (page, index) naming another comment (pages moved, inserted or deleted). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 2 +- crates/annot/src/modify.rs | 48 ++++++++------- crates/annot/src/tests.rs | 20 +++++++ crates/render/src/inspect/annotations.rs | 12 ++-- crates/render/src/inspect/tests.rs | 12 ++++ crates/ui-egui/src/canvas.rs | 7 ++- crates/ui-egui/src/editing.rs | 13 ++++ crates/ui-egui/src/tools/comments/state.rs | 45 +++++++++++++- crates/ui-egui/tests/it/comments.rs | 70 ++++++++++++++++++++++ 9 files changed, 201 insertions(+), 28 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 6a90541437..18a395a848 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -70,7 +70,7 @@ pub(crate) fn own_popup(doc: &Document, own: Option, annot: &Dict) -> Op /// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it /// is unreadable. -fn popup_info(doc: &Document, own: Option, annot: &Dict) -> Option { +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); diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 8e5a297ab3..7a1071c306 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -2,7 +2,7 @@ use super::create::{ add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{is_comment_subtype, line_endings_of, own_popup, text_value}; +use super::info::{is_comment_subtype, 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::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, @@ -310,30 +310,36 @@ pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f6 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) => 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)); - } - })?, + 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 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], - }; let mut pd = Dict::new(); pd.set(b"Type".to_vec(), Object::name("Annot")); pd.set(b"Subtype".to_vec(), Object::name("Popup")); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index e20f001a65..4e45d1d777 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2300,3 +2300,23 @@ fn foreign_pop_ups_are_not_the_marks(doc: &mut Document, note: ObjRef, mark: usi 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"); +} diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 71e95024ab..36e0556f65 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -166,12 +166,16 @@ impl<'a> Inspector<'a> { && p.get(b"Parent") .map_or(true, |parent| parent.as_reference().is_ok() && self.resolved_id(parent).is_some_and(|id| Some(id) == own)) }; - let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).map(|p| { - let [x0, y0, x1, y1] = rect4(self.resolve(p.get(b"Rect").unwrap_or(&Object::Null))); - Popup { + // A pop-up without a `/Rect` of four numbers is none, as `pdfcraft_annot::summaries` + // reads it: both lists of the comments agree. + 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 }; + 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, diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 94794b698b..62bc130290 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1904,3 +1904,15 @@ trailer << /Root 1 0 R >> assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); } + +/// #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:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7d9187270b..5c5dd611f8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2733,7 +2733,12 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim view.pending_edit = Some(e); } if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { - view.pending_edit = Some(e); + // 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); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f1c9ba7949..71b6beb030 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -123,6 +123,15 @@ impl PdfCraftApp { (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), _ => 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 }; @@ -169,6 +178,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 std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 32ef995f8f..54a723396f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -242,16 +242,59 @@ impl CommentView { 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.selected = None; 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; + } } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index e2f6b0412b..64de66a640 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3439,3 +3439,73 @@ fn a_pop_up_resized_on_a_turned_page_stays_in_place() { // 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); +} From b115a8a098a9e00445877e2184ab259d34f8aef4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:33:48 +0800 Subject: [PATCH 31/48] M5: test that closing a pop-up saves its typed text (#2137 review) Text typed into a pop-up's box and not yet saved is saved when the window's x is clicked: the click takes the focus from the box and closes the window in one frame, as one undo step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/tests/it/comments.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 64de66a640..5e66092d98 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3509,3 +3509,26 @@ fn a_pop_ups_unsaved_work_follows_its_comment_or_is_dropped() { 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"); +} From c2251a4e1665fdeb4881fc19cf20160ada4d6490 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:38:13 +0800 Subject: [PATCH 32/48] M5: a pop-up /Rect too large for f32 reads as no pop-up; reversed corners are tested (#2137 review) A file's huge number became infinite as f32 and reached the window's place. Both readers (annot summaries and the render inspector) now take such a rectangle as no pop-up, as one that isn't four numbers; opening it gives it a new place. Reversed corners were already put in order; tests now cover both, read from a file and saved through set_popup. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/info.rs | 4 +++ crates/annot/src/tests.rs | 31 ++++++++++++++++++++++++ crates/render/src/inspect/annotations.rs | 8 ++++-- crates/render/src/inspect/tests.rs | 16 ++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 18a395a848..b6fa661482 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -76,7 +76,11 @@ pub(crate) fn popup_info(doc: &Document, own: Option, annot: &Dict) -> O 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 }) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4e45d1d777..d25cbacdb9 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2320,3 +2320,34 @@ fn opening_a_pop_up_without_a_rect_gives_it_one() { 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:?}"); +} diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 36e0556f65..7139a874ae 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -166,12 +166,16 @@ impl<'a> Inspector<'a> { && 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 is none, as `pdfcraft_annot::summaries` - // reads it: both lists of the comments agree. + // 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), diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 62bc130290..69466b0e07 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1916,3 +1916,19 @@ fn a_pop_up_without_a_four_number_rect_is_none() { 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"); +} From 2c02ca3a5619f03c80eada5f7c187a898287f9bd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:05:11 +0800 Subject: [PATCH 33/48] M5: a text box's /DA without a usable size, with a non-finite operand or naming no listed font draws at the defaults (ISO 32000-2 12.7.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /DA string comes from the file. Reviewing how the PR reads it: - An operand that is not a finite number was taken as one: Rust parses "NaN" and "inf", so "NaN NaN NaN rg" reached the appearance stream as "NaN NaN NaN rg" (clamping keeps NaN), and "/Helv inf Tf" drew at the 400 pt cap. parse_da now ignores such an operand with its operator. - A Tf without its size ("/TiRo Tf") lost its font: da_font_name read the name two tokens before Tf only. The name is now read whether or not a size sits between it and Tf; the size falls back to 12 pt. - A zero or negative size keeps the default 12 pt (zero is auto-size only for form fields, §12.7.4.3; a text box has none), now stated in parse_da's comment. Tests (each fails with its fix reverted): a text_style unit test over missing, zero, negative, NaN, infinite, huge and non-numeric sizes, missing Tf, non-finite and out-of-range colours, garbage and non-UTF-8 bytes; and an appearance test that draws boxes from such /DA strings, including a font in /DR and one no /DR lists, and checks the stream's font, size and colour and that no NaN or inf reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 8 +++-- crates/annot/src/tests.rs | 39 +++++++++++++++++++++ crates/annot/src/text_style.rs | 64 ++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 99536fe023..ad8b5b847f 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -233,13 +233,17 @@ fn rg_stroke(c: Rgb) -> String { format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2])) } -/// `/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 => { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f8bc881fd0..220e8b070b 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2471,3 +2471,42 @@ fn a_colour_or_size_change_keeps_the_drawn_face() { 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}"); + } +} diff --git a/crates/annot/src/text_style.rs b/crates/annot/src/text_style.rs index fc3ad87f86..dfa4c8c1fa 100644 --- a/crates/annot/src/text_style.rs +++ b/crates/annot/src/text_style.rs @@ -65,13 +65,20 @@ pub fn dr_face(doc: &Document, d: &Dict) -> Option { 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). +/// 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 = toks.get(tf.checked_sub(2)?)?.strip_prefix('/')?; + 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()) } @@ -409,6 +416,59 @@ mod tests { 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] { From 9d38c80b8a916e4292c3e6dcbb1854a5f4db9df3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:52:38 +0800 Subject: [PATCH 34/48] M5: a callout's knee on the box's outline meets the side it is on (#1152) callout_side gave every position a side, but a knee exactly on the left, right or top edge, or on a corner, took the bottom side, and the leader line was drawn across the box. A knee on the outline (or within 0.01 pt inside it, as the page hands positions back as f32) now meets the side whose edge it lies on, a corner the left or right side as beside the box. Inside the box, or with a coordinate that isn't a number, it meets the bottom side. The rule is written out on callout_side and every tie is tested. Tests also pin the /CL invariant (ISO 32000-2 Table 177: four or six numbers): four- and six-number callouts keep their count through the arrow point, knee, box move, box resize, whole move and save/reopen, and ten malformed /CL values are refused, left as they were by a resize and shifted in place by a move. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/create.rs | 28 ++++++- crates/annot/src/tests.rs | 166 ++++++++++++++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 3d33bff324..7b024479fb 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -217,13 +217,33 @@ impl Side { } /// 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, _, x1, y1] = normalize(rect); - if knee[0] < x0 { + let [x0, y0, x1, y1] = normalize(rect); + let [x, y] = knee; + if x < x0 { Side::Left - } else if knee[0] > x1 { + } else if x > x1 { Side::Right - } else if knee[1] > y1 { + } 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 { Side::Bottom diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 47c956640f..ae4cc20099 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2405,8 +2405,9 @@ fn a_callout_refuses_points_it_cant_have() { } } -/// The side a knee faces: left or right of the box first, then above or below. A knee exactly on -/// a side's line (or inside the box's width) is above or below it. +/// 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]; @@ -2460,3 +2461,164 @@ fn a_callouts_arrow_point_alone_keeps_its_knee() { 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); 26] = [ + ([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::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!(text_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 = text_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 = text_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 = text_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]); +} From 3602d9f2d9ff68cd54d6e0171ee3d4ba527054c9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:09:33 +0800 Subject: [PATCH 35/48] M5: a group member outlives its deleted primary without /IRT; /IRT walks tested past their bounds Deleting a group's primary (delete_annotation, lift_annotation) or clearing a redaction mark with a group member (redact::clear_marks) now removes /IRT from each subordinate that stays and keeps its /RT /Group, as Acrobat desktop does. The kept member no longer refers to the deleted annotation, so a full save leaves the deleted one's content out. It is part of the same edit: one undo restores the group, /IRT included. lift_annotation's copy-only uses (the page beneath a dragged stamp or signature) only render the copy, so the change there is harmless. Each /IRT walk this PR relies on gets a test past its bound and a loop: group_primaries on an unsettled loop (render), comment_summary under a 100-deep chain of subordinates and a self-listed loop (engine), and deletion over a 200-deep thread and reply and group loops (annot). Review on #4358. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/lib.rs | 4 +- crates/annot/src/modify.rs | 37 +++++++++- crates/annot/src/tests.rs | 112 +++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 64 +++++++++++++++++ crates/redact/src/lib.rs | 8 ++- crates/redact/src/tests.rs | 28 ++++++++ crates/render/src/inspect/tests.rs | 9 +++ parity/acrobat-features.toml | 4 +- 8 files changed, 258 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 43b9d098e1..51810738c3 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -38,8 +38,8 @@ pub use lookup::{ }; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, - set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members, + set_contents, set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index ace7938cd4..d6b365f2c2 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -73,8 +73,9 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { /// 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. Replace -/// Text's strikeout and caret are one comment and go together from either ([`own_deletion`]). +/// 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> { let index = deleted_index(doc, page, index)?; lift_annotation(doc, page, index) @@ -133,9 +134,41 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result< let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; let doomed = with_replies_and_popups(doc, &list, seed(target)); 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. fn seed(target: &Object) -> Option { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 11e32c1791..4f825b1a76 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -987,6 +987,118 @@ fn a_group_reads_as_one_and_deletes_by_member() { 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] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6ced87376..4dad75adf5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4428,6 +4428,70 @@ fn comment_summary_leaves_group_members_out_and_keeps_their_replies() { 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); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index f71400daef..634d4bd7ca 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -753,9 +753,13 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result = 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 125f00fc5c..2e703769c2 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1393,6 +1393,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 unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index afde323a7c..21288a5edb 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1935,6 +1935,15 @@ mod group_tests { } } + /// `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); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c0d3e6649..5552304a86 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3595,8 +3595,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", "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"] -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; 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" +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" From 95ba736d6653c54b5be6a689f858bc3eadd9f60c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:07:57 +0800 Subject: [PATCH 36/48] M5: a callout knee with a coordinate that isn't a number takes the bottom side whatever the other callout_side's doc says a coordinate that isn't a number takes the bottom side, but every comparison with NaN is false, so the other coordinate alone decided: [NaN, y above the box] gave Top, [x left of it, NaN] Left. A NaN coordinate is now checked before any comparison. Review on #4094 (CodeRabbit, crates/annot/src/create.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/create.rs | 4 ++++ crates/annot/src/tests.rs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 7b024479fb..86d01c23a6 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -231,6 +231,10 @@ impl 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 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index ae4cc20099..074221aced 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2470,7 +2470,7 @@ fn a_callouts_arrow_point_alone_keeps_its_knee() { #[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); 26] = [ + 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"), @@ -2495,6 +2495,10 @@ fn a_callouts_knee_has_one_side_for_every_tie() { ([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"), ]; From 8f75c414e909dadc06244ba45ce35fb392bff05c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:11:53 +0800 Subject: [PATCH 37/48] M5: comments are sized with the border width they are drawn with border_width_of, which fits a text box's new text and sizes callouts, polygons and line endings, read only /BS /W (and turned a negative width into 0), while the appearance draws with appearance::border_width: /BS /W, else /Border's third number, 1 for an unusable one. A text box with /Border [0 0 4] and no /BS was drawn with a 4-pt border but fitted as if it had a 1-pt one, 6 pt shorter. border_width_of now is that rule. Review on #4365 (CodeRabbit, crates/annot/src/modify.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/modify.rs | 5 ++++- crates/annot/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 6cefc3eca1..9bac76e002 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -521,8 +521,11 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } } +/// 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 { - 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).max(0.0) + appearance::border_width(d) } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 220e8b070b..2a88555576 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2241,6 +2241,28 @@ fn free_text_without_a_border_entry_has_the_default_one() { } } +/// 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] From f668a8a64b3486a6cc341bebd4a3480c89e9437f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:40:27 +0800 Subject: [PATCH 38/48] M5: a Replace Text pair is one pop-up window, with the replacement as its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group member (/RT /Group, as Replace Text's strikeout and caret are) is part of its comment, not a reply (ISO 32000-2 §12.5.6.2), so the window lists no group member among the replies. For Replace Text as main writes it (the replacement on the caret, grouped under an empty strikeout, #5688), the window shows and edits the caret's text, the proposal's. A second test pins that a double-click on the proposal opens one window, the primary's, in both layouts; the page's hit test already picks only the group's primary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/tools/comments/popups.rs | 23 +++++- crates/ui-egui/tests/it/comments.rs | 87 +++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index fc902708af..b89e23caba 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -446,14 +446,31 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr // 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. + // 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.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) + 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 PdfCraft used to write it keeps the + // replacement on the caret grouped under an empty strikeout: the window shows + // and edits the caret's text, the proposal's. + let 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()); 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 }; @@ -474,7 +491,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr 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, &a, tl!("Add a comment")); + 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); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 5e66092d98..24f34eeb19 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3091,6 +3091,93 @@ trailer << /Root 1 0 R >> 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() +} + +/// Review on #2137: 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. +#[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"); + } +} + +/// Review on #2137: 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). From a3842f4a85471edfd4d516d6c2565794ce73f834 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:44:50 +0800 Subject: [PATCH 39/48] M5: a locked Replace Text proposal's window shows its replacement read-only For Replace Text as main writes it (#5688), the window shows and edits the caret's text, but the caret is the group's subordinate: locking the comment sets /Locked on the strikeout, the primary, and the caret's own flag left the text editable from the window. The text box is now read-only when either is locked. The tests' comments no longer call this a review point. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/tools/comments/popups.rs | 8 +++-- crates/ui-egui/tests/it/comments.rs | 33 +++++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index b89e23caba..84350212ec 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -457,10 +457,11 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr && 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 PdfCraft used to write it keeps the + // 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. - let holder = info + // and edits the caret's text, the proposal's. Locking the comment locks that + // text too. + let mut holder = info .annotations .iter() .find(|r| { @@ -471,6 +472,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr }) .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 }; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index b3f18643f8..d5eb8b495c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3119,10 +3119,10 @@ trailer << /Root 1 0 R >> .into_bytes() } -/// Review on #2137: 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. +/// 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] { @@ -3142,8 +3142,29 @@ fn a_replace_text_window_shows_the_replacement_as_its_text_not_as_a_reply() { } } -/// Review on #2137: 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 +/// 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() { From a0254022cf1a0e2b73edf4555b313182ea2f6f31 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:47:29 +0800 Subject: [PATCH 40/48] M5: selecting from anywhere but a page press forgets the group member a press picked CommentView remembered the group member a press on the page picked, so Delete acts on the member clicked, as Acrobat desktop does. The memory outlived the selection it came from: after choosing the group's card in the Comments panel, a deletion, an undo or a selection made by a tool, Delete still took the member clicked earlier. Every selection now goes through CommentView::select(), which clears the remembered member; pick() (a press on the page: the Select tool's press and click, and the context-menu press) is the only place that sets it. The panel's cards and their Delete, Delete and Backspace, Escape, the context menus (Delete, Flatten comments), deletion and undo (annotation_deleted, history_stepped), the drawing tools, the measuring panel and the control channel use select(). Test: choosing_the_groups_card_makes_delete_act_on_the_card_not_the_member_clicked (ui-egui). With select() leaving the member in place it fails: the Circle clicked earlier goes instead of the Square the card shows (left: ["Square"], right: ["Circle"]). CodeRabbit review on #4358. (cherry picked from commit a24b5ae4e8590be640028f501c9fe87cfdf679d2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP --- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/editing.rs | 6 ++-- crates/ui-egui/src/panels/comments_panel.rs | 4 +-- crates/ui-egui/src/tools/comments/input.rs | 40 ++++++++++++--------- crates/ui-egui/src/tools/comments/menu.rs | 4 +-- crates/ui-egui/src/tools/comments/state.rs | 20 +++++++++-- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/comments.rs | 23 ++++++++++++ 9 files changed, 75 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..5ab24328f7 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -383,7 +383,7 @@ impl PdfCraftApp { let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?; let (p, i): (usize, usize) = (p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?); - v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1))); + v.comments.select(Some((p.saturating_sub(1), i.saturating_sub(1)))); v.comments.reveal = true; } // `--combine-view list`: Combine files as thumbnails (grid) or as the table (list). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a220644421..b6c9a8aec0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3987,7 +3987,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/editing.rs b/crates/ui-egui/src/editing.rs index b810e2abe5..7d73d004cf 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -58,12 +58,12 @@ fn select_after_edit( crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) ) => { - view.comments.selected = None; + view.comments.select(None); } Edit::AddAnnotation(a) => { // Select the new comment (appended last among the page's comments). let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); - view.comments.selected = newest.map(|n| (a.page, n)); + view.comments.select(newest.map(|n| (a.page, n))); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, // unless a note typed into another card is still unsaved. @@ -697,7 +697,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; } diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index cae1afc4eb..cc3b9c2494 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -571,7 +571,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { - view.comments.selected = Some(key); + view.comments.select(Some(key)); view.comments.reply.clear(); *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } @@ -622,7 +622,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti ui.separator(); // A locked reply can't be deleted, so it keeps the comment it answers too. if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; + view.comments.select(None); edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5d7c870154..305b537d82 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -32,8 +32,10 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, { cv.context_at = Some((cx.page, cx.to_user(p))); // On a group subordinate, its group (the primary), so the menu offers the comment's actions. - cv.selected = cx.click_hit(p).map(|a| (cx.page, a.index)); - cv.member = cx.picked(p, cv.selected); + match cx.click_hit(p) { + Some(a) => cv.pick(cx, p, a), + None => cv.select(None), + } } let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { @@ -209,7 +211,7 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu consumed = true; } CommentPart::Body if !is_markup(&a.subtype) => { - cv.selected = Some((cx.page, a.index)); + cv.pick(cx, o, a); cv.reveal = true; if cx.allowed { cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o }); @@ -264,12 +266,14 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu // leaves comments unselected. match owner { Some((a, _)) => { - cv.selected = Some((cx.page, a.index)); - cv.member = pointer.and_then(|p| cx.picked(p, cv.selected)); + match pointer { + Some(p) => cv.pick(cx, p, a), + None => cv.select(Some((cx.page, a.index))), + } cv.reveal = true; consumed = true; } - None => cv.selected = None, + None => cv.select(None), } } if resp.double_clicked() @@ -344,7 +348,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 @@ -384,7 +388,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())); @@ -435,7 +439,7 @@ impl Gestures<'_> { } _ => { cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; + cv.select(None); } } } @@ -467,7 +471,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 @@ -504,7 +508,7 @@ impl Gestures<'_> { && let Some(p) = pointer { cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; + cv.select(None); } true } @@ -526,7 +530,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 } @@ -548,7 +552,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 @@ -578,7 +582,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 } @@ -595,7 +599,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool let cv = &mut view.comments; if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { // One step back per press: cancel the gesture, then deselect, then drop the tool. - let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); + let cancelled = cv.gesture.take().is_some() || { + let deselected = cv.selected.is_some(); + cv.select(None); + deselected + }; if !cancelled && matches!(tool, QuickTool::Comment(_)) { *tool = QuickTool::Select; } @@ -604,7 +612,7 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool && let Some((page, index)) = cv.target() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { - cv.selected = None; + cv.select(None); view.pending_edit = Some(Edit::DeleteAnnotation { page, index }); } } diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index af2b792f1a..708b31cad4 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -96,7 +96,7 @@ pub(crate) fn context_menu( // subordinate, Delete takes just it, as Acrobat's does. let (dpage, dindex) = view.comments.target().unwrap_or((page, index)); if ui.add_enabled(allowed && deletable(dpage, dindex), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; + view.comments.select(None); action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page: dpage, index: dindex }))); ui.close(); } @@ -127,7 +127,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/state.rs b/crates/ui-egui/src/tools/comments/state.rs index b987ab5ba0..149357dfbe 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -202,6 +202,22 @@ impl CommentView { } } + /// Select `key`, 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)>) { + self.selected = key; + self.member = None; + } + + /// Select `a`, the comment a press at `p` on this page selects ([`PageCx::click_hit`]), + /// 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.selected = Some((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 /// point's removal cancels the drawing. Returns whether there was such a drawing. pub fn undo_point(&mut self) -> bool { @@ -240,7 +256,7 @@ impl CommentView { /// were on, where it now is (deleting a reply keeps its comment selected), and close when it /// went too. pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { - self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); + self.select(self.selected.and_then(|k| after_delete(before, after, gone, k))); 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; @@ -252,7 +268,7 @@ impl CommentView { /// editor close, its unsaved text with it, rather than Save writing that text over whatever /// took its place. pub(crate) fn history_stepped(&mut self) { - self.selected = None; + self.select(None); self.editing = None; self.editor_focus = None; } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..60481f65da 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -562,7 +562,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } if let Some((page, annotation, reading, scale)) = select { app.views[index].current = page; - app.views[index].comments.selected = Some((page, annotation)); + app.views[index].comments.select(Some((page, annotation))); app.views[index].measure.reading = Some(reading); app.views[index].measure.scale = Some(scale); app.quick_tool = QuickTool::Select; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 3b59436784..879c155829 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3108,6 +3108,29 @@ fn delete_deletes_the_group_member_clicked() { 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"); +} + /// `n` pages with one comment each, "Card 1" to "Card n". fn one_comment_per_page(n: usize) -> Vec { let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; From 03da84523b061d6fcc8a1d1f7e9ad6d91111f74f Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Wed, 4 Nov 2026 01:56:46 +0800 Subject: [PATCH 41/48] M5: the edit-composer whitespace test opens a note's composer from its menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test #5954 added on main double-clicks a sticky note and expects the edit composer. On this branch a double-click on a note opens its pop-up window instead, so for a note the test now checks that the pop-up opened, then opens the edit composer with the page menu's "Edit text…" and goes on as before: the composer still trims trailing whitespace from a non-typewriter comment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/ui-egui/tests/it/comments.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index e1dab26b61..cae06493b9 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -4035,7 +4035,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())); From 4d89082ab54633979193e0f8132a2cbadd089369 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 04:22:50 +0800 Subject: [PATCH 42/48] M5: group selection goes through the page router's one look at the point (#4358) main now checks that only listed functions ask which comment lies under a point. The group lookup is `click_target` (a subordinate stands for its primary, ISO 32000-2 12.5.6.2), listed with `picked`; a right-click acts on the comment the router already found instead of looking again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/ui-egui/src/canvas/page_pointer/tests.rs | 9 +++++++-- crates/ui-egui/src/tools/comments/input.rs | 7 ++++--- crates/ui-egui/src/tools/comments/state.rs | 4 ++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 3f5d1e105f..634faf0cbc 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -559,9 +559,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/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d0a1b3fdc1..5012c821a7 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -103,7 +103,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op // 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_hit(p)?, true), + 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. @@ -131,7 +131,7 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } - let a = cx.click_hit(p)?; + let a = cx.click_target(p)?; Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } @@ -146,7 +146,8 @@ pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) { /// anything else selects none. pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { match index { - Some(i) => match cx.click_hit(p).filter(|a| a.index == i) { + // `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))), }, diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 4d5f63e792..33177590ba 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -208,7 +208,7 @@ impl CommentView { self.member = None; } - /// Select `a`, the comment a press at `p` on this page selects ([`PageCx::click_hit`]), + /// 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) { @@ -439,7 +439,7 @@ impl PageCx<'_> { /// 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_hit(&self, p: Pos2) -> Option<&Annotation> { + pub(super) fn click_target(&self, p: Pos2) -> Option<&Annotation> { self.comment_at(p).or_else(|| self.member_at(p).map(|(_, p)| p)) } From abb29ba7e129bcb2a9ffe70d13cc776d3790412c Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:14:10 +0800 Subject: [PATCH 43/48] M5: keep the pop-up raster rule in a helper after merging main (clippy cognitive complexity) Main's signature-state redraw in apply_edit pushed the function over the cognitive-complexity limit with the pop-up exception inline; the rule moves into one_page_changed unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/ui-egui/src/editing.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5923e9c435..969af5bdf4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -219,12 +219,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. 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). - if !matches!(edit, Edit::SetAnnotationPopup { .. }) { - 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 { @@ -946,6 +941,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), From 389e0e983944e153ee18cb2562379b13d99e15cd Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 11:36:22 +0800 Subject: [PATCH 44/48] M5: pop-up title bars and resize grips move only with the primary button Main's page-pointer guard (NOT_PAGE_DRAGS) refuses any-button drag starts in page modules; a right-button drag on a pop-up's bar no longer moves it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/ui-egui/src/tools/comments/popups.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index 8b529dfa0a..839cdcd9c7 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -425,7 +425,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr if grip.hovered() || grip.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } - if grip.drag_started() { + if grip.drag_started_by(egui::PointerButton::Primary) { view.comments.popup_drag = Some((page, a.index, pos)); } if grip.dragged() @@ -514,7 +514,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr 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() { + if grip.drag_started_by(egui::PointerButton::Primary) { view.comments.popup_resize = Some((page, a.index, size)); } if grip.dragged() From c994a0a4ac8c2ea8ee59940d6fb894fab5ba48b8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:29:25 +0700 Subject: [PATCH 45/48] Fit the landed changes together Comment literals in tests gain the callout text box and pop-up fields, and the review-state map is formatted. --- crates/render/src/inspect/model.rs | 9 ++++----- crates/render/src/inspect/tests.rs | 2 ++ crates/ui-egui/src/tools/comments/tests.rs | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 2ed99ced5e..e79c4cf0b9 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -616,11 +616,8 @@ impl<'a> ReviewStates<'a> { 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(); + 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 { @@ -823,6 +820,8 @@ mod review_state_tests { fill_sign: false, no_rotate: false, points: Vec::new(), + text_box: None, + popup: None, }) .collect(); link_replies(&mut all); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 9ef34ebe56..119136a2ce 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -2081,6 +2081,8 @@ mod group_tests { fill_sign: false, no_rotate: false, points: Vec::new(), + text_box: None, + popup: None, } } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index d6a0bee369..6c3e4ea163 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -579,6 +579,8 @@ fn a_replace_text_group_makes_replace_text_defaults() { 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)]; From 85ebbab0ce219fa659bd0a560f74a79eea6c5f52 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:33:09 +0700 Subject: [PATCH 46/48] Fit the landed changes together: modify.rs imports polygon_rect --- crates/annot/src/modify.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index a262db39c4..eb77e3f973 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,6 +1,6 @@ use super::create::{ 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, rect_for, rgb, + 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::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; From 99eaa73ca896f05e5edce5b9f56c24cb461ec028 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:54:18 +0700 Subject: [PATCH 47/48] Fit the landed changes together: a double-click on a group member opens the group's pop-up --- crates/ui-egui/tests/it/comments.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 4dfd738409..3327e02fcd 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3599,7 +3599,8 @@ fn a_group_is_one_comment_in_the_panel_and_on_the_page() { 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 edits the group's text, which is the Square's. + // 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); @@ -3612,8 +3613,11 @@ fn a_group_is_one_comment_in_the_panel_and_on_the_page() { h.run_steps(2); h.state_mut().views[0].comments.selected = None; crate::support::input::double_click(&mut h, p); - let composer = h.state().views[0].comments.composer.as_ref().map(|c| c.text.clone()); - assert_eq!(composer.as_deref(), Some("Primary"), "a double-click on the Circle edits its group's text"); + 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 From fe3879bc880a4780110105b40fc44506d9827c60 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 21:14:18 +0700 Subject: [PATCH 48/48] Windows gate: the owner-rights test allows an elevated run to replace the list, if it then restricts the file (#6729) --- crates/platform/src/owner_only.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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]