Files
pdfcraft/crates/automation/src/comments.rs
T
2026-11-09 18:23:14 +07:00

943 lines
50 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)?;
// A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's
// attributes: the subordinates are neither comments nor replies, only its `group`.
let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) {
if let Some(p) = p.and_then(|p| all.get(p)) {
members.entry((p.page, p.index)).or_default().push(m);
}
}
let view = |c: &Annotation| -> Value {
let group: Vec<Value> = 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,
"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),
"group": group,
// Unchanged on a page the viewer could not read, as `rect` is.
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
});
// A text box's or typed text's oval or circle (a rectangle is not reported).
if c.subtype == "FreeText"
&& 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, 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;
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<_>>());
}
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
if let Some(tb) = c.text_box {
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
// checkmark replies are read through `review` instead. A group subordinate is in no
// thread, and a reply to one is the group's, its primary's.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
threads.entry(top).or_default().push(r);
}
// An annotation's review states. A state reply's `/IRT` refers to the annotation whose
// state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2
// §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status`
// is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the
// specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one
// on, and `marked` whether anyone has.
let review = pdfcraft_render::ReviewStates::new(&all);
let states = |c: &Annotation, v: &mut Value| {
v["status"] = json!(review.latest_status(c));
v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::<Vec<_>>());
let marked_by = review.marked(c);
v["marked"] = json!(!marked_by.is_empty());
v["marked_by"] = json!(marked_by);
};
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()
.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]),
// Acrobat's: 12 pt out from the middle of the side facing the point.
None => pdfcraft_engine::callout_knee(rect, point),
};
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()));
}
// `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the
// caret carries the colour too. Only the colour: opacity and the rest are the
// caret's own.
let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
caret.color = style.color;
let strike = style;
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_popup(&mut self, a: &Args) -> Result<Value> {
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<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 author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
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,
},
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)
}
}
/// `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");
}
}