mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 03:01:55 +00:00
- Renderer cache: built with the entry API instead of insert-then-`expect`. - Page-text cache: filled and read with `get`/`get_mut`. - Schemas and form JSON: extended through `if let` instead of `as_object_mut().expect`. - A replace-text mark of the wrong shape is an invalid-argument error. The crate now denies the panic lints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
496 lines
24 KiB
Rust
496 lines
24 KiB
Rust
//! 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 printcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
|
use printcraft_render::{Annotation, PageInfo};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{Args, Automation, Result, ToolError, failed};
|
|
|
|
/// Author used when a tool call names none.
|
|
pub(crate) const DEFAULT_AUTHOR: &str = "PrintCraft";
|
|
|
|
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)])
|
|
}
|
|
|
|
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])]
|
|
}
|
|
|
|
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 {
|
|
/// 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")? {
|
|
let c =
|
|
all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (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 p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
|
|
json!({
|
|
"id": c.name,
|
|
"page": c.page + 1,
|
|
"index": c.index + 1,
|
|
"type": c.subtype,
|
|
"author": c.author,
|
|
"contents": c.contents,
|
|
"modified": c.modified,
|
|
"rect": rect_to_view(p, c.rect),
|
|
"color": c.color.map(hex),
|
|
})
|
|
};
|
|
let roots: Vec<Value> = all
|
|
.iter()
|
|
.filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p))
|
|
.map(|c| {
|
|
let mut v = view(c);
|
|
let thread: Vec<&Annotation> = match &c.name {
|
|
Some(nm) => all.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
|
|
None => Vec::new(),
|
|
};
|
|
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone());
|
|
v["status"] = json!(status);
|
|
v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
|
|
v["locked"] = json!(c.locked);
|
|
v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).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 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: to_user(&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")?);
|
|
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), arrow: kind == "arrow" }
|
|
}
|
|
"ink" => {
|
|
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<_>>>()?;
|
|
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" => Shape::PolyLine { vertices },
|
|
_ => 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) }
|
|
}
|
|
"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 = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
|
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) => printcraft_engine::AttachIcon::from_name(n)
|
|
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?} (PushPin, Paperclip, Graph, Tag)")))?,
|
|
None => printcraft_engine::AttachIcon::PushPin,
|
|
};
|
|
Shape::Attachment { at: to_user(&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)
|
|
}
|
|
|
|
pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result<Value> {
|
|
use printcraft_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 size = 10.0;
|
|
let text_at = |t: &str| {
|
|
let w = (printcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
|
|
let h = size * 1.2 + 6.0;
|
|
Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size }
|
|
};
|
|
let (shape, contents) = match a.str("type")? {
|
|
"text" => {
|
|
let t = a.str("text")?.to_string();
|
|
(text_at(&t), t)
|
|
}
|
|
"date" => {
|
|
let (yy, m, d) = self.session.today();
|
|
let t = format!("{m}/{d}/{yy}");
|
|
(text_at(&t), t)
|
|
}
|
|
// A typed signature or initials in the script font, left edge at `at`.
|
|
kind @ ("signature" | "initials") => {
|
|
let t = a.str("text")?;
|
|
let h = if kind == "initials" { 24.0 } else { 32.0 };
|
|
let shape =
|
|
printcraft_engine::typed_signature_shape(at, t, h).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:?}"))),
|
|
};
|
|
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
|
|
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
|
|
}
|
|
};
|
|
let style = Style::default_for(&shape);
|
|
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);
|
|
self.session.set_hide_comments(id, hidden);
|
|
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 });
|
|
}
|
|
if let Some(r) = a.nums::<4>("rect")? {
|
|
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
|
}
|
|
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 edits.is_empty() {
|
|
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, rect or move".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 `width` points wide.
|
|
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 path = self.resolve(a.str("path")?, false)?;
|
|
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
|
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 [x, y] = a.need::<2>("at", "a stamp (its centre)")?;
|
|
let [ux, uy] = to_user(&info, x, y);
|
|
let edit = Edit::AddCustomStamp {
|
|
page,
|
|
rect: [ux, uy, ux, uy],
|
|
name,
|
|
file: printcraft_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");
|
|
}
|
|
}
|