Files
pdfcraft/crates/automation/src/comments.rs
T
AdrianTM 0d3f57e120 M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) (#5954)
* M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850)

* M5: preserve typewriter whitespace through readers and edits (#5850)

* M5: retain direct-subtype comment listing for Fill & Sign fix (#5850)
2026-11-03 10:24:45 -05:00

759 lines
39 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::{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]
}
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 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()));
}
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, t) = (a.need::<2>("from", "a line")?, 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 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)]) => Ok(to_user(&info, *x, *y)),
_ => 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);
}
if let Some(w) = a.opt_num("width")? {
style.width = w.max(0.0);
}
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> {
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()));
}
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);
}
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(20.0, 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 rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
Shape::Typewriter { rect, 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;
}
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(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, width, fill, 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 })
}
}
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)
}
}
#[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");
}
}