Files
pdfcraft/crates/automation/src/comments.rs
T
496e37d72b M5: text boxes and typed text take an oval or a circle for their border (#4543)
* Fill & Sign: a Border control for typed text, and a border colour of its own

Rebased onto main as one commit: main's Fill & Sign text colour (#2444) runs through the
same places as the border (the open box, typed(), the page input, the toolbar and the
preferences), so every step of the branch met it again. Text colour and border now travel
together as TypedLook (typed_with), which keeps typed() and the new function under Clippy's
argument limit; a border without its own colour takes the text's. Make Current Properties
Default on typed text also keeps its colour for new text now.

The branch's commits, in order:
- Typewriter text: a border change grows its box on every side, so the text keeps its place
- Fill & Sign: a Border control for typed text and dates
- Parity: the typewriter border's evidence
- i18n: the Border label in every catalog, recorded in ATTRIBUTION.toml
- Text boxes, typewriter text and callouts: a border colour of their own
- Fill & Sign: the toolbar follows the selected text; a Border colour button
- fill_sign_add and comment_edit take `border_color`
- Parity: the border colour and the toolbar following the selection
- i18n: Border colour and Same as text in every catalog, recorded in ATTRIBUTION.toml
- Typed text's border: /Border's width is read, a non-finite border colour refused, a saved Same as text restored
- Fill & Sign: the toolbar changes only the selected text; Make Current Properties Default keeps new text's border
- Review: Make Properties Default gives a text box's or callout's tool its border colour
- i18n: the new labels in the Persian catalog, recorded in ATTRIBUTION.toml
- Review: a test that dragging the Font size control resizes the selected text to where the drag ends
- Rebase: the typed text's border in main's annot and comments modules (Style's border colour, exports), and craft_ui's ComboBox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5: text boxes and typed text take an oval or a circle for their border

Rebased onto #4146's new head as one commit (see there: main's Fill & Sign text colour runs
through the same places). The border shape goes into TypedLook's border with the width and
colour; a circle around typed text still takes the snug box.

The branch's commits, in order:
- Text boxes and typed text take an oval or a circle for their border
- A circle goes snug around its text
- Review: fill_sign_add takes an oval or a circle only with a border
- Review: a circle made on a quarter-turned page is sized from the text as shown
- Review: Border ▸ None takes an oval or a circle away with the line
- Review: the Border colour button shows the selected text box's own colour
- i18n: Border shape in the Persian catalog, recorded in ATTRIBUTION.toml
- Review: Border ▸ None on a circled text is one undo step
- Rebase: the border shape in main's annot, engine and comments modules; craft_ui's ComboBox; Fill & Sign's border controls in a function of their own (main's complexity limit)
- Review: Thickness 0 in Comment Properties takes an oval or a circle away, as Border ▸ None does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open the Font size popup first in the drag test: main moved the size into a popup

* Border shape per selected comment, and split the oval/circle border out of draw_free_text (clippy cognitive complexity)

* Re-run CI (the push of the main merge did not start it)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 17:32:01 +01:00

904 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Comment tools: list, add, reply, set status, edit and delete (execution plan M5, AGENTS.md §3).
//!
//! Geometry follows the automation convention: points from the top-left of the displayed page,
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
use std::collections::HashMap;
use pdfcraft_engine::{
BorderShape, Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
};
use pdfcraft_render::{Annotation, PageInfo};
use serde_json::{Value, json};
use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError, encode_png, failed};
/// Author used when a tool call names none.
pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft";
/// The largest side of a stamp's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
const MAX_STAMP_SIDE: f64 = 14_400.0;
pub(crate) fn parse_color(s: &str) -> Result<Rgb> {
let named = match s.to_ascii_lowercase().as_str() {
"yellow" => Some("#FFEF00"),
"red" => Some("#E32222"),
"orange" => Some("#FF8A00"),
"green" => Some("#2E9E5C"),
"blue" => Some("#0078D6"),
"purple" => Some("#8A3FD1"),
"pink" => Some("#FF5FA2"),
"black" => Some("#000000"),
"gray" | "grey" => Some("#808080"),
"white" => Some("#FFFFFF"),
_ => None,
};
let hex = named.unwrap_or(s).trim_start_matches('#');
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ToolError::InvalidArgs(format!("{s:?} is not a colour (use #RRGGBB or a name)")));
}
let c = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map(|v| v as f64 / 255.0).unwrap_or(0.0);
Ok([c(0), c(2), c(4)])
}
const ENDING_NAMES: &str = "None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash";
fn line_endings(a: &Args) -> Result<Option<Vec<LineEnding>>> {
let Some(v) = a.get("endings") else { return Ok(None) };
let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs("endings must be an array of line-ending names".into()))?;
if arr.is_empty() || arr.len() > 2 {
return Err(ToolError::InvalidArgs("endings takes one name for a callout, or two for a line or polyline".into()));
}
arr.iter()
.map(|item| {
let name = item.as_str().ok_or_else(|| ToolError::InvalidArgs("endings must be line-ending names".into()))?;
LineEnding::parse(name).ok_or_else(|| ToolError::InvalidArgs(format!("unknown line ending {name:?} ({ENDING_NAMES})")))
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// `[start, end]` for a line, arrow or polyline. An arrow with no `endings` stays `[None, OpenArrow]`.
fn ending_pair(kind: &str, endings: Option<&[LineEnding]>) -> Result<[LineEnding; 2]> {
match endings {
None if kind == "arrow" => Ok([LineEnding::None, LineEnding::OpenArrow]),
None => Ok([LineEnding::None, LineEnding::None]),
Some(v) if v.len() == 2 => Ok([v.first().copied().unwrap_or(LineEnding::None), v.get(1).copied().unwrap_or(LineEnding::None)]),
Some(_) => Err(ToolError::InvalidArgs("a line or polyline needs two endings, the start and the end".into())),
}
}
fn hex(c: [f32; 3]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02X}{:02X}{:02X}", b(c[0]), b(c[1]), b(c[2]))
}
fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
let [ux, uy] = p.view_to_user(x as f32, y as f32);
[ux as f64, uy as f64]
}
pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3]));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
/// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the
/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments
/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner
/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`).
fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
to_user(p, x, y)
}
/// A user-space rectangle as the displayed-page rectangle every tool reports and accepts:
/// origin at the top-left after `/Rotate`, rounded to 1/100 pt.
pub(crate) fn rect_to_view(p: &PageInfo, r: [f32; 4]) -> [f32; 4] {
let (a, b) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3]));
let round = |v: f32| (v * 100.0).round() / 100.0;
[round(a[0].min(b[0])), round(a[1].min(b[1])), round(a[0].max(b[0])), round(a[1].max(b[1]))]
}
impl Args<'_> {
pub(crate) fn nums<const N: usize>(&self, key: &str) -> Result<Option<[f64; N]>> {
let Some(v) = self.get(key) else { return Ok(None) };
let wrong = || Self::wrong(key, &format!("an array of {N} numbers"));
let arr = v.as_array().ok_or_else(wrong)?;
let nums: Vec<f64> = arr.iter().map(|x| x.as_f64().filter(|f| f.is_finite())).collect::<Option<_>>().ok_or_else(wrong)?;
<[f64; N]>::try_from(nums).map(Some).map_err(|_| wrong())
}
fn need<const N: usize>(&self, key: &str, why: &str) -> Result<[f64; N]> {
self.nums(key)?.ok_or_else(|| ToolError::InvalidArgs(format!("{why} needs `{key}`")))
}
fn color(&self, key: &str) -> Result<Option<Rgb>> {
self.opt_str(key)?.map(parse_color).transpose()
}
}
impl Automation {
/// One of the read-only layers the GUI uses to drag/resize an embedded image signature.
pub(crate) fn comment_image_preview(&self, a: &Args) -> Result<Vec<Content>> {
let (page, index) = self.comment_target(a)?;
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let doc = self.doc(a)?;
let preview = doc.image_signature_preview(page, index).map_err(failed)?.ok_or_else(|| failed("choose an image signature or initials"))?;
let annotation = doc.info.annotations.iter().find(|c| c.page == page && c.index == index).ok_or_else(|| failed("no such comment"))?;
let info = doc.info.pages.get(page).ok_or_else(|| failed("no such page"))?;
let [w, h] = preview.image.size();
let layer = a.opt_str("layer")?.unwrap_or("background");
let image = match layer {
"image" => Content::Png { data: preview.image.bytes().as_ref().clone(), width: w as u32, height: h as u32 },
"background" => {
let out = preview.render_background((dpi / 72.0) as f32).map_err(failed)?;
Content::Png { data: encode_png(out.width, out.height, &out.rgba)?, width: out.width, height: out.height }
}
_ => return Err(ToolError::InvalidArgs("layer must be background or image".into())),
};
Ok(vec![
Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation,
"image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360),
"layer": layer, "opacity": preview.opacity, "dpi": dpi })),
image,
])
}
/// Comments of a document, optionally only of one 0-based page.
fn comments(&self, a: &Args) -> Result<Vec<Annotation>> {
let doc = self.doc(a)?;
Ok(doc.info.annotations.iter().filter(|x| x.subtype != "Popup").cloned().collect())
}
/// The (0-based page, index) a comment tool call refers to: `id`, or `page` + `index`.
fn comment_target(&self, a: &Args) -> Result<(usize, usize)> {
let all = self.comments(a)?;
if let Some(id) = a.opt_str("id")? {
// `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`):
// the first match could be another page's comment, so a shared id is refused.
let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id));
let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?;
if named.next().is_some() {
return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)")));
}
return Ok((c.page, c.index));
}
let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else {
return Err(ToolError::InvalidArgs("pass the comment's id, or page and index".into()));
};
let (page, index) = ((page.max(1) - 1) as usize, (index.max(1) - 1) as usize);
if !all.iter().any(|c| c.page == page && c.index == index) {
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
}
Ok((page, index))
}
pub(crate) fn comment_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
let all = self.comments(a)?;
let view = |c: &Annotation| -> Value {
let mut v = json!({
"id": c.name,
"page": c.page + 1,
"index": c.index + 1,
"type": c.subtype,
"intent": c.intent,
"author": c.author,
"contents": c.contents,
"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),
});
// A text box's or typed text's oval or circle (a rectangle is not reported).
if c.subtype == "FreeText"
&& let Some(shape) = doc.comment_props(c.page, c.index).and_then(|p| p.border_shape).filter(|s| *s != BorderShape::Rectangle)
{
v["border_shape"] = json!(shape.name().to_ascii_lowercase());
}
// How opaque it is drawn: as a whole, or its fill and text each at their own.
// Not for replies, which are drawn with the comment they answer.
if !c.is_reply()
&& let Some(p) = doc.comment_props(c.page, c.index)
{
v["opacity"] = json!(p.opacity);
if let Some(f) = p.fill_opacity {
v["fill_opacity"] = json!(f);
}
if let Some(t) = p.text_opacity {
v["text_opacity"] = json!(t);
}
}
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
let page = doc.info.pages.get(c.page);
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::<Vec<_>>());
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. 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.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in &all {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
let key = match r.parent {
Some(parent) if r.state.is_some() => parent,
_ => top,
};
threads.entry(key).or_default().push(r);
}
// An annotation's review status and checkmark: the latest of each set on it.
let states = |c: &Annotation, v: &mut Value| {
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
};
let roots: Vec<Value> = all
.iter()
.filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p))
.map(|c| {
let mut v = view(c);
states(c, &mut v);
v["locked"] = json!(c.locked);
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["replies"] = thread
.iter()
.filter(|r| r.state.is_none())
.map(|r| {
let mut reply = view(r);
states(r, &mut reply);
reply
})
.collect();
v
})
.collect();
Ok(json!({ "count": roots.len(), "comments": roots }))
}
pub(crate) fn comment_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let kind = a.str("type")?;
let endings = line_endings(a)?;
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
}
// Shift while drawing: each end or corner held level or plumb with the one before.
let ortho = a.opt_bool("ortho")?.unwrap_or(false);
if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") {
return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into()));
}
let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p };
let markup = match kind {
"highlight" => Some(Markup::Highlight),
"underline" => Some(Markup::Underline),
"strikeout" | "replace" => Some(Markup::StrikeOut),
"squiggly" => Some(Markup::Squiggly),
_ => None,
};
let shape = if let Some(markup) = markup {
let quads = match (a.opt_str("find")?, a.get("quads")) {
(Some(needle), _) => {
let id = self.doc(a)?.id;
let text = self.page_texts(id, &[page])?.remove(0);
let mut hits = text.find(needle);
if hits.is_empty() {
return Err(failed(format!("{needle:?} was not found on page {}", page + 1)));
}
if !a.opt_bool("all")?.unwrap_or(false) {
hits.truncate(1);
}
hits.into_iter().flat_map(|r| text.line_rects(r)).map(|r| info.view_rect_to_quad(r)).collect()
}
(None, Some(q)) => {
let wrong = || ToolError::InvalidArgs("quads must be arrays of 8 numbers".into());
let quads = q.as_array().ok_or_else(wrong)?;
quads
.iter()
.map(|q| {
let v: Vec<f64> = q.as_array().ok_or_else(wrong)?.iter().map(|x| x.as_f64()).collect::<Option<_>>().ok_or_else(wrong)?;
let v: [f64; 8] = v.try_into().map_err(|_| wrong())?;
let mut out = [0.0; 8];
for i in 0..4 {
let [x, y] = to_user(&info, v[2 * i], v[2 * i + 1]);
(out[2 * i], out[2 * i + 1]) = (x, y);
}
Ok(out)
})
.collect::<Result<Vec<_>>>()?
}
(None, None) => return Err(ToolError::InvalidArgs(format!("{kind} needs `find` (text to mark) or `quads`"))),
};
Shape::TextMarkup { kind: markup, quads }
} else {
match kind {
"note" => {
let [x, y] = a.need::<2>("at", "a note")?;
let icon = match a.opt_str("icon")? {
Some(n) => NoteIcon::from_name(n).ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?}")))?,
None => NoteIcon::Comment,
};
Shape::Note { at: icon_anchor(&info, x, y), icon }
}
"stamp" => {
let want = a.opt_str("stamp")?.unwrap_or("approved").to_ascii_lowercase().replace([' ', '-', '_'], "");
let dynamic = a.opt_bool("dynamic")?.unwrap_or(false);
let stamp = StampKind::ALL
.into_iter()
.find(|k| k.label().to_ascii_lowercase().replace(' ', "") == want && ((k.group() == StampGroup::Dynamic) == dynamic))
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown stamp {want:?} (see the tool description)")))?;
let (w, h) = stamp.size();
let [x, y] = a.need::<2>("at", "a stamp (its centre)")?;
let rect = rect_to_user(&info, [x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0]);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let by = dynamic.then(|| self.session.stamp_by_line(&author));
Shape::Stamp { rect, stamp, by }
}
"rectangle" => Shape::Rectangle { rect: rect_to_user(&info, a.need::<4>("rect", "a rectangle")?) },
"oval" => Shape::Oval { rect: rect_to_user(&info, a.need::<4>("rect", "an oval")?) },
"textbox" => {
let font_size = a.opt_num("font_size")?.unwrap_or(12.0);
Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size }
}
"line" | "arrow" => {
let f = a.need::<2>("from", "a line")?;
let t = held(f, a.need::<2>("to", "a line")?);
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
}
"ink" | "freehand_highlight" => {
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?;
let strokes = strokes
.as_array()
.ok_or_else(wrong)?
.iter()
.map(|s| {
s.as_array()
.ok_or_else(wrong)?
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } }
}
"polygon" | "cloud" | "polyline" => {
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?;
let mut last = None;
let vertices = pts
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => {
let p = last.map_or([*x, *y], |l| held(l, [*x, *y]));
last = Some(p);
Ok(to_user(&info, p[0], p[1]))
}
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
match kind {
"polyline" => {
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::PolyLine { vertices, start, end }
}
_ => Shape::Polygon { vertices, cloud: kind == "cloud" },
}
}
"callout" => {
let rect = rect_to_user(&info, a.need::<4>("rect", "a callout (its text box)")?);
let t = a.need::<2>("to", "a callout (the point it points at)")?;
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]
}
};
Shape::Callout {
rect,
knee,
point,
font_size: a.opt_num("font_size")?.unwrap_or(10.0),
ending: match endings.as_deref() {
None => LineEnding::OpenArrow,
Some([ending]) => *ending,
Some(_) => return Err(ToolError::InvalidArgs("a callout needs one ending".into())),
},
}
}
"attachment" => {
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
let path = self.resolve(a.str("path")?, false)?;
let data = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let icon = match a.opt_str("icon")? {
Some(n) => pdfcraft_engine::AttachIcon::from_name(n)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?} (PushPin, Paperclip, Graph, Tag)")))?,
None => pdfcraft_engine::AttachIcon::PushPin,
};
Shape::Attachment { at: icon_anchor(&info, x, y), icon, file, data }
}
"caret" => {
let [x, y] = a.need::<2>("at", "a caret (the insertion point on the baseline)")?;
Shape::Caret { rect: rect_to_user(&info, [x - 4.0, y, x + 4.0, y + 8.0]) }
}
other => return Err(ToolError::InvalidArgs(format!("unknown comment type {other:?}"))),
}
};
let mut style = Style::default_for(&shape);
if let Some(c) = a.color("color")? {
style.color = c;
}
style.fill = a.color("fill")?;
if let Some(o) = a.opt_num("opacity")? {
style.opacity = o.clamp(0.0, 1.0);
}
// The fill's and a text box's text's own opacities (the line keeps `opacity`).
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. });
if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) {
return Err(ToolError::InvalidArgs(
"fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(),
));
}
if text_opacity.is_some() && !texts {
return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into()));
}
style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0));
style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0));
if let Some(w) = a.opt_num("width")? {
style.width = w.max(0.0);
}
if let Some(b) = border_shape(a)? {
if !matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. }) {
return Err(ToolError::InvalidArgs("border_shape is for a text box or typewriter text".into()));
}
style.border_shape = b;
}
let contents = a.opt_str("contents")?.unwrap_or_default().to_string();
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
// Replace Text: the struck-out text and a grouped caret holding `contents`.
if kind == "replace" {
let Shape::TextMarkup { quads, .. } = shape else {
return Err(ToolError::InvalidArgs("replace marks text: give it text to replace".into()));
};
if contents.trim().is_empty() {
return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into()));
}
let strike = style;
let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?;
out["comment"] = json!({ "page": page + 1, "type": "replace" });
return Ok(out);
}
let marked = match &shape {
Shape::TextMarkup { quads, .. } => Some(quads.len()),
_ => None,
};
let before: Vec<Option<String>> = self.doc(a)?.info.annotations.iter().map(|c| c.name.clone()).collect();
let mut out = self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))?;
let added = self.doc(a)?.info.annotations.iter().find(|c| c.page == page && c.subtype != "Popup" && !before.contains(&c.name));
out["comment"] = json!({ "id": added.and_then(|c| c.name.clone()), "page": page + 1, "index": added.map(|c| c.index + 1) });
if let Some(n) = marked {
out["comment"]["lines"] = json!(n);
}
Ok(out)
}
/// Preferences ▸ Date format: set it with `format` and its names' `language` (`auto`
/// follows the app's interface language, so English here), or read them.
pub(crate) fn fill_sign_date_format(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::dates;
let format = a.opt_str("format")?;
let language = a.opt_str("language")?.map(|l| Some(l).filter(|l| !l.trim().eq_ignore_ascii_case("auto")));
// Check the format before changing anything, so a bad one leaves both settings alone.
if let Some(f) = format {
dates::check_date_format(f).map_err(ToolError::InvalidArgs)?;
}
if let Some(l) = language {
self.session.set_date_language(l).map_err(ToolError::InvalidArgs)?;
}
if let Some(f) = format {
self.session.set_date_format(f).map_err(ToolError::InvalidArgs)?;
}
let today = self.session.today_text(None, None).map_err(ToolError::Failed)?;
let languages: Vec<Value> = dates::DATE_LANGUAGES.iter().map(|l| json!({ "code": l.code, "name": l.name })).collect();
Ok(json!({
"format": self.session.date_format(),
"language": self.session.date_language().unwrap_or("auto"),
// Characters Fill & Sign can't write into a PDF yet; dates with them are refused.
"unwritable": dates::unwritable(&today),
"today": today,
"presets": dates::DATE_FORMATS,
"languages": languages,
}))
}
pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result<Value> {
let border_shape = border_shape(a)?;
use pdfcraft_engine::FillMark;
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let [x, y] = a.need::<2>("at", "Fill & Sign")?;
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let kind = a.str("type")?;
let scan = a.opt_bool("scan")?.unwrap_or(false);
let sensitivity = a.opt_int("sensitivity")?;
if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() {
return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into()));
}
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
let requested_color = a.color("color")?;
if requested_color.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("color is only for type text or date".into()));
}
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
}
let size = requested_size.unwrap_or(10.0);
if !(4.0..=300.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
}
let requested_border = a.opt_num("border")?;
if requested_border.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("border is only for type text or date".into()));
}
let border = requested_border.unwrap_or(0.0);
if !(0.0..=12.0).contains(&border) {
return Err(ToolError::InvalidArgs("border must be 0–12 pt".into()));
}
let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?;
if border_color.is_some() && border <= 0.0 {
return Err(ToolError::InvalidArgs("border_color needs a border".into()));
}
if border_shape.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("border_shape is only for type text or date".into()));
}
// Without a border an oval or a circle would grow the box around nothing drawn.
if border_shape.is_some_and(|s| s != BorderShape::Rectangle) && border <= 0.0 {
return Err(ToolError::InvalidArgs("border_shape needs a border".into()));
}
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
}
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = if scan {
let sensitivity = match sensitivity {
None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY,
Some(s) => u8::try_from(s)
.ok()
.filter(|s| *s <= 100)
.ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?,
};
let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let frame = photo.auto_frame();
photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
} else {
pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
};
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
// A circle goes around the text, so its box is no wider than the text needs.
let min_width = if border_shape == Some(BorderShape::Circle) { 0.0 } else { 20.0 };
let text_at = |t: &str| {
let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max);
let w = (longest + 8.0).clamp(min_width, 600.0);
let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1);
let h = rows as f64 * size * 1.2 + 6.0;
let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
// A border goes around the box, so the text sits where it would without one.
Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size }
};
let (shape, contents) = match kind {
"text" => {
let t = a.str("text")?.to_string();
(text_at(&t), t)
}
"date" => {
let lang = a.opt_str("language")?;
if let Some(l) = lang.filter(|l| pdfcraft_engine::dates::date_language(l).is_none()) {
return Err(ToolError::InvalidArgs(format!("unknown date language {l:?} (see fill_sign_date_format)")));
}
let t = self.session.today_text_for_pdf(a.opt_str("format")?, lang).map_err(ToolError::InvalidArgs)?;
(text_at(&t), t)
}
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
// Nobody sees a preview here: refuse what the script font would leave out.
if let Some(c) = pdfcraft_engine::script_missing(t) {
return Err(ToolError::InvalidArgs(format!(
"the signature script font can't draw \"{}\" (U+{:04X}); type the {kind} in Latin, Cyrillic or Greek letters, or pass `path` to a PNG or JPEG image of it instead of `text`",
c.escape_debug(),
u32::from(c)
)));
}
let h = if kind == "initials" { 24.0 } else { 32.0 };
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
(shape, String::new())
}
kind => {
let mark = match kind {
"check" => FillMark::Check,
"cross" => FillMark::Cross,
"dot" => FillMark::Dot,
"line" => FillMark::Line,
other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))),
};
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
}
};
let mut style = Style::default_for(&shape);
if let Some(color) = requested_color {
style.color = color;
}
if matches!(shape, Shape::Typewriter { .. }) {
style.width = border;
style.border_color = border_color;
style.border_shape = border_shape.unwrap_or_default();
}
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
}
pub(crate) fn comment_reply(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
self.apply(a, Edit::ReplyToAnnotation { page, index, text: a.str("text")?.to_string(), author })
}
pub(crate) fn comment_set_status(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let status = a.str("status")?;
let state = ReviewState::from_name(status).ok_or_else(|| ToolError::InvalidArgs(format!("unknown status {status:?}")))?;
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
self.apply(a, Edit::SetAnnotationStatus { page, index, state, author })
}
pub(crate) fn comment_mark(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let marked = a.opt_bool("marked")?.unwrap_or(true);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
}
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let locked = a.opt_bool("locked")?.unwrap_or(true);
self.apply(a, Edit::LockAnnotation { page, index, locked })
}
pub(crate) fn comments_hide(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let hidden = a.opt_bool("hidden")?.unwrap_or(true);
if self.session.set_hide_comments(id, hidden) {
// The cached renderer drew with the old setting; page_render must read it again.
self.renderers.remove(&id);
}
Ok(json!({ "hidden": hidden }))
}
pub(crate) fn comments_summarize(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let (id, stem) = (doc.id, std::path::Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());
let sort = match a.opt_str("sort")? {
Some(s) => SummarySort::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown sort {s:?}")))?,
None => SummarySort::Page,
};
let bytes = self.session.summarize_comments(id, sort).map_err(failed)?;
self.deliver(a, &format!("Summary of comments on {stem}.pdf"), bytes)
}
pub(crate) fn comment_edit(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let mut edits = Vec::new();
if let Some(text) = a.opt_str("contents")? {
edits.push(Edit::SetAnnotationContents { page, index, text: text.to_string() });
}
let (color, opacity, width) = (a.color("color")?, a.opt_num("opacity")?, a.opt_num("width")?);
if color.is_some() || opacity.is_some() || width.is_some() {
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
}
if let Some(f) = a.opt_str("fill")? {
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
edits.push(Edit::FillAnnotation { page, index, fill });
}
if let Some(c) = a.opt_str("border_color")? {
let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) };
edits.push(Edit::BorderColorAnnotation { page, index, color });
}
if let Some(shape) = border_shape(a)? {
edits.push(Edit::BorderShapeAnnotation { page, index, shape });
}
// After `opacity`, which sets the whole comment's.
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) {
return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into()));
}
if fill_opacity.is_some() || text_opacity.is_some() {
edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity });
}
if let Some(size) = a.opt_num("font_size")? {
if !(1.0..=400.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
}
edits.push(Edit::FontSizeAnnotation { page, index, size });
}
if let Some(r) = a.nums::<4>("rect")? {
// The rectangle as comment_list reports it: where the comment is drawn.
let doc = self.doc(a)?;
let rect = rect_to_user(&info, r);
let rect =
doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation));
edits.push(Edit::ResizeAnnotation { page, index, rect });
}
if let Some([dx, dy]) = a.nums::<2>("move")? {
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
}
if let Some(pts) = a.get("points") {
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
let points = pts
.as_array()
.ok_or_else(wrong)?
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
edits.push(Edit::ReshapeAnnotation { page, index, points });
}
if edits.is_empty() {
return Err(ToolError::InvalidArgs(
"nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, border_shape, font_size, rect, move or points".into(),
));
}
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
self.apply(a, edit)
}
pub(crate) fn comment_delete(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
self.apply(a, Edit::DeleteAnnotation { page, index })
}
/// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs).
pub(crate) fn comments_copy(&self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let all = self.comments(a)?;
let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into());
let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?;
let mut which = Vec::with_capacity(list.len());
for item in list {
let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));
let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) };
let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?);
if !all.iter().any(|c| c.page == page && c.index == index) {
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
}
which.push((page, index));
}
let xfdf = self.session.export_comments(id, &which).map_err(failed)?;
Ok(json!({ "xfdf": xfdf, "comments": which.len() }))
}
/// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments.
pub(crate) fn comments_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let xfdf = a.str("xfdf")?.to_owned();
if !pdfcraft_engine::is_xfdf_comments(&xfdf) {
return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with <annots>)".into()));
}
let offset = match a.nums::<2>("at")? {
Some([x, y]) => {
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?;
let [cx, cy] = to_user(&info, x, y);
let b =
pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?;
Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0])
}
None => None,
};
let before = self.comments(a)?.len();
let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?;
out["comments"] = json!(self.comments(a)?.len().saturating_sub(before));
Ok(out)
}
}
impl Automation {
/// A custom stamp: a picture file (PDF page or image) centred at `at`, at its natural size
/// (at most 200 pt), or fitted inside `rect`, keeping its shape and centred (#4077).
pub(crate) fn stamp_custom(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let rect = match (a.nums::<4>("rect")?, a.get("at")) {
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass at (a point) or rect (a box), not both".into())),
(Some(r), None) => {
let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
if !((1.0..=MAX_STAMP_SIDE).contains(&w) && (1.0..=MAX_STAMP_SIDE).contains(&h)) {
return Err(ToolError::InvalidArgs(format!(
"rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_STAMP_SIDE} pt"
)));
}
rect_to_user(&info, r)
}
(None, _) => {
let [x, y] = a.need::<2>("at", "a stamp (its centre, or pass rect)")?;
let [ux, uy] = to_user(&info, x, y);
[ux, uy, ux, uy]
}
};
let path = self.resolve(a.str("path")?, false)?;
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 edit = Edit::AddCustomStamp {
page,
rect,
name,
file: pdfcraft_engine::MarkFile {
name: file,
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(),
};
self.apply(a, edit)
}
}
/// `border_shape`: `rectangle`, `oval` or `circle`.
fn border_shape(a: &Args) -> Result<Option<BorderShape>> {
a.opt_str("border_shape")?
.map(|s| BorderShape::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown border_shape {s:?}: rectangle, oval or circle"))))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colours_parse_by_hex_and_name() {
assert_eq!(parse_color("#FF0000").unwrap(), [1.0, 0.0, 0.0]);
assert_eq!(parse_color("black").unwrap(), [0.0, 0.0, 0.0]);
assert!(parse_color("#12").is_err() && parse_color("chartreuse").is_err());
assert_eq!(hex([1.0, 0.5, 0.0]), "#FF8000");
}
}