diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs new file mode 100644 index 000000000..40ae65ea8 --- /dev/null +++ b/crates/automation/src/assemble.rs @@ -0,0 +1,424 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based, summary}; +use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; +use super::*; + +impl Automation { + pub(crate) fn doc_create(&mut self, a: &Args) -> Result { + let (name, bytes) = match a.str("from")? { + "blank" => { + let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; + let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); + ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) + } + "images" => { + let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); + let mut images = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); + } + let name = if images.len() == 1 { + format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) + } else { + "Images.pdf".into() + }; + (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) + } + "text" => { + let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { + (Some(t), _) => ("Text".to_string(), t.to_string()), + (None, Some(p)) => { + let path = self.resolve(p, false)?; + let t = read_input(&path)?; + (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) + } + (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), + }; + // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with + // its line) by `pdfcraft_create::from_text`, as for every other caller. + let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { + pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), + e => failed(e), + })?; + (format!("{title}.pdf"), bytes) + } + other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), + }; + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); + let id = self.session.open_new(name, bytes).map_err(failed)?; + Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) + } + + pub(crate) fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + + pub(crate) fn page_set_box(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{BoxSpec, PageBox}; + let doc = self.doc(a)?; + let n = doc.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let which = match a.opt_str("box")? { + None => PageBox::Crop, + Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, + }; + let nums = |key: &str| -> Result> { + a.get(key) + .map(|v| { + let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) + }) + .transpose() + }; + let spec = match (nums("margins")?, nums("rect")?) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), + (Some(m), None) => BoxSpec::Margins(m), + (None, Some(r)) => { + // Top-left-origin points on the displayed page → user space (one page at a time). + if pages.len() != 1 { + return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); + } + let p = &doc.info.pages[pages[0]]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + (None, None) => BoxSpec::Remove, + }; + let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; + out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); + Ok(out) + } + + pub(crate) fn insert_blank(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let at = self.position(a, "at")?; + // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. + let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); + let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); + let width = a.opt_num("width")?.unwrap_or(w); + let height = a.opt_num("height")?.unwrap_or(h); + if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { + return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); + } + self.apply(a, Edit::InsertBlankPage { at, width, height }) + } + + pub(crate) fn insert_file(&mut self, a: &Args) -> Result { + let at = self.position(a, "at")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) + } + + /// Write freshly created bytes to `out` and/or open them as a new document. + pub(crate) fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { + let mut result = json!({ "bytes": bytes.len() }); + let out = a.opt_str("out")?; + if let Some(out) = out { + let path = self.resolve(out, true)?; + write_atomic(&path, &bytes)?; + result["path"] = json!(path.to_string_lossy()); + } + if a.opt_bool("open")?.unwrap_or(out.is_none()) { + let id = self.session.open_new(name, bytes).map_err(failed)?; + result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); + } + Ok(result) + } + + pub(crate) fn doc_create_multiple(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.is_empty() { + return Err(ToolError::InvalidArgs("paths must list at least one file".into())); + } + let separate = match a.opt_str("mode")? { + None | Some("combine") => false, + Some("separate") => true, + Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), + }; + if separate { + return self.create_separate(a, &paths); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + // Each file is read, converted and copied in turn, so a long list holds one file at a time. + let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; + let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((title, pdf, range)) + }); + let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; + self.deliver(a, "Combined", bytes) + } + + /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. + fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for &p in paths { + let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; + Ok((kind, stem, pdf)) + }); + out.push(match converted { + Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), + Ok((_, stem, pdf)) => { + let target = unused(&dir, &stem); + write_atomic(&target, &pdf)?; + json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + pub(crate) fn page_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, name) = (doc.id, format!("{} (extract)", doc.name)); + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); + let pages = self.pages(a, "pages")?; + let mut out = if a.opt_bool("separate")?.unwrap_or(false) { + // Each page as its own file. + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + for &p in &pages { + let bytes = self.session.extract(id, &[p]).map_err(failed)?; + let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); + write_atomic(&path, &bytes)?; + files.push(path.to_string_lossy().into_owned()); + } + json!({ "files": files }) + } else { + let bytes = self.session.extract(id, &pages).map_err(failed)?; + self.deliver(a, &name, bytes)? + }; + if a.opt_bool("delete")?.unwrap_or(false) { + let deleted = self.apply(a, Edit::DeletePages { pages })?; + out["original"] = deleted; + } + Ok(out) + } + + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.len() < 2 { + return Err(ToolError::InvalidArgs("combine needs at least two files".into())); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::Null => Ok(None), + Value::String(range) => Ok(Some(range.clone())), + _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), + }) + .collect::>()?, + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + let passwords: Vec> = match a.get("passwords") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { + v.iter().map(|x| x.as_str().map(str::to_owned)).collect() + } + Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), + }; + // Entries sharing a group number are one file split into parts placed apart. + let groups: Option>> = match a.get("groups") { + None | Some(Value::Null) => None, + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { + Some(v.iter().map(Value::as_u64).collect()) + } + Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), + }; + let field_conflicts = match a.opt_str("field_conflicts")? { + None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, + Some("merge") => pdfcraft_organize::FieldConflicts::Merge, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); + } + }; + let mut sources: Vec = Vec::new(); + let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); + for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { + let path = self.resolve(p, false)?; + let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); + let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); + let bytes = match first { + Some((g, first_path, at)) => { + if *first_path != path { + return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); + } + sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? + } + None => { + if let Some(g) = group { + firsts.push((g, path.clone(), sources.len())); + } + let bytes = Arc::new(read_input(&path)?); + // Images and text files are made into PDFs first (#569), as in the Combine files tab. + let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.session.as_pdf(&file_name, &bytes).map_err(failed)? + } + }; + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((name, bytes, range)); + } + let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); + let combined = match groups { + None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), + Some(groups) => { + // A path without a group is a file of its own: give it a key no group uses. + let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); + let mut keys = Vec::with_capacity(groups.len()); + for g in &groups { + keys.push(match g { + Some(g) => *g, + None => { + let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; + next = own.checked_add(1); + own + } + }); + } + self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) + } + } + .map_err(failed)?; + let mut result = self.deliver(a, "Combined", combined.bytes)?; + if !combined.renamed_fields.is_empty() { + result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); + } + Ok(result) + } + + pub(crate) fn doc_split(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); + let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); + let max_mb = a.opt_num("max_mb")?; + let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); + if chosen != 1 { + return Err(ToolError::InvalidArgs( + "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), + )); + } + let mut titles: Vec<(usize, String)> = Vec::new(); + let parts = if let Some(mb) = max_mb { + if !mb.is_finite() || mb <= 0.0 { + return Err(ToolError::InvalidArgs("max_mb must be positive".into())); + } + self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? + } else { + let by = match (a.opt_int("every")?, a.opt_ints("before")?) { + (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), + (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), + _ if bookmarks => { + titles = self.session.bookmark_splits(id); + if titles.is_empty() { + return Err(failed("the document has no top-level bookmarks")); + } + pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) + } + _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), + }; + self.session.split(id, &by).map_err(failed)? + }; + let dir = self.resolve(a.str("out_dir")?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); + for (i, (first, last, bytes)) in parts.iter().enumerate() { + let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), + }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file; + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{base}-{n}.pdf"); + } + let path = child(&dir, &unique); + write_atomic(&path, bytes)?; + files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); + } + Ok(json!({ "files": files })) + } +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs new file mode 100644 index 000000000..26e1f9536 --- /dev/null +++ b/crates/automation/src/dispatch.rs @@ -0,0 +1,784 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; +use super::paths::{read_input, write_atomic}; +use super::*; + +/// One piece of a tool's result. +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + /// Structured data (MCP: a text block with the JSON plus `structuredContent`). + Json(Value), + /// A PNG image. + Png { data: Vec, width: u32, height: u32 }, +} + +impl Automation { + /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). + pub fn call(&mut self, name: &str, args: &Value) -> Result> { + let empty = json!({}); + let args = if args.is_null() { &empty } else { args }; + if !args.is_object() { + return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); + } + let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; + tools::check_args(def, args)?; + let a = Args(args); + let out = match name { + "doc_open" => self.doc_open(&a)?, + "doc_batch" => self.doc_batch(&a)?, + "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), + "doc_info" => { + let id = self.doc(&a)?.id; + self.session.refresh_fonts(id); + info(self.doc(&a)?) + } + "doc_close" => self.doc_close(&a)?, + "doc_save" => self.doc_save(&a)?, + "doc_set_info" => { + let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; + self.apply(&a, edit)? + } + "page_render" => return self.page_render(&a).map(|c| vec![c]), + "comment_image_preview" => return self.comment_image_preview(&a), + "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, + "text_find" => self.text_find(&a)?, + "page_rotate" => { + let degrees = a.int("degrees")?; + if degrees % 90 != 0 { + return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); + } + let doc = self.doc(&a)?; + let base = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let parity = match a.opt_str("subset")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageParity::Both, + "even" => pdfcraft_engine::PageParity::Even, + "odd" => pdfcraft_engine::PageParity::Odd, + s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), + }; + let orientation = match a.opt_str("orientation")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageOrientation::Both, + "landscape" => pdfcraft_engine::PageOrientation::Landscape, + "portrait" => pdfcraft_engine::PageOrientation::Portrait, + o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), + }; + let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); + if pages.is_empty() { + return Err(failed("no pages match the filters")); + } + let n = pages.len(); + let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; + out["rotated"] = json!(n); + out + } + "page_enhance_camera" => { + let page = self.page(&a)?; + let given = a.nums::<8>("corners")?; + let doc = self.doc(&a)?; + let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); + let corners = match given { + Some(c) => [0, 1, 2, 3].map(|i| { + let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); + [x as f64, y as f64] + }), + None => self.session.detect_camera_corners(id, page).ok_or_else(|| { + failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") + })?, + }; + let level = match a.opt_int("level")? { + None => 0, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let look = match a.opt_str("mode")? { + None => pdfcraft_engine::camera_clean::Look::Colour, + Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, + }; + self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; + let r = |v: f32| (v * 100.0).round() / 100.0; + let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); + let mut out = summary(self.doc(&a)?); + out["corners"] = json!(used); + out["detected"] = json!(given.is_none()); + out + } + "page_delete" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DeletePages { pages })? + } + "page_move" => { + let pages = self.pages(&a, "pages")?; + let to = self.position(&a, "to")?; + self.apply(&a, Edit::MovePages { pages, to })? + } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } + "page_insert_blank" => self.insert_blank(&a)?, + "page_insert_file" => self.insert_file(&a)?, + "page_extract" => self.page_extract(&a)?, + "doc_combine" => self.doc_combine(&a)?, + "doc_create_multiple" => self.doc_create_multiple(&a)?, + "doc_split" => self.doc_split(&a)?, + "edit_undo" => { + let id = self.doc(&a)?.id; + let label = self.session.undo(id).map_err(failed)?.to_string(); + json!({ "undone": label, "document": summary(self.doc(&a)?) }) + } + "edit_redo" => { + let id = self.doc(&a)?.id; + let label = self.session.redo(id).map_err(failed)?.to_string(); + json!({ "redone": label, "document": summary(self.doc(&a)?) }) + } + "command_list" => self.command_list(&a)?, + "command_run" => return self.command_run(&a), + "command_batch" => self.command_batch(&a)?, + "doc_inspect" => self.doc_inspect(&a)?, + "render_preview" => return self.render_preview(&a).map(|c| vec![c]), + "page_number" => { + use pdfcraft_organize::LabelStyle as L; + let n = self.doc(&a)?.info.pages.len(); + let (from, to) = (a.int("from")?, a.int("to")?); + if from < 1 || to < from || to as usize > n { + return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); + } + let style = match a.opt_str("style")?.unwrap_or("decimal") { + "decimal" => L::Decimal, + "upper-roman" => L::UpperRoman, + "lower-roman" => L::LowerRoman, + "upper-alpha" => L::UpperAlpha, + "lower-alpha" => L::LowerAlpha, + "none" => L::None, + other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), + }; + let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); + let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; + let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; + out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); + out + } + "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_add" => { + let page = self.page(&a)?; + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + let title = a.str("title")?.to_string(); + let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); + let [left, top] = match self.doc(&a)?.info.pages.get(page) { + Some(p) => p.xyz_for_view(left, top), + None => [None, None], + }; + let (left, top) = (left.map(f64::from), top.map(f64::from)); + self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? + } + "bookmark_rename" => { + let (path, title) = (a.path("path")?, a.str("title")?.to_string()); + self.apply(&a, Edit::RenameBookmark { path, title })? + } + "bookmark_delete" => { + let path = a.path("path")?; + self.apply(&a, Edit::DeleteBookmark { path })? + } + "bookmark_move" => { + let from = a.path("path")?; + let to_parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? + } + "bookmark_set_page" => { + let (path, page) = (a.path("path")?, self.page(&a)?); + self.apply(&a, Edit::SetBookmarkPage { path, page })? + } + "bookmark_set_style" => { + let path = a.path("path")?; + // Left out keeps the colour; [0, 0, 0] is the default black. + let color = match a.get("color") { + None => None, + Some(v) => { + let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), + } + } + }; + let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); + if color.is_none() && bold.is_none() && italic.is_none() { + return Err(ToolError::InvalidArgs("give color, bold or italic".into())); + } + let mut edits = Vec::new(); + if let Some(color) = color { + edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); + } + if bold.is_some() || italic.is_some() { + edits.push(Edit::SetBookmarkStyle { path, bold, italic }); + } + // A colour and a style given together are one step to undo, applied all or nothing. + match edits.len() { + 1 => self.apply(&a, edits.remove(0))?, + _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, + } + } + "bookmark_from_structure" => { + let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } + "doc_protect" => self.doc_protect(&a)?, + "page_replace" => { + let pages = self.pages(&a, "pages")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = Arc::new(read_input(&path)?); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let src_pages = match a.opt_ints("from_pages")? { + Some(p) => one_based(&p)?, + None => (0..pages.len()).collect(), + }; + self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? + } + "page_duplicate" => { + let pages = self.pages(&a, "pages")?; + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? + } + "page_set_box" => self.page_set_box(&a)?, + "page_transition_set" => self.page_transition_set(&a)?, + "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, + "doc_flatten" => { + let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); + if !comments && !fields { + return Err(ToolError::InvalidArgs("nothing to flatten".into())); + } + self.apply(&a, Edit::Flatten { comments, fields })? + } + "doc_reduce" => { + let id = self.doc(&a)?.id; + let before = self.doc(&a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + } + "doc_optimize" => self.doc_optimize(&a)?, + "doc_initial_view" => self.doc_initial_view(&a)?, + "doc_revisions" => self.doc_revisions(&a)?, + "text_lines" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let lines: Vec = doc + .text_lines(page) + .iter() + .enumerate() + .map(|(i, l)| { + let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); + json!({ + "line": i + 1, + "text": l.text, + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": l.base_font, + "size": (l.size * 100.0).round() / 100.0, + }) + }) + .collect(); + json!({ "page": page + 1, "count": lines.len(), "lines": lines }) + } + "image_hit" => { + let page = self.page(&a)?; + let point = a + .get("point") + .and_then(Value::as_array) + .filter(|p| p.len() == 2) + .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) + .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; + let doc = self.doc(&a)?; + let p = &doc.info.pages[page]; + let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); + let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); + json!({"page":page+1,"images":images}) + } + "page_images" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let list: Vec = doc + .page_images(page) + .iter() + .enumerate() + .map(|(i, im)| { + let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); + let visible = im.visible_rect.map(|rect| { + let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); + [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] + }); + json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + }) + .collect(); + json!({ "page": page + 1, "count": list.len(), "images": list }) + } + "image_edit" | "image_save" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.page_images(page).len(); + let k = a.int("image")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); + } + let index = k as usize - 1; + if name == "image_save" { + let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; + let mut path = self.resolve(a.str("path")?, true)?; + if path.is_dir() { + // Adding the extension to a folder's name would write beside it: "." is the + // root, so that would land outside it. + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + if path.extension().is_none() { + path.set_extension(ext); + } + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) + } else { + use pdfcraft_engine::ImageEdit; + let change = match a.str("action")? { + "move" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; + // Top-left-origin points → user space. + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "rotate_by" => ImageEdit::RotateBy( + a.get("degrees") + .and_then(Value::as_f64) + .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, + ), + "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "flip_horizontal" => ImageEdit::Flip { horizontal: true }, + "flip_vertical" => ImageEdit::Flip { horizontal: false }, + "delete" => ImageEdit::Delete, + "replace" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + ImageEdit::Replace { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + } + } + other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), + }; + self.apply(&a, Edit::EditPageImage { page, index, change })? + } + } + "text_paragraphs" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let current = doc.text_blocks(page); + let id = doc.id; + let blocks: Vec = current + .iter() + .enumerate() + .map(|(i, b)| { + let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); + json!({ + "paragraph": i + 1, + "text": b.text, + "editor_text": b.editor_text, + "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), + "lines": b.lines.iter().map(|l| l + 1).collect::>(), + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": b.base_font, + "size": (b.size * 100.0).round() / 100.0, + }) + }) + .collect(); + self.record_listing(id, page, ¤t); + json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) + } + "text_font_files" => { + let roots = if let Some(paths) = a.get("directories") { + let paths = paths + .as_array() + .filter(|p| p.len() <= 16) + .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; + paths + .iter() + .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) + .collect::>>()? + } else if let Some(roots) = &self.roots { + // The installed fonts are outside the roots: look in the read root instead. + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; + vec![self.resolve(&read.to_string_lossy(), false)?] + } else if let Some(root) = &self.root { + vec![root.clone()] + } else { + pdfcraft_engine::text_fonts::installed_directories() + }; + json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) + } + "text_font_import" => { + let page = self.page(&a)?; + let path = self.resolve(a.str("path")?, false)?; + let file = std::fs::File::open(&path).map_err(failed)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; + if bytes.len() > 16 << 20 { + return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); + } + let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); + let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; + let added = self + .doc(&a)? + .text_editor_fonts(page) + .into_iter() + .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); + if let Some(f) = added { + out["resource"] = json!(f.resource); + out["font"] = json!(f.face); + } + out + } + "text_fonts" => { + let page = self.page(&a)?; + json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) + } + "text_edit_layout" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let k = a.int("paragraph")?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; + let text = a.opt_str("text")?.unwrap_or(&source.editor_text); + let style = pdfcraft_engine::BlockStyle { + size: a.opt_num("size")?, + width: a.opt_num("width")?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + ranges: parse_style_ranges(a.get("ranges"))?, + ..Default::default() + }; + let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; + let info = &doc.info.pages[page]; + let view_rect = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }; + let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); + json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), + "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) + } + "text_edit" if a.get("paragraph").is_some() => { + let page = self.page(&a)?; + let k = a.int("paragraph")?; + let (id, blocks) = { + let doc = self.doc(&a)?; + (doc.id, doc.text_blocks(page)) + }; + let index = self.paragraph_index(id, page, k, &blocks)?; + let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; + let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let mut style = pdfcraft_engine::BlockStyle { + ranges: parse_style_ranges(a.get("ranges"))?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + bold: a.opt_bool("bold")?, + size: a.opt_num("size")?, + underline: a.opt_bool("underline")?, + line_spacing: a.opt_num("line_spacing")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + width: a.opt_num("width")?, + ..Default::default() + }; + let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); + if dx.is_some() || dy.is_some() { + style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); + } + if let Some(f) = a.opt_str("font")? { + let family = match f { + "helvetica" => pdfcraft_engine::FontFamily::Helvetica, + "times" => pdfcraft_engine::FontFamily::Times, + "courier" => pdfcraft_engine::FontFamily::Courier, + other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), + }; + style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); + } + if let Some(c) = a.opt_str("color")? { + style.color = Some(comments::parse_color(c)?); + } + if let Some(al) = a.opt_str("align")? { + style.align = Some(match al { + "left" => pdfcraft_engine::TextAlign::Left, + "center" => pdfcraft_engine::TextAlign::Center, + "right" => pdfcraft_engine::TextAlign::Right, + "justify" => pdfcraft_engine::TextAlign::Justify, + other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), + }); + } + // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; + let wanted: String = text.split_whitespace().collect(); + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); + // Only a paragraph that now shows exactly the requested text is the edited one. A + // neighbour that overlaps the old box (overlaid text, a text layer) must not take + // its number, and neither must a paragraph the edit merged it into: rewriting that + // one by this number would drop the neighbour's text. + let edited = block_overlapping(&after, moved) + .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); + if let Some(b) = edited.and_then(|i| after.get(i)) { + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } + } + let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); + if !stale.is_empty() { + out["stale_paragraphs"] = json!(stale); + } + out + } + "text_edit" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.text_lines(page).len(); + let line = a.int("line")?; + if line < 1 || line as usize > n { + return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); + } + let text = a.str("text")?.to_owned(); + let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; + let after = self.doc(&a)?.text_lines(page); + if let Some(l) = after.get(line as usize - 1) { + out["line"] = json!({ "text": l.text, "font": l.base_font }); + } + out + } + "doc_audit_space" => { + let rows: Vec = self + .doc(&a)? + .audit_space() + .iter() + .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) + .collect(); + json!({ "categories": rows }) + } + "doc_open_revision" => { + let id = self.doc(&a)?.id; + let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); + let new = self.session.open_revision(id, n).map_err(failed)?; + summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) + } + "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_unprotect" => { + let mut out = self.apply(&a, Edit::RemoveProtection)?; + out["security"] = security(self.doc(&a)?); + out + } + "form_fields" => self.form_fields(&a)?, + "form_fill" => self.form_fill(&a)?, + "form_set_image" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? + } + "form_reset" => self.form_reset(&a)?, + "form_add_field" => self.form_add_field(&a)?, + "form_add_field_table" => self.form_add_field_table(&a)?, + "form_set_props" => self.form_set_props(&a)?, + "form_delete_field" => self.form_delete_field(&a)?, + "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, + "doc_export_data" => self.doc_export_data(&a)?, + "doc_import_data" => self.doc_import_data(&a)?, + "redact_mark" => self.redact_mark(&a)?, + "redact_apply" => self.redact_apply(&a)?, + "redact_clear" => self.redact_clear(&a)?, + "doc_hidden_info" => self.doc_hidden_info(&a)?, + "printers" => self.printers()?, + "printer_options" => self.printer_options(&a)?, + "link_list" => self.link_list(&a)?, + "link_open" => self.link_open(&a)?, + "link_add" => self.link_add(&a)?, + "link_edit" => self.link_edit(&a)?, + "link_delete" => self.link_delete(&a)?, + "links_from_urls" => self.links_from_urls(&a)?, + "links_remove" => self.links_remove(&a)?, + "object_list" => self.object_list(&a)?, + "object_move" => self.object_move(&a)?, + "content_list" => self.content_list(&a)?, + "page_add_text" => self.page_add_text(&a)?, + "page_add_image" => self.page_add_image(&a)?, + "content_update" => self.content_update(&a)?, + "content_delete" => self.content_delete(&a)?, + "content_copy" => self.content_copy(&a)?, + "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + // Only the document-level keys: an annotation attachment of the same name is not the new one. + let before = document_attachment_keys(self.doc(&a)?); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } + "doc_remove_hidden" => self.doc_remove_hidden(&a)?, + "doc_audit_redactions" => self.doc_audit_redactions(&a)?, + "fill_sign_add" => self.fill_sign_add(&a)?, + "fill_sign_date_format" => self.fill_sign_date_format(&a)?, + "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, + "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, + "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, + "measure_info" => self.measurement_info(&a)?, + "measure_list" => self.measurement_list(&a)?, + "measure_scale" => self.measurement_scale(&a)?, + "measure_snap" => self.measurement_snap(&a)?, + "measure_export" => self.measurement_export(&a)?, + "comment_list" => self.comment_list(&a)?, + "comment_add" => self.comment_add(&a)?, + "stamp_custom" => self.stamp_custom(&a)?, + "comment_reply" => self.comment_reply(&a)?, + "comment_set_status" => self.comment_set_status(&a)?, + "sign_list" => self.sign_list(&a)?, + "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, + "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, + "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, + "ocr_recognize" => self.ocr_recognize(&a)?, + "js_run" => self.js_run(&a)?, + "js_document_scripts" => self.js_document_scripts(&a)?, + "js_set_document_script" => self.js_set_document_script(&a)?, + "js_enabled" => self.js_enabled(&a)?, + "form_set_script" => self.form_set_script(&a)?, + "form_merge_data" => self.form_merge_data(&a)?, + "form_actions" => self.form_actions(&a)?, + "form_detect_fields" => self.form_detect_fields(&a)?, + "doc_compare" => self.doc_compare(&a)?, + "action_list" => self.action_list()?, + "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, + "doc_export_office" => self.doc_export_office(&a)?, + "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } + "action_run" => self.action_run(&a)?, + "doc_compare_report" => self.doc_compare_report(&a)?, + "doc_compare_mark" => self.doc_compare_mark(&a)?, + "form_set_actions" => self.form_set_actions(&a)?, + "ocr_status" => self.ocr_status()?, + "ocr_recognize_files" => self.ocr_recognize_files(&a)?, + "accessibility_report" => self.a11y_report(&a)?, + "accessibility_review_record" => self.a11y_review_record(&a)?, + "accessibility_review_save" => self.a11y_review_save(&a)?, + "accessibility_review_load" => self.a11y_review_load(&a)?, + "accessibility_review_html" => self.a11y_review_html(&a)?, + "accessibility_fix" => self.a11y_fix(&a)?, + "accessibility_figures" => self.accessibility_figures(&a)?, + "accessibility_set_alt" => self.accessibility_set_alt(&a)?, + "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, + "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_move_tag" => self.accessibility_move_tag(&a)?, + "sign_id_create" => self.sign_id_create(&a)?, + "sign_document" => self.sign_document(&a)?, + "sign_keychain_ids" => { + #[cfg(target_os = "macos")] + let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) + .map_err(failed)? + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "macos"))] + let ids: Vec = Vec::new(); + json!({ "count": ids.len(), "ids": ids }) + } + "sign_windows_ids" => { + #[cfg(target_os = "windows")] + let (ids, unusable): (Vec, Vec) = { + let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; + let ids = listing + .ids + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + let unusable = listing + .unusable + .iter() + .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) + .collect(); + (ids, unusable) + }; + #[cfg(not(target_os = "windows"))] + let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); + json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) + } + "sign_trust" => self.sign_trust(&a)?, + "comment_mark" => self.comment_mark(&a)?, + "comment_lock" => self.comment_lock(&a)?, + "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, + "view_reading_colors" => self.view_reading_colors(&a)?, + "comments_summarize" => self.comments_summarize(&a)?, + "comment_edit" => self.comment_edit(&a)?, + "comment_delete" => self.comment_delete(&a)?, + other => return Err(ToolError::UnknownTool(other.into())), + }; + Ok(vec![Content::Json(out)]) + } +} diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs new file mode 100644 index 000000000..26addfd80 --- /dev/null +++ b/crates/automation/src/documents.rs @@ -0,0 +1,606 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, nest_rows, security, summary}; +use super::paths::{child, read_input, write_atomic}; +use super::*; + +/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). +const WATERMARK_MAX_OFFSET: f64 = 20_000.0; + +/// Bookmarks in one page of the bookmark list, by default and at most. +const BOOKMARK_PAGE: usize = 100; + +const MAX_BOOKMARK_PAGE: usize = 1000; + +impl Automation { + // ---- documents --------------------------------------------------------------------------- + + /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). + pub(crate) fn bookmark_listing(&self, a: &Args) -> Result { + let id = a.int("doc")?; + let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); + let offset = a.opt_int("offset")?.unwrap_or(0); + let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; + let page = self + .session + .bookmark_page(id, offset, limit) + .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) + } + + pub(crate) fn doc(&self, a: &Args) -> Result<&Document> { + let id = a.int("doc")?; + let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; + self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) + } + + /// Most pages whose paragraph listing is remembered. + const MAX_LISTINGS: usize = 256; + + /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be + /// resolved against that listing. + pub(crate) fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { + if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { + self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); + self.listed.clear(); + } + self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); + } + + /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). + /// + /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is + /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a + /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph + /// and lose its text (#2516). So is any number on a page of a document whose listings were + /// dropped at the cap, until the page is listed again. + pub(crate) fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { + let keys = self.listed.get(&(doc, page)); + if keys.is_none() && self.evicted.contains(&doc) { + return Err(ToolError::InvalidArgs(format!( + "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", + page + 1 + ))); + } + let n = keys.map_or(blocks.len(), Vec::len); + let i = usize::try_from(k) + .ok() + .and_then(|k| k.checked_sub(1)) + .filter(|i| *i < n) + .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; + let Some(keys) = keys else { return Ok(i) }; + keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { + ToolError::InvalidArgs(format!( + "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", + page + 1 + )) + }) + } + + /// Update the listing after paragraph `k` was edited into `edited`, one of the page's + /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. + pub(crate) fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { + let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; + if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { + *key = BlockKey::of(edited); + } + keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() + } + + /// The document, if its security allows copying text and images out of it (Table 22, bit 5). + pub(crate) fn doc_to_copy(&self, a: &Args) -> Result<&Document> { + let doc = self.doc(a)?; + doc.check_copying().map_err(failed)?; + Ok(doc) + } + + pub(crate) fn doc_open(&mut self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; + Ok(summary(doc)) + } + + pub(crate) fn doc_close(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); + if doc.dirty && !discard_changes { + return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); + } + self.session.close(id); + self.renderers.remove(&id); + self.texts.remove(&id); + self.listed.retain(|(d, _), _| *d != id); + self.evicted.remove(&id); + Ok(json!({ "closed": id.0 })) + } + + pub(crate) fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let target = match a.opt_str("path")? { + Some(p) => self.resolve(p, true)?, + None => { + // Saving in place writes where the file was opened from, which the read root + // allows and the write root may not. + let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; + let own_path = PathBuf::from(&own); + self.check_roots(&own, &own_path, file_access::Access::Write)?; + own_path + } + }; + let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + // Saving to a new file is a full rewrite unless asked otherwise, like Save As. + let full = a.opt_bool("full")?.unwrap_or(!same_file); + let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); + if flatten_fill_sign { + self.apply(a, Edit::FlattenFillSign)?; + } + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; + // A save that adds or removes encryption, or must not keep the old revision (Sanitize, + // redaction), is rewritten in full even when an incremental save was asked for. + let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + pub(crate) fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { + self.session.check_save(id, &bytes).map_err(failed)?; + write(target, &bytes)?; + let path = target.to_string_lossy().into_owned(); + self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) + } + + pub(crate) fn doc_revisions(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let ends = doc.revision_ends(); + let sigs = doc.signatures.clone(); + let list: Vec = ends + .iter() + .enumerate() + .map(|(i, end)| { + let start = if i == 0 { 0 } else { ends[i - 1] }; + // A signature signs the revision its byte range ends in. + let signed: Vec<&str> = + sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); + json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) + }) + .collect(); + Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) + } + + pub(crate) fn doc_initial_view(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let bad = |m: String| ToolError::InvalidArgs(m); + let mut v = self.doc(a)?.initial_view(); + let before = v.clone(); + if let Some(n) = a.opt_str("navigation")? { + v.navigation = match n { + "page" => N::PageOnly, + "bookmarks" => N::Bookmarks, + "pages" => N::Pages, + "attachments" => N::Attachments, + "layers" => N::Layers, + o => return Err(bad(format!("unknown navigation {o:?}"))), + }; + } + if let Some(l) = a.opt_str("layout")? { + v.layout = match l { + "default" => L::Default, + "single" => L::SinglePage, + "continuous" => L::SinglePageContinuous, + "two_up" => L::TwoUp, + "two_up_continuous" => L::TwoUpContinuous, + "two_up_cover" => L::TwoUpCoverPage, + "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, + o => return Err(bad(format!("unknown layout {o:?}"))), + }; + } + match a.get("magnification") { + None => {} + Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), + Some(Value::String(m)) => { + v.magnification = match m.as_str() { + "default" => M::Default, + "actual" => M::ActualSize, + "fit_page" => M::FitPage, + "fit_width" => M::FitWidth, + "fit_height" => M::FitHeight, + "fit_visible" => M::FitVisible, + o => return Err(bad(format!("unknown magnification {o:?}"))), + } + } + Some(_) => return Err(bad("magnification is a name or a percentage".into())), + } + if let Some(p) = a.opt_int("page")? { + v.page = (p.max(1) - 1) as usize; + } + for (k, f) in [ + ("fit_window", &mut v.fit_window), + ("center_window", &mut v.center_window), + ("full_screen", &mut v.full_screen), + ("display_title", &mut v.display_title), + ("hide_menubar", &mut v.hide_menubar), + ("hide_toolbar", &mut v.hide_toolbar), + ("hide_window_ui", &mut v.hide_window_ui), + ] { + if let Some(b) = a.opt_bool(k)? { + *f = b; + } + } + if let Some(l) = a.opt_str("language")? { + v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); + } + if let Some(b) = a.opt_str("binding")? { + v.right_to_left = match b { + "left" => false, + "right" => true, + o => return Err(bad(format!("binding is left or right, not {o:?}"))), + }; + } + let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; + out["initial_view"] = json!({ + "navigation": format!("{:?}", v.navigation), + "layout": format!("{:?}", v.layout), + "magnification": format!("{:?}", v.magnification), + "page": v.page + 1, + "fit_window": v.fit_window, + "center_window": v.center_window, + "full_screen": v.full_screen, + "display_title": v.display_title, + "hide_menubar": v.hide_menubar, + "hide_toolbar": v.hide_toolbar, + "hide_window_ui": v.hide_window_ui, + "language": v.language, + "binding": if v.right_to_left { "right" } else { "left" }, + }); + Ok(out) + } + + pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + let id = self.doc(a)?.id; + let before = self.doc(a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } + let images = |key: &str, base: ImageSettings| -> Result { + let Some(v) = a.get(key) else { return Ok(base) }; + let mut s = base; + if let Some(d) = v.get("downsample").and_then(Value::as_bool) { + s.downsample = d; + } + if let Some(p) = v.get("ppi").and_then(Value::as_f64) { + s.target_ppi = p.clamp(9.0, 2400.0); + } + if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { + s.above_ppi = p.clamp(9.0, 2400.0); + } + s.above_ppi = s.above_ppi.max(s.target_ppi); + if let Some(c) = v.get("compression").and_then(Value::as_str) { + let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; + s.compression = match c { + "jpeg" => Compression::Jpeg(q), + "zip" | "flate" => Compression::Flate, + "retain" => Compression::Retain, + other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), + }; + } + Ok(s) + }; + settings.color = images("color", settings.color)?; + settings.gray = images("gray", settings.gray)?; + for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), + ("discard_thumbnails", &mut settings.discard_thumbnails), + ("discard_alternate_images", &mut settings.discard_alternate_images), + ("discard_tags", &mut settings.discard_tags), + ("discard_print_settings", &mut settings.discard_print_settings), + ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), + ("lzw_to_flate", &mut settings.lzw_to_flate), + ("remove_invalid_links", &mut settings.remove_invalid_links), + ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ] { + if let Some(b) = a.opt_bool(key)? { + *flag = b; + } + } + let discard: Vec = match a.get("discard") { + None => Vec::new(), + Some(v) => v + .as_array() + .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? + .iter() + .map(|x| { + x.as_str() + .and_then(pdfcraft_engine::Hidden::from_id) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) + }) + .collect::>()?, + }; + let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + write_atomic(&path, &bytes)?; + fn valid_version(v: &[u8]) -> Option<&str> { + if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } + } + let catalog_version = (settings.compatibility == Compatibility::Retain) + .then(|| { + let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; + let root = doc.root()?; + doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + }) + .flatten(); + let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); + let o = &r.optimize; + Ok(json!({ + "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), + "bytes_before": before, + "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, + "images": o.images, + "images_resampled": o.images_resampled, + "images_recompressed": o.images_recompressed, + "image_bytes_before": o.image_bytes_before, + "image_bytes_after": o.image_bytes_after, + "thumbnails": o.thumbnails, + "alternate_images": o.alternate_images, + "tags_removed": o.tags_removed, + "streams_compressed": o.streams_compressed, + "lzw_streams": o.lzw_streams, + "invalid_links": o.invalid_links, + "invalid_bookmarks": o.invalid_bookmarks, + "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, + "merged_objects": r.merged, + "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), + })) + } + + /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). + fn mark_file(&self, a: &Args) -> Result> { + let Some(p) = a.opt_str("file")? else { return Ok(None) }; + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; + Ok(Some(pdfcraft_engine::MarkFile { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + page, + })) + } + + pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let doc = self.doc(a)?; + let mut out = summary(doc); + // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. + if !doc.edit_notices.is_empty() { + out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); + } + Ok(out) + } + + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { + use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let color = + |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; + let replace = a.opt_bool("replace")?.unwrap_or(false); + let edit = match tool { + "doc_header_footer" => { + let mut hf = HeaderFooter::default(); + for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { + hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); + } + if let Some(s) = a.opt_num("font_size")? { + hf.font_size = s; + } + hf.font = match a.opt_str("font")? { + None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, + Some("Times") => pdfcraft_engine::FontFamily::Times, + Some("Courier") => pdfcraft_engine::FontFamily::Courier, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); + } + }; + hf.color = color("color", hf.color)?; + if let Some(m) = a.get("margins").and_then(Value::as_array) { + let m: Vec = m.iter().filter_map(Value::as_f64).collect(); + hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; + } + if let Some(s) = a.opt_int("start_number")? { + hf.start_number = s.clamp(1, u32::MAX as i64) as u32; + } + hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); + Edit::AddHeaderFooter { pages, settings: hf, replace } + } + "doc_watermark" => { + let d = Watermark::default(); + let file = self.mark_file(a)?; + let text = match &file { + Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), + None => a.str("text")?.to_string(), + }; + let wm = Watermark { + text, + source: None, + scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), + font_size: a.opt_num("font_size")?.unwrap_or(0.0), + color: color("color", d.color)?, + opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), + rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), + behind: a.opt_bool("behind")?.unwrap_or(false), + offset: [ + a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + ], + }; + Edit::AddWatermark { pages, settings: wm, replace, file } + } + "doc_background" => Edit::AddBackground { + pages, + settings: Background { + color: color("color", [1.0; 3])?, + opacity: a.opt_num("opacity")?.unwrap_or(1.0), + scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + ..Background::default() + }, + replace, + file: self.mark_file(a)?, + }, + _ => { + let kind = match a.str("kind")? { + "header_footer" => MarkKind::HeaderFooter, + "watermark" => MarkKind::Watermark, + "background" => MarkKind::Background, + other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), + }; + Edit::RemoveMarks { kind } + } + }; + self.apply(a, edit) + } + + pub(crate) fn export(&mut self, tool: &str, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let mut ex = pdfcraft_engine::export::Exporter::new(doc); + if tool == "doc_export_text" { + let path = self.resolve(a.str("path")?, true)?; + let text = ex.text_of(&pages).map_err(failed)?; + write_atomic(&path, text.as_bytes())?; + return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); + } + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; + if tool == "doc_export_all_images" { + let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); + let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; + let mut files = Vec::new(); + for (k, img) in out.images.iter().enumerate() { + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); + write_atomic(&path, &img.data)?; + files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); + } + let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); + return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); + } + let dpi = a.opt_num("dpi")?.unwrap_or(150.0); + let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; + let format = match a.opt_str("format")?.unwrap_or("png") { + "png" => pdfcraft_engine::export::ImageFormat::Png, + "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, + "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, + f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), + }; + let mut files = Vec::new(); + let mut lowered = Vec::new(); + for p in pages { + let img = ex.image(p, dpi, format).map_err(failed)?; + let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); + write_atomic(&path, &img)?; + files.push(path.to_string_lossy().into_owned()); + // A page too large for the renderer at `dpi` is drawn at the most it allows. + let used = ex.dpi_used(p, dpi); + if used < dpi.clamp(18.0, 1200.0) - 0.5 { + lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); + } + } + if lowered.is_empty() { + Ok(json!({ "count": files.len(), "files": files })) + } else { + Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) + } + } + + pub(crate) fn doc_protect(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; + let d = Protection::default(); + // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is + // enforced against the owner password; without one everything stays allowed). Refuse a + // restriction that could not take effect instead of writing an unrestricted file (#134). + let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); + if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { + return Err(ToolError::InvalidArgs(format!( + "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" + ))); + } + let p = Protection { + open_password: a.opt_str("open_password")?.map(str::to_owned), + permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), + printing: match a.opt_str("printing")? { + None => d.printing, + Some("none") => Printing::None, + Some("low") => Printing::Low, + Some("high") => Printing::High, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), + }, + changes: match a.opt_str("changes")? { + None => d.changes, + Some("none") => Changes::None, + Some("pages") => Changes::Pages, + Some("fill-sign") => Changes::FillSign, + Some("comment-fill-sign") => Changes::CommentFillSign, + Some("any-except-extract") => Changes::AnyExceptExtract, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), + }, + copy: a.opt_bool("copy")?.unwrap_or(d.copy), + accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), + algorithm: match a.opt_str("compatibility")? { + None | Some("aes-256") => Algorithm::Aes256, + Some("aes-128") => Algorithm::Aes128, + Some("rc4-128") => Algorithm::Rc4_128, + Some("rc4-40") => Algorithm::Rc4_40, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), + }, + encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), + }; + let mut out = self.apply(a, Edit::Protect(p))?; + out["security"] = security(self.doc(a)?); + Ok(out) + } +} diff --git a/crates/automation/src/error.rs b/crates/automation/src/error.rs new file mode 100644 index 000000000..18e58939e --- /dev/null +++ b/crates/automation/src/error.rs @@ -0,0 +1,33 @@ +/// Why a tool call failed. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolError { + /// No tool has this name. + UnknownTool(String), + /// The arguments don't match the tool's schema. + InvalidArgs(String), + /// The tool ran and failed (the message is for the agent to read). + Failed(String), +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), + ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for ToolError {} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn failed(e: impl std::fmt::Display) -> ToolError { + ToolError::Failed(e.to_string()) +} + +impl From for ToolError { + fn from(e: pdfcraft_engine::EditError) -> Self { + failed(e) + } +} diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs new file mode 100644 index 000000000..c8710aa50 --- /dev/null +++ b/crates/automation/src/json.rs @@ -0,0 +1,361 @@ +use super::error::{Result, ToolError}; +use super::*; + +/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at +/// most 14 400 across). +const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; + +// ---- JSON helpers ---------------------------------------------------------------------------- + +pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + +/// Typed access to validated arguments. +pub(crate) struct Args<'a>(pub(crate) &'a Value); + +impl Args<'_> { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + pub(crate) fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + pub(crate) fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + pub(crate) fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + pub(crate) fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + pub(crate) fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + pub(crate) fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + pub(crate) fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + /// A 1-based bookmark path → 0-based indices. + pub(crate) fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + pub(crate) fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } + /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error + /// when it is not a number or lies absurdly far off the page. + pub(crate) fn bookmark_coordinate(&self, key: &str) -> Result> { + match self.opt_num(key)? { + None => Ok(None), + Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), + Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), + } + } + pub(crate) fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +pub(crate) fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } +} + +pub(crate) fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// The document's security as the next save writes it (never includes passwords). +pub(crate) fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + +/// What every tool that changes a document returns. +pub(crate) fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +pub(crate) fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), + // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). + "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // A script of the form ran too long and was abandoned: its scripts no longer run in this + // document (calculations, buttons) until it is opened again. + "xfa_scripts_off": d.xfa_scripts_off(), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "outline_more": i.outline_more, + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), + "target": match &l.target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + +fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bf44c965..e1ae0141c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -45,73 +45,32 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -/// One piece of a tool's result. -#[derive(Clone, Debug, PartialEq)] -pub enum Content { - /// Structured data (MCP: a text block with the JSON plus `structuredContent`). - Json(Value), - /// A PNG image. - Png { data: Vec, width: u32, height: u32 }, -} - -/// Why a tool call failed. -#[derive(Clone, Debug, PartialEq)] -pub enum ToolError { - /// No tool has this name. - UnknownTool(String), - /// The arguments don't match the tool's schema. - InvalidArgs(String), - /// The tool ran and failed (the message is for the agent to read). - Failed(String), -} - -impl std::fmt::Display for ToolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), - ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), - } - } -} - -impl std::error::Error for ToolError {} - -type Result = std::result::Result; - -fn failed(e: impl std::fmt::Display) -> ToolError { - ToolError::Failed(e.to_string()) -} - -impl From for ToolError { - fn from(e: pdfcraft_engine::EditError) -> Self { - failed(e) - } -} - -/// Default and maximum resolution for `page_render`. -const DEFAULT_DPI: f64 = 96.0; -const MAX_DPI: f64 = 600.0; -/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). -const WATERMARK_MAX_OFFSET: f64 = 20_000.0; - -/// Bookmarks in one page of the bookmark list, by default and at most. -const BOOKMARK_PAGE: usize = 100; -const MAX_BOOKMARK_PAGE: usize = 1000; -/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at -/// most 14 400 across). -const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; - -/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a -/// match near the start returns without touching the rest of a long document (#971). -const FIND_FIRST_BATCH: usize = 8; -const FIND_MAX_BATCH: usize = 128; - use file_access::foreign_share; use text_cache::TextCache; -type PageTexts = Vec<(usize, std::result::Result, String>)>; -const FIND_MAX_WARNINGS: usize = 100; -const FIND_MAX_WARNING_BYTES: usize = 1024; +mod assemble; +mod dispatch; +mod documents; +mod error; +mod json; +mod pages; +mod paths; + +pub use dispatch::Content; +pub use error::ToolError; +use error::{Result, failed}; +pub(crate) use json::destination; +pub use json::view_rect; +use json::{Args, info, summary}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png}; +#[cfg(test)] +use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; +#[cfg(test)] +pub(crate) use paths::MAX_INPUT; +use paths::{child, lexical, unused, write_atomic}; +pub(crate) use paths::{read_input, read_input_bytes}; +#[cfg(test)] +use paths::{write_atomic_synced, write_atomic_with}; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -205,2652 +164,6 @@ impl Automation { } Ok(target) } - - /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). - pub fn call(&mut self, name: &str, args: &Value) -> Result> { - let empty = json!({}); - let args = if args.is_null() { &empty } else { args }; - if !args.is_object() { - return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); - } - let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; - tools::check_args(def, args)?; - let a = Args(args); - let out = match name { - "doc_open" => self.doc_open(&a)?, - "doc_batch" => self.doc_batch(&a)?, - "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), - "doc_info" => { - let id = self.doc(&a)?.id; - self.session.refresh_fonts(id); - info(self.doc(&a)?) - } - "doc_close" => self.doc_close(&a)?, - "doc_save" => self.doc_save(&a)?, - "doc_set_info" => { - let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; - self.apply(&a, edit)? - } - "page_render" => return self.page_render(&a).map(|c| vec![c]), - "comment_image_preview" => return self.comment_image_preview(&a), - "text_extract" => self.text_extract(&a)?, - "text_word_count" => self.text_word_count(&a)?, - "text_find" => self.text_find(&a)?, - "page_rotate" => { - let degrees = a.int("degrees")?; - if degrees % 90 != 0 { - return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); - } - let doc = self.doc(&a)?; - let base = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let parity = match a.opt_str("subset")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageParity::Both, - "even" => pdfcraft_engine::PageParity::Even, - "odd" => pdfcraft_engine::PageParity::Odd, - s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), - }; - let orientation = match a.opt_str("orientation")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageOrientation::Both, - "landscape" => pdfcraft_engine::PageOrientation::Landscape, - "portrait" => pdfcraft_engine::PageOrientation::Portrait, - o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), - }; - let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); - if pages.is_empty() { - return Err(failed("no pages match the filters")); - } - let n = pages.len(); - let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; - out["rotated"] = json!(n); - out - } - "page_enhance_camera" => { - let page = self.page(&a)?; - let given = a.nums::<8>("corners")?; - let doc = self.doc(&a)?; - let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); - let corners = match given { - Some(c) => [0, 1, 2, 3].map(|i| { - let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); - [x as f64, y as f64] - }), - None => self.session.detect_camera_corners(id, page).ok_or_else(|| { - failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") - })?, - }; - let level = match a.opt_int("level")? { - None => 0, - Some(l @ 0..=100) => l as u8, - Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), - }; - let look = match a.opt_str("mode")? { - None => pdfcraft_engine::camera_clean::Look::Colour, - Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, - }; - self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; - let r = |v: f32| (v * 100.0).round() / 100.0; - let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); - let mut out = summary(self.doc(&a)?); - out["corners"] = json!(used); - out["detected"] = json!(given.is_none()); - out - } - "page_delete" => { - let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DeletePages { pages })? - } - "page_move" => { - let pages = self.pages(&a, "pages")?; - let to = self.position(&a, "to")?; - self.apply(&a, Edit::MovePages { pages, to })? - } - "page_reverse" => { - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..self.doc(&a)?.info.pages.len()).collect(), - }; - self.apply(&a, Edit::ReversePages { pages })? - } - "page_insert_blank" => self.insert_blank(&a)?, - "page_insert_file" => self.insert_file(&a)?, - "page_extract" => self.page_extract(&a)?, - "doc_combine" => self.doc_combine(&a)?, - "doc_create_multiple" => self.doc_create_multiple(&a)?, - "doc_split" => self.doc_split(&a)?, - "edit_undo" => { - let id = self.doc(&a)?.id; - let label = self.session.undo(id).map_err(failed)?.to_string(); - json!({ "undone": label, "document": summary(self.doc(&a)?) }) - } - "edit_redo" => { - let id = self.doc(&a)?.id; - let label = self.session.redo(id).map_err(failed)?.to_string(); - json!({ "redone": label, "document": summary(self.doc(&a)?) }) - } - "command_list" => self.command_list(&a)?, - "command_run" => return self.command_run(&a), - "command_batch" => self.command_batch(&a)?, - "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), - "page_number" => { - use pdfcraft_organize::LabelStyle as L; - let n = self.doc(&a)?.info.pages.len(); - let (from, to) = (a.int("from")?, a.int("to")?); - if from < 1 || to < from || to as usize > n { - return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); - } - let style = match a.opt_str("style")?.unwrap_or("decimal") { - "decimal" => L::Decimal, - "upper-roman" => L::UpperRoman, - "lower-roman" => L::LowerRoman, - "upper-alpha" => L::UpperAlpha, - "lower-alpha" => L::LowerAlpha, - "none" => L::None, - other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), - }; - let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); - let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; - let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; - out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); - out - } - "bookmark_list" => self.bookmark_listing(&a)?, - "bookmark_add" => { - let page = self.page(&a)?; - let parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - let title = a.str("title")?.to_string(); - let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); - let [left, top] = match self.doc(&a)?.info.pages.get(page) { - Some(p) => p.xyz_for_view(left, top), - None => [None, None], - }; - let (left, top) = (left.map(f64::from), top.map(f64::from)); - self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? - } - "bookmark_rename" => { - let (path, title) = (a.path("path")?, a.str("title")?.to_string()); - self.apply(&a, Edit::RenameBookmark { path, title })? - } - "bookmark_delete" => { - let path = a.path("path")?; - self.apply(&a, Edit::DeleteBookmark { path })? - } - "bookmark_move" => { - let from = a.path("path")?; - let to_parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? - } - "bookmark_set_page" => { - let (path, page) = (a.path("path")?, self.page(&a)?); - self.apply(&a, Edit::SetBookmarkPage { path, page })? - } - "bookmark_set_style" => { - let path = a.path("path")?; - // Left out keeps the colour; [0, 0, 0] is the default black. - let color = match a.get("color") { - None => None, - Some(v) => { - let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); - match rgb.as_deref() { - Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), - _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), - } - } - }; - let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); - if color.is_none() && bold.is_none() && italic.is_none() { - return Err(ToolError::InvalidArgs("give color, bold or italic".into())); - } - let mut edits = Vec::new(); - if let Some(color) = color { - edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); - } - if bold.is_some() || italic.is_some() { - edits.push(Edit::SetBookmarkStyle { path, bold, italic }); - } - // A colour and a style given together are one step to undo, applied all or nothing. - match edits.len() { - 1 => self.apply(&a, edits.remove(0))?, - _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, - } - } - "bookmark_from_structure" => { - let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; - let listing = self.bookmark_listing(&a)?; - out["bookmarks"] = listing["bookmarks"].clone(); - out["next"] = listing["next"].clone(); - out - } - "doc_protect" => self.doc_protect(&a)?, - "page_replace" => { - let pages = self.pages(&a, "pages")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(read_input(&path)?); - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let src_pages = match a.opt_ints("from_pages")? { - Some(p) => one_based(&p)?, - None => (0..pages.len()).collect(), - }; - self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? - } - "page_duplicate" => { - let pages = self.pages(&a, "pages")?; - let at = self.opt_position(&a, "at")?; - self.apply(&a, Edit::DuplicatePages { pages, at })? - } - "page_set_box" => self.page_set_box(&a)?, - "page_transition_set" => self.page_transition_set(&a)?, - "doc_create" => self.doc_create(&a)?, - "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, - "doc_flatten" => { - let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); - if !comments && !fields { - return Err(ToolError::InvalidArgs("nothing to flatten".into())); - } - self.apply(&a, Edit::Flatten { comments, fields })? - } - "doc_reduce" => { - let id = self.doc(&a)?.id; - let before = self.doc(&a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - // As in the app, a copy that isn't smaller is not written (#490). - let written = bytes.len() < before; - if written { - write_atomic(&path, &bytes)?; - } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) - } - "doc_optimize" => self.doc_optimize(&a)?, - "doc_initial_view" => self.doc_initial_view(&a)?, - "doc_revisions" => self.doc_revisions(&a)?, - "text_lines" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let lines: Vec = doc - .text_lines(page) - .iter() - .enumerate() - .map(|(i, l)| { - let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - json!({ - "line": i + 1, - "text": l.text, - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": l.base_font, - "size": (l.size * 100.0).round() / 100.0, - }) - }) - .collect(); - json!({ "page": page + 1, "count": lines.len(), "lines": lines }) - } - "image_hit" => { - let page = self.page(&a)?; - let point = a - .get("point") - .and_then(Value::as_array) - .filter(|p| p.len() == 2) - .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) - .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) - .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; - let doc = self.doc(&a)?; - let p = &doc.info.pages[page]; - let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); - let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); - json!({"page":page+1,"images":images}) - } - "page_images" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let list: Vec = doc - .page_images(page) - .iter() - .enumerate() - .map(|(i, im)| { - let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); - let visible = im.visible_rect.map(|rect| { - let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); - [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] - }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) - }) - .collect(); - json!({ "page": page + 1, "count": list.len(), "images": list }) - } - "image_edit" | "image_save" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.page_images(page).len(); - let k = a.int("image")?; - if k < 1 || k as usize > n { - return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); - } - let index = k as usize - 1; - if name == "image_save" { - let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; - let mut path = self.resolve(a.str("path")?, true)?; - if path.is_dir() { - // Adding the extension to a folder's name would write beside it: "." is the - // root, so that would land outside it. - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - if path.extension().is_none() { - path.set_extension(ext); - } - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) - } else { - use pdfcraft_engine::ImageEdit; - let change = match a.str("action")? { - "move" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; - // Top-left-origin points → user space. - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "rotate_by" => ImageEdit::RotateBy( - a.get("degrees") - .and_then(Value::as_f64) - .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, - ), - "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), - "crop" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = - <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, - "delete" => ImageEdit::Delete, - "replace" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - ImageEdit::Replace { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - } - } - other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), - }; - self.apply(&a, Edit::EditPageImage { page, index, change })? - } - } - "text_paragraphs" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let current = doc.text_blocks(page); - let id = doc.id; - let blocks: Vec = current - .iter() - .enumerate() - .map(|(i, b)| { - let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); - json!({ - "paragraph": i + 1, - "text": b.text, - "editor_text": b.editor_text, - "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), - "lines": b.lines.iter().map(|l| l + 1).collect::>(), - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": b.base_font, - "size": (b.size * 100.0).round() / 100.0, - }) - }) - .collect(); - self.record_listing(id, page, ¤t); - json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) - } - "text_font_files" => { - let roots = if let Some(paths) = a.get("directories") { - let paths = paths - .as_array() - .filter(|p| p.len() <= 16) - .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; - paths - .iter() - .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) - .collect::>>()? - } else if let Some(roots) = &self.roots { - // The installed fonts are outside the roots: look in the read root instead. - let read = roots - .read_root() - .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; - vec![self.resolve(&read.to_string_lossy(), false)?] - } else if let Some(root) = &self.root { - vec![root.clone()] - } else { - pdfcraft_engine::text_fonts::installed_directories() - }; - json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) - } - "text_font_import" => { - let page = self.page(&a)?; - let path = self.resolve(a.str("path")?, false)?; - let file = std::fs::File::open(&path).map_err(failed)?; - use std::io::Read; - let mut bytes = Vec::new(); - file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; - if bytes.len() > 16 << 20 { - return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); - } - let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); - let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; - let added = self - .doc(&a)? - .text_editor_fonts(page) - .into_iter() - .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); - if let Some(f) = added { - out["resource"] = json!(f.resource); - out["font"] = json!(f.face); - } - out - } - "text_fonts" => { - let page = self.page(&a)?; - json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) - } - "text_edit_layout" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let k = a.int("paragraph")?; - let blocks = doc.text_blocks(page); - let index = self.paragraph_index(doc.id, page, k, &blocks)?; - let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; - let text = a.opt_str("text")?.unwrap_or(&source.editor_text); - let style = pdfcraft_engine::BlockStyle { - size: a.opt_num("size")?, - width: a.opt_num("width")?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - ranges: parse_style_ranges(a.get("ranges"))?, - ..Default::default() - }; - let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; - let info = &doc.info.pages[page]; - let view_rect = |r: [f64; 4]| { - let a = info.user_to_view(r[0] as f32, r[1] as f32); - let b = info.user_to_view(r[2] as f32, r[3] as f32); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - }; - let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); - json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), - "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) - } - "text_edit" if a.get("paragraph").is_some() => { - let page = self.page(&a)?; - let k = a.int("paragraph")?; - let (id, blocks) = { - let doc = self.doc(&a)?; - (doc.id, doc.text_blocks(page)) - }; - let index = self.paragraph_index(id, page, k, &blocks)?; - let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); - let mut style = pdfcraft_engine::BlockStyle { - ranges: parse_style_ranges(a.get("ranges"))?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - bold: a.opt_bool("bold")?, - size: a.opt_num("size")?, - underline: a.opt_bool("underline")?, - line_spacing: a.opt_num("line_spacing")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - width: a.opt_num("width")?, - ..Default::default() - }; - let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); - if dx.is_some() || dy.is_some() { - style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); - } - if let Some(f) = a.opt_str("font")? { - let family = match f { - "helvetica" => pdfcraft_engine::FontFamily::Helvetica, - "times" => pdfcraft_engine::FontFamily::Times, - "courier" => pdfcraft_engine::FontFamily::Courier, - other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), - }; - style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); - } - if let Some(c) = a.opt_str("color")? { - style.color = Some(comments::parse_color(c)?); - } - if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); - } - // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; - let wanted: String = text.split_whitespace().collect(); - let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let doc = self.doc(&a)?; - let after = doc.text_blocks(page); - // Without `text` the paragraph keeps the document's own words: not echoed when - // the document doesn't allow copying them. - let copy = doc.allows_copying(); - // Only a paragraph that now shows exactly the requested text is the edited one. A - // neighbour that overlaps the old box (overlaid text, a text layer) must not take - // its number, and neither must a paragraph the edit merged it into: rewriting that - // one by this number would drop the neighbour's text. - let edited = block_overlapping(&after, moved) - .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); - if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); - if copy { - out["paragraph"]["text"] = json!(b.text); - } - } - let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); - if !stale.is_empty() { - out["stale_paragraphs"] = json!(stale); - } - out - } - "text_edit" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.text_lines(page).len(); - let line = a.int("line")?; - if line < 1 || line as usize > n { - return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); - } - let text = a.str("text")?.to_owned(); - let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; - let after = self.doc(&a)?.text_lines(page); - if let Some(l) = after.get(line as usize - 1) { - out["line"] = json!({ "text": l.text, "font": l.base_font }); - } - out - } - "doc_audit_space" => { - let rows: Vec = self - .doc(&a)? - .audit_space() - .iter() - .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) - .collect(); - json!({ "categories": rows }) - } - "doc_open_revision" => { - let id = self.doc(&a)?.id; - let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); - let new = self.session.open_revision(id, n).map_err(failed)?; - summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) - } - "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, - "doc_unprotect" => { - let mut out = self.apply(&a, Edit::RemoveProtection)?; - out["security"] = security(self.doc(&a)?); - out - } - "form_fields" => self.form_fields(&a)?, - "form_fill" => self.form_fill(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } - "form_reset" => self.form_reset(&a)?, - "form_add_field" => self.form_add_field(&a)?, - "form_add_field_table" => self.form_add_field_table(&a)?, - "form_set_props" => self.form_set_props(&a)?, - "form_delete_field" => self.form_delete_field(&a)?, - "form_tab_order" => self.form_tab_order(&a)?, - "form_calculation_order" => self.form_calculation_order(&a)?, - "doc_export_data" => self.doc_export_data(&a)?, - "doc_import_data" => self.doc_import_data(&a)?, - "redact_mark" => self.redact_mark(&a)?, - "redact_apply" => self.redact_apply(&a)?, - "redact_clear" => self.redact_clear(&a)?, - "doc_hidden_info" => self.doc_hidden_info(&a)?, - "printers" => self.printers()?, - "printer_options" => self.printer_options(&a)?, - "link_list" => self.link_list(&a)?, - "link_open" => self.link_open(&a)?, - "link_add" => self.link_add(&a)?, - "link_edit" => self.link_edit(&a)?, - "link_delete" => self.link_delete(&a)?, - "links_from_urls" => self.links_from_urls(&a)?, - "links_remove" => self.links_remove(&a)?, - "object_list" => self.object_list(&a)?, - "object_move" => self.object_move(&a)?, - "content_list" => self.content_list(&a)?, - "page_add_text" => self.page_add_text(&a)?, - "page_add_image" => self.page_add_image(&a)?, - "content_update" => self.content_update(&a)?, - "content_delete" => self.content_delete(&a)?, - "content_copy" => self.content_copy(&a)?, - "doc_print" => self.doc_print(&a)?, - "attachment_add" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = match a.opt_str("name")? { - Some(n) => n.to_string(), - None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - }; - let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); - let description = a.opt_str("description")?.map(str::to_string); - // Only the document-level keys: an annotation attachment of the same name is not the new one. - let before = document_attachment_keys(self.doc(&a)?); - let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; - let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); - json!({ "name": key, "document": out }) - } - "attachment_delete" => { - let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; - self.apply(&a, edit)? - } - "doc_remove_hidden" => self.doc_remove_hidden(&a)?, - "doc_audit_redactions" => self.doc_audit_redactions(&a)?, - "fill_sign_add" => self.fill_sign_add(&a)?, - "fill_sign_date_format" => self.fill_sign_date_format(&a)?, - "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, - "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, - "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, - "measure_info" => self.measurement_info(&a)?, - "measure_list" => self.measurement_list(&a)?, - "measure_scale" => self.measurement_scale(&a)?, - "measure_snap" => self.measurement_snap(&a)?, - "measure_export" => self.measurement_export(&a)?, - "comment_list" => self.comment_list(&a)?, - "comment_add" => self.comment_add(&a)?, - "stamp_custom" => self.stamp_custom(&a)?, - "comment_reply" => self.comment_reply(&a)?, - "comment_set_status" => self.comment_set_status(&a)?, - "sign_list" => self.sign_list(&a)?, - "accessibility_check" => self.a11y_check(&a)?, - "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, - "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, - "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, - "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, - "ocr_recognize" => self.ocr_recognize(&a)?, - "js_run" => self.js_run(&a)?, - "js_document_scripts" => self.js_document_scripts(&a)?, - "js_set_document_script" => self.js_set_document_script(&a)?, - "js_enabled" => self.js_enabled(&a)?, - "form_set_script" => self.form_set_script(&a)?, - "form_merge_data" => self.form_merge_data(&a)?, - "form_actions" => self.form_actions(&a)?, - "form_detect_fields" => self.form_detect_fields(&a)?, - "doc_compare" => self.doc_compare(&a)?, - "action_list" => self.action_list()?, - "pdfa_verify" => self.pdfa_verify(&a)?, - "print_readiness" => self.print_readiness(&a)?, - "doc_export_office" => self.doc_export_office(&a)?, - "pdfa_convert" => self.pdfa_convert(&a)?, - "standards_remove_id" => { - let standard = match a.str("standard")? { - "pdfa" => pdfcraft_engine::Standard::PdfA, - "pdfx" => pdfcraft_engine::Standard::PdfX, - other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), - }; - self.apply(&a, Edit::RemoveStandardId { standard })? - } - "action_run" => self.action_run(&a)?, - "doc_compare_report" => self.doc_compare_report(&a)?, - "doc_compare_mark" => self.doc_compare_mark(&a)?, - "form_set_actions" => self.form_set_actions(&a)?, - "ocr_status" => self.ocr_status()?, - "ocr_recognize_files" => self.ocr_recognize_files(&a)?, - "accessibility_report" => self.a11y_report(&a)?, - "accessibility_review_record" => self.a11y_review_record(&a)?, - "accessibility_review_save" => self.a11y_review_save(&a)?, - "accessibility_review_load" => self.a11y_review_load(&a)?, - "accessibility_review_html" => self.a11y_review_html(&a)?, - "accessibility_fix" => self.a11y_fix(&a)?, - "accessibility_figures" => self.accessibility_figures(&a)?, - "accessibility_set_alt" => self.accessibility_set_alt(&a)?, - "accessibility_tags" => self.accessibility_tags(&a)?, - "accessibility_tag_content" => self.accessibility_tag_content(&a)?, - "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, - "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, - "accessibility_move_tag" => self.accessibility_move_tag(&a)?, - "sign_id_create" => self.sign_id_create(&a)?, - "sign_document" => self.sign_document(&a)?, - "sign_keychain_ids" => { - #[cfg(target_os = "macos")] - let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) - .map_err(failed)? - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - #[cfg(not(target_os = "macos"))] - let ids: Vec = Vec::new(); - json!({ "count": ids.len(), "ids": ids }) - } - "sign_windows_ids" => { - #[cfg(target_os = "windows")] - let (ids, unusable): (Vec, Vec) = { - let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; - let ids = listing - .ids - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - let unusable = listing - .unusable - .iter() - .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) - .collect(); - (ids, unusable) - }; - #[cfg(not(target_os = "windows"))] - let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); - json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) - } - "sign_trust" => self.sign_trust(&a)?, - "comment_mark" => self.comment_mark(&a)?, - "comment_lock" => self.comment_lock(&a)?, - "comments_hide" => self.comments_hide(&a)?, - "layer_set" => self.layer_set(&a)?, - "view_reading_colors" => self.view_reading_colors(&a)?, - "comments_summarize" => self.comments_summarize(&a)?, - "comment_edit" => self.comment_edit(&a)?, - "comment_delete" => self.comment_delete(&a)?, - other => return Err(ToolError::UnknownTool(other.into())), - }; - Ok(vec![Content::Json(out)]) - } - - // ---- documents --------------------------------------------------------------------------- - - /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). - fn bookmark_listing(&self, a: &Args) -> Result { - let id = a.int("doc")?; - let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); - let offset = a.opt_int("offset")?.unwrap_or(0); - let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; - let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); - let limit = usize::try_from(limit) - .ok() - .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) - .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; - let page = self - .session - .bookmark_page(id, offset, limit) - .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - let pages = &self.doc(a)?.info.pages; - Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) - } - - fn doc(&self, a: &Args) -> Result<&Document> { - let id = a.int("doc")?; - let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; - self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) - } - - /// Most pages whose paragraph listing is remembered. - const MAX_LISTINGS: usize = 256; - - /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be - /// resolved against that listing. - fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { - if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { - self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); - self.listed.clear(); - } - self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); - } - - /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). - /// - /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is - /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a - /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph - /// and lose its text (#2516). So is any number on a page of a document whose listings were - /// dropped at the cap, until the page is listed again. - fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { - let keys = self.listed.get(&(doc, page)); - if keys.is_none() && self.evicted.contains(&doc) { - return Err(ToolError::InvalidArgs(format!( - "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", - page + 1 - ))); - } - let n = keys.map_or(blocks.len(), Vec::len); - let i = usize::try_from(k) - .ok() - .and_then(|k| k.checked_sub(1)) - .filter(|i| *i < n) - .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; - let Some(keys) = keys else { return Ok(i) }; - keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { - ToolError::InvalidArgs(format!( - "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", - page + 1 - )) - }) - } - - /// Update the listing after paragraph `k` was edited into `edited`, one of the page's - /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. - fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { - let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; - if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { - *key = BlockKey::of(edited); - } - keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() - } - - /// The document, if its security allows copying text and images out of it (Table 22, bit 5). - fn doc_to_copy(&self, a: &Args) -> Result<&Document> { - let doc = self.doc(a)?; - doc.check_copying().map_err(failed)?; - Ok(doc) - } - - fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) - } - - fn doc_close(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); - if doc.dirty && !discard_changes { - return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); - } - self.session.close(id); - self.renderers.remove(&id); - self.texts.remove(&id); - self.listed.retain(|(d, _), _| *d != id); - self.evicted.remove(&id); - Ok(json!({ "closed": id.0 })) - } - - fn doc_save(&mut self, a: &Args) -> Result { - self.doc_save_with(a, write_atomic) - } - - fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let target = match a.opt_str("path")? { - Some(p) => self.resolve(p, true)?, - None => { - // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. - let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path - } - }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); - // Saving to a new file is a full rewrite unless asked otherwise, like Save As. - let full = a.opt_bool("full")?.unwrap_or(!same_file); - let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); - if flatten_fill_sign { - self.apply(a, Edit::FlattenFillSign)?; - } - let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; - // A save that adds or removes encryption, or must not keep the old revision (Sanitize, - // redaction), is rewritten in full even when an incremental save was asked for. - let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) - } - - /// Validate the prepared bytes before publishing them or marking the document saved. - fn publish_save( - &mut self, - id: DocId, - target: &Path, - bytes: Arc>, - incremental: bool, - write: impl FnOnce(&Path, &[u8]) -> Result<()>, - ) -> Result { - self.session.check_save(id, &bytes).map_err(failed)?; - write(target, &bytes)?; - let path = target.to_string_lossy().into_owned(); - self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) - } - - fn doc_revisions(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let ends = doc.revision_ends(); - let sigs = doc.signatures.clone(); - let list: Vec = ends - .iter() - .enumerate() - .map(|(i, end)| { - let start = if i == 0 { 0 } else { ends[i - 1] }; - // A signature signs the revision its byte range ends in. - let signed: Vec<&str> = - sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); - json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) - }) - .collect(); - Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) - } - - fn doc_initial_view(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let bad = |m: String| ToolError::InvalidArgs(m); - let mut v = self.doc(a)?.initial_view(); - let before = v.clone(); - if let Some(n) = a.opt_str("navigation")? { - v.navigation = match n { - "page" => N::PageOnly, - "bookmarks" => N::Bookmarks, - "pages" => N::Pages, - "attachments" => N::Attachments, - "layers" => N::Layers, - o => return Err(bad(format!("unknown navigation {o:?}"))), - }; - } - if let Some(l) = a.opt_str("layout")? { - v.layout = match l { - "default" => L::Default, - "single" => L::SinglePage, - "continuous" => L::SinglePageContinuous, - "two_up" => L::TwoUp, - "two_up_continuous" => L::TwoUpContinuous, - "two_up_cover" => L::TwoUpCoverPage, - "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, - o => return Err(bad(format!("unknown layout {o:?}"))), - }; - } - match a.get("magnification") { - None => {} - Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), - Some(Value::String(m)) => { - v.magnification = match m.as_str() { - "default" => M::Default, - "actual" => M::ActualSize, - "fit_page" => M::FitPage, - "fit_width" => M::FitWidth, - "fit_height" => M::FitHeight, - "fit_visible" => M::FitVisible, - o => return Err(bad(format!("unknown magnification {o:?}"))), - } - } - Some(_) => return Err(bad("magnification is a name or a percentage".into())), - } - if let Some(p) = a.opt_int("page")? { - v.page = (p.max(1) - 1) as usize; - } - for (k, f) in [ - ("fit_window", &mut v.fit_window), - ("center_window", &mut v.center_window), - ("full_screen", &mut v.full_screen), - ("display_title", &mut v.display_title), - ("hide_menubar", &mut v.hide_menubar), - ("hide_toolbar", &mut v.hide_toolbar), - ("hide_window_ui", &mut v.hide_window_ui), - ] { - if let Some(b) = a.opt_bool(k)? { - *f = b; - } - } - if let Some(l) = a.opt_str("language")? { - v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); - } - if let Some(b) = a.opt_str("binding")? { - v.right_to_left = match b { - "left" => false, - "right" => true, - o => return Err(bad(format!("binding is left or right, not {o:?}"))), - }; - } - let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; - out["initial_view"] = json!({ - "navigation": format!("{:?}", v.navigation), - "layout": format!("{:?}", v.layout), - "magnification": format!("{:?}", v.magnification), - "page": v.page + 1, - "fit_window": v.fit_window, - "center_window": v.center_window, - "full_screen": v.full_screen, - "display_title": v.display_title, - "hide_menubar": v.hide_menubar, - "hide_toolbar": v.hide_toolbar, - "hide_window_ui": v.hide_window_ui, - "language": v.language, - "binding": if v.right_to_left { "right" } else { "left" }, - }); - Ok(out) - } - - fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; - let id = self.doc(a)?.id; - let before = self.doc(a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let mut settings = match a.opt_str("preset")? { - None => Preset::Standard.settings(), - Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), - }; - if let Some(c) = a.opt_str("compatibility")? { - settings.compatibility = Compatibility::from_name(c) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; - } - let images = |key: &str, base: ImageSettings| -> Result { - let Some(v) = a.get(key) else { return Ok(base) }; - let mut s = base; - if let Some(d) = v.get("downsample").and_then(Value::as_bool) { - s.downsample = d; - } - if let Some(p) = v.get("ppi").and_then(Value::as_f64) { - s.target_ppi = p.clamp(9.0, 2400.0); - } - if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { - s.above_ppi = p.clamp(9.0, 2400.0); - } - s.above_ppi = s.above_ppi.max(s.target_ppi); - if let Some(c) = v.get("compression").and_then(Value::as_str) { - let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; - s.compression = match c { - "jpeg" => Compression::Jpeg(q), - "zip" | "flate" => Compression::Flate, - "retain" => Compression::Retain, - other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), - }; - } - Ok(s) - }; - settings.color = images("color", settings.color)?; - settings.gray = images("gray", settings.gray)?; - for (key, flag) in [ - ("merge_fragments", &mut settings.merge_fragments), - ("discard_thumbnails", &mut settings.discard_thumbnails), - ("discard_alternate_images", &mut settings.discard_alternate_images), - ("discard_tags", &mut settings.discard_tags), - ("discard_print_settings", &mut settings.discard_print_settings), - ("flate_unencoded", &mut settings.flate_unencoded), - ("object_streams", &mut settings.object_streams), - ("lzw_to_flate", &mut settings.lzw_to_flate), - ("remove_invalid_links", &mut settings.remove_invalid_links), - ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), - ] { - if let Some(b) = a.opt_bool(key)? { - *flag = b; - } - } - let discard: Vec = match a.get("discard") { - None => Vec::new(), - Some(v) => v - .as_array() - .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? - .iter() - .map(|x| { - x.as_str() - .and_then(pdfcraft_engine::Hidden::from_id) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) - }) - .collect::>()?, - }; - let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; - write_atomic(&path, &bytes)?; - fn valid_version(v: &[u8]) -> Option<&str> { - if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } - } - let catalog_version = (settings.compatibility == Compatibility::Retain) - .then(|| { - let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; - let root = doc.root()?; - doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) - }) - .flatten(); - let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); - // Viewers read the later of the two (object streams raise the header past a lower catalog - // entry). Both are "d.d", so text order is version order. - let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); - let o = &r.optimize; - Ok(json!({ - "path": path.to_string_lossy(), - "pdf_version": pdf_version, - "object_streams": settings.effective_object_streams(), - "bytes_before": before, - "bytes_after": bytes.len(), - "merged_images": o.merged_images, - "fragments_merged": o.fragments_merged, - "images": o.images, - "images_resampled": o.images_resampled, - "images_recompressed": o.images_recompressed, - "image_bytes_before": o.image_bytes_before, - "image_bytes_after": o.image_bytes_after, - "thumbnails": o.thumbnails, - "alternate_images": o.alternate_images, - "tags_removed": o.tags_removed, - "streams_compressed": o.streams_compressed, - "lzw_streams": o.lzw_streams, - "invalid_links": o.invalid_links, - "invalid_bookmarks": o.invalid_bookmarks, - "unreferenced_dests": o.unreferenced_dests, - "unused_xobjects": o.unused_xobjects, - "merged_objects": r.merged, - "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), - })) - } - - /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). - fn mark_file(&self, a: &Args) -> Result> { - let Some(p) = a.opt_str("file")? else { return Ok(None) }; - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; - Ok(Some(pdfcraft_engine::MarkFile { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - page, - })) - } - - fn apply(&mut self, a: &Args, edit: Edit) -> Result { - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let doc = self.doc(a)?; - let mut out = summary(doc); - // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. - if !doc.edit_notices.is_empty() { - out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); - } - Ok(out) - } - - fn marks(&mut self, tool: &str, a: &Args) -> Result { - use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let color = - |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; - let replace = a.opt_bool("replace")?.unwrap_or(false); - let edit = match tool { - "doc_header_footer" => { - let mut hf = HeaderFooter::default(); - for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { - hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); - } - if let Some(s) = a.opt_num("font_size")? { - hf.font_size = s; - } - hf.font = match a.opt_str("font")? { - None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, - Some("Times") => pdfcraft_engine::FontFamily::Times, - Some("Courier") => pdfcraft_engine::FontFamily::Courier, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); - } - }; - hf.color = color("color", hf.color)?; - if let Some(m) = a.get("margins").and_then(Value::as_array) { - let m: Vec = m.iter().filter_map(Value::as_f64).collect(); - hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; - } - if let Some(s) = a.opt_int("start_number")? { - hf.start_number = s.clamp(1, u32::MAX as i64) as u32; - } - hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); - Edit::AddHeaderFooter { pages, settings: hf, replace } - } - "doc_watermark" => { - let d = Watermark::default(); - let file = self.mark_file(a)?; - let text = match &file { - Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), - None => a.str("text")?.to_string(), - }; - let wm = Watermark { - text, - source: None, - scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), - font_size: a.opt_num("font_size")?.unwrap_or(0.0), - color: color("color", d.color)?, - opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), - rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), - behind: a.opt_bool("behind")?.unwrap_or(false), - offset: [ - a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - ], - }; - Edit::AddWatermark { pages, settings: wm, replace, file } - } - "doc_background" => Edit::AddBackground { - pages, - settings: Background { - color: color("color", [1.0; 3])?, - opacity: a.opt_num("opacity")?.unwrap_or(1.0), - scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), - ..Background::default() - }, - replace, - file: self.mark_file(a)?, - }, - _ => { - let kind = match a.str("kind")? { - "header_footer" => MarkKind::HeaderFooter, - "watermark" => MarkKind::Watermark, - "background" => MarkKind::Background, - other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), - }; - Edit::RemoveMarks { kind } - } - }; - self.apply(a, edit) - } - - fn export(&mut self, tool: &str, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); - let mut ex = pdfcraft_engine::export::Exporter::new(doc); - if tool == "doc_export_text" { - let path = self.resolve(a.str("path")?, true)?; - let text = ex.text_of(&pages).map_err(failed)?; - write_atomic(&path, text.as_bytes())?; - return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); - } - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; - if tool == "doc_export_all_images" { - let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); - let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; - let mut files = Vec::new(); - for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); - write_atomic(&path, &img.data)?; - files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); - } - let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); - return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); - } - let dpi = a.opt_num("dpi")?.unwrap_or(150.0); - let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; - let format = match a.opt_str("format")?.unwrap_or("png") { - "png" => pdfcraft_engine::export::ImageFormat::Png, - "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, - "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, - f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), - }; - let mut files = Vec::new(); - let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; - let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; - files.push(path.to_string_lossy().into_owned()); - // A page too large for the renderer at `dpi` is drawn at the most it allows. - let used = ex.dpi_used(p, dpi); - if used < dpi.clamp(18.0, 1200.0) - 0.5 { - lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); - } - } - if lowered.is_empty() { - Ok(json!({ "count": files.len(), "files": files })) - } else { - Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) - } - } - - fn doc_create(&mut self, a: &Args) -> Result { - let (name, bytes) = match a.str("from")? { - "blank" => { - let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; - let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); - ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) - } - "images" => { - let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); - let mut images = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); - } - let name = if images.len() == 1 { - format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) - } else { - "Images.pdf".into() - }; - (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) - } - "text" => { - let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { - (Some(t), _) => ("Text".to_string(), t.to_string()), - (None, Some(p)) => { - let path = self.resolve(p, false)?; - let t = read_input(&path)?; - (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) - } - (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), - }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. - let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { - pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), - e => failed(e), - })?; - (format!("{title}.pdf"), bytes) - } - other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), - }; - let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); - let id = self.session.open_new(name, bytes).map_err(failed)?; - Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) - } - - fn page_transition_set(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Transition, TransitionSpec}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let transition = match a.opt_str("style")? { - None | Some("none") => None, - Some(style) => { - let mut t = Transition::new(style); - if let Some(d) = a.opt_num("duration")? { - t.duration = d as f32; - } - if let Some(dm) = a.opt_str("dimension")? { - t.horizontal = match dm { - "horizontal" => true, - "vertical" => false, - other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), - }; - } - if let Some(m) = a.opt_str("motion")? { - t.inward = match m { - "inward" => true, - "outward" => false, - other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), - }; - } - match a.get("direction") { - None => {} - Some(Value::String(s)) if s == "none" => t.direction = None, - Some(v) => { - t.direction = Some( - v.as_u64() - .and_then(|d| u16::try_from(d).ok()) - .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, - ); - } - } - if let Some(s) = a.opt_num("scale")? { - t.scale = s as f32; - } - if let Some(b) = a.opt_bool("opaque")? { - t.opaque = b; - } - Some(t) - } - }; - let display = a.opt_num("display")?.map(|s| s as f32); - self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) - } - - fn page_set_box(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{BoxSpec, PageBox}; - let doc = self.doc(a)?; - let n = doc.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let which = match a.opt_str("box")? { - None => PageBox::Crop, - Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, - }; - let nums = |key: &str| -> Result> { - a.get(key) - .map(|v| { - let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) - }) - .transpose() - }; - let spec = match (nums("margins")?, nums("rect")?) { - (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), - (Some(m), None) => BoxSpec::Margins(m), - (None, Some(r)) => { - // Top-left-origin points on the displayed page → user space (one page at a time). - if pages.len() != 1 { - return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); - } - let p = &doc.info.pages[pages[0]]; - let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); - BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - (None, None) => BoxSpec::Remove, - }; - let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; - out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); - Ok(out) - } - - fn doc_protect(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; - let d = Protection::default(); - // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is - // enforced against the owner password; without one everything stays allowed). Refuse a - // restriction that could not take effect instead of writing an unrestricted file (#134). - let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); - if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { - return Err(ToolError::InvalidArgs(format!( - "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" - ))); - } - let p = Protection { - open_password: a.opt_str("open_password")?.map(str::to_owned), - permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), - printing: match a.opt_str("printing")? { - None => d.printing, - Some("none") => Printing::None, - Some("low") => Printing::Low, - Some("high") => Printing::High, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), - }, - changes: match a.opt_str("changes")? { - None => d.changes, - Some("none") => Changes::None, - Some("pages") => Changes::Pages, - Some("fill-sign") => Changes::FillSign, - Some("comment-fill-sign") => Changes::CommentFillSign, - Some("any-except-extract") => Changes::AnyExceptExtract, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), - }, - copy: a.opt_bool("copy")?.unwrap_or(d.copy), - accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), - algorithm: match a.opt_str("compatibility")? { - None | Some("aes-256") => Algorithm::Aes256, - Some("aes-128") => Algorithm::Aes128, - Some("rc4-128") => Algorithm::Rc4_128, - Some("rc4-40") => Algorithm::Rc4_40, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), - }, - encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), - }; - let mut out = self.apply(a, Edit::Protect(p))?; - out["security"] = security(self.doc(a)?); - Ok(out) - } - - fn insert_blank(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let at = self.position(a, "at")?; - // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. - let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); - let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); - let width = a.opt_num("width")?.unwrap_or(w); - let height = a.opt_num("height")?.unwrap_or(h); - if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { - return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); - } - self.apply(a, Edit::InsertBlankPage { at, width, height }) - } - - fn insert_file(&mut self, a: &Args) -> Result { - let at = self.position(a, "at")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = read_input(&path)?; - let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) - } - - /// Write freshly created bytes to `out` and/or open them as a new document. - fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { - let mut result = json!({ "bytes": bytes.len() }); - let out = a.opt_str("out")?; - if let Some(out) = out { - let path = self.resolve(out, true)?; - write_atomic(&path, &bytes)?; - result["path"] = json!(path.to_string_lossy()); - } - if a.opt_bool("open")?.unwrap_or(out.is_none()) { - let id = self.session.open_new(name, bytes).map_err(failed)?; - result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); - } - Ok(result) - } - - fn doc_create_multiple(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.is_empty() { - return Err(ToolError::InvalidArgs("paths must list at least one file".into())); - } - let separate = match a.opt_str("mode")? { - None | Some("combine") => false, - Some("separate") => true, - Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), - }; - if separate { - return self.create_separate(a, &paths); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - // Each file is read, converted and copied in turn, so a long list holds one file at a time. - let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { - let path = self.resolve(p, false)?; - let bytes = read_input(&path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; - let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - Ok((title, pdf, range)) - }); - let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; - self.deliver(a, "Combined", bytes) - } - - /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. - fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut out = Vec::new(); - for &p in paths { - let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; - let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; - Ok((kind, stem, pdf)) - }); - out.push(match converted { - Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), - Ok((_, stem, pdf)) => { - let target = unused(&dir, &stem); - write_atomic(&target, &pdf)?; - json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - fn page_extract(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let (id, name) = (doc.id, format!("{} (extract)", doc.name)); - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); - let pages = self.pages(a, "pages")?; - let mut out = if a.opt_bool("separate")?.unwrap_or(false) { - // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) - } else { - let bytes = self.session.extract(id, &pages).map_err(failed)?; - self.deliver(a, &name, bytes)? - }; - if a.opt_bool("delete")?.unwrap_or(false) { - let deleted = self.apply(a, Edit::DeletePages { pages })?; - out["original"] = deleted; - } - Ok(out) - } - - fn doc_combine(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.len() < 2 { - return Err(ToolError::InvalidArgs("combine needs at least two files".into())); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v - .iter() - .enumerate() - .map(|(index, value)| match value { - Value::Null => Ok(None), - Value::String(range) => Ok(Some(range.clone())), - _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), - }) - .collect::>()?, - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - let passwords: Vec> = match a.get("passwords") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { - v.iter().map(|x| x.as_str().map(str::to_owned)).collect() - } - Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), - }; - // Entries sharing a group number are one file split into parts placed apart. - let groups: Option>> = match a.get("groups") { - None | Some(Value::Null) => None, - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { - Some(v.iter().map(Value::as_u64).collect()) - } - Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), - }; - let field_conflicts = match a.opt_str("field_conflicts")? { - None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, - Some("merge") => pdfcraft_organize::FieldConflicts::Merge, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); - } - }; - let mut sources: Vec = Vec::new(); - let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); - for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { - let path = self.resolve(p, false)?; - let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); - let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); - let bytes = match first { - Some((g, first_path, at)) => { - if *first_path != path { - return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); - } - sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? - } - None => { - if let Some(g) = group { - firsts.push((g, path.clone(), sources.len())); - } - let bytes = Arc::new(read_input(&path)?); - // Images and text files are made into PDFs first (#569), as in the Combine files tab. - let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.session.as_pdf(&file_name, &bytes).map_err(failed)? - } - }; - let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - sources.push((name, bytes, range)); - } - let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); - let combined = match groups { - None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), - Some(groups) => { - // A path without a group is a file of its own: give it a key no group uses. - let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); - let mut keys = Vec::with_capacity(groups.len()); - for g in &groups { - keys.push(match g { - Some(g) => *g, - None => { - let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; - next = own.checked_add(1); - own - } - }); - } - self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) - } - } - .map_err(failed)?; - let mut result = self.deliver(a, "Combined", combined.bytes)?; - if !combined.renamed_fields.is_empty() { - result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); - } - Ok(result) - } - - fn doc_split(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); - let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); - let max_mb = a.opt_num("max_mb")?; - let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); - if chosen != 1 { - return Err(ToolError::InvalidArgs( - "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), - )); - } - let mut titles: Vec<(usize, String)> = Vec::new(); - let parts = if let Some(mb) = max_mb { - if !mb.is_finite() || mb <= 0.0 { - return Err(ToolError::InvalidArgs("max_mb must be positive".into())); - } - self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? - } else { - let by = match (a.opt_int("every")?, a.opt_ints("before")?) { - (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), - (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), - _ if bookmarks => { - titles = self.session.bookmark_splits(id); - if titles.is_empty() { - return Err(failed("the document has no top-level bookmarks")); - } - pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) - } - _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), - }; - self.session.split(id, &by).map_err(failed)? - }; - let dir = self.resolve(a.str("out_dir")?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - let mut used = std::collections::HashSet::new(); - for (i, (first, last, bytes)) in parts.iter().enumerate() { - let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}", safe(t)), - None => format!("{stem}-part{}", i + 1), - }; - // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. - if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { - base.truncate(end); - } - let file = format!("{base}.pdf"); - // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file; - let mut n = 1; - while !used.insert(unique.to_lowercase()) { - n += 1; - unique = format!("{base}-{n}.pdf"); - } - let path = child(&dir, &unique); - write_atomic(&path, bytes)?; - files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); - } - Ok(json!({ "files": files })) - } - - // ---- pages and text ---------------------------------------------------------------------- - - /// 1-based page list → validated 0-based indices. - fn pages(&self, a: &Args, key: &str) -> Result> { - let n = self.doc(a)?.info.pages.len(); - let pages = one_based(&a.ints(key)?)?; - if pages.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); - } - if let Some(p) = pages.iter().find(|p| **p >= n) { - return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); - } - Ok(pages) - } - - /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. - fn position(&self, a: &Args, key: &str) -> Result { - self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) - } - - /// `position`, but `None` when the key is absent. - fn opt_position(&self, a: &Args, key: &str) -> Result> { - let Some(at) = a.opt_int(key)? else { return Ok(None) }; - let n = self.doc(a)?.info.pages.len(); - if at < 1 || at as usize > n + 1 { - return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); - } - Ok(Some(at as usize - 1)) - } - - fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - // The document's own view settings: layer visibility, hidden comments, reading colours - // (tools that change one drop this cache so the next render reads it again). - let config = - self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); - let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); - let entry = self.renderers.entry(id).or_insert_with(fresh); - if !Arc::ptr_eq(&entry.0, &doc.display) { - *entry = fresh(); - } - Ok(&mut entry.1) - } - - fn page_render(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let page = self.page(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 scale = (dpi / 72.0) as f32; - let block = a.opt_int("hide_text_block")?; - let added = a.opt_int("hide_added_text")?; - if block.is_some() && added.is_some() { - return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); - } - let out = if let Some(block) = block { - if block < 1 { - return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); - } - // The number names the paragraph text_paragraphs listed, as it does for text_edit. - let doc = self.doc(a)?; - let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; - doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else if let Some(added) = added { - let index = usize::try_from(added) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; - self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else { - self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) - }; - if let Some(e) = out.error { - return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); - } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) - } - - /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels - /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: - /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted - /// work on every scroll. It uses the same renderer, and the same per-document cache, as - /// `page_render`. - pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { - if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { - return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); - } - let id = DocId(doc); - let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); - if page < 1 || page > n { - return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); - } - let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); - if let Some(e) = out.error { - return Err(failed(format!("page {page} could not be rendered: {e}"))); - } - Ok((out.width, out.height, out.rgba)) - } - - fn page(&self, a: &Args) -> Result { - let n = self.doc(a)?.info.pages.len(); - let p = a.int("page")?; - if p < 1 || p as usize > n { - return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); - } - Ok(p as usize - 1) - } - - /// The text of `pages` (0-based), from the cache or extracted in parallel. - fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { - self.page_text_results(id, pages, &mut extract_parallel)? - .into_iter() - .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) - .collect() - } - - /// Keep per-page failures until the caller chooses strict or partial-result semantics. - fn page_text_results( - &mut self, - id: DocId, - pages: &[usize], - extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, - ) -> Result { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); - let password: Option> = doc.password.as_deref().map(Arc::from); - self.texts.prepare(id, &bytes); - let mut results = HashMap::new(); - let mut missing = Vec::new(); - for &p in pages { - if p >= n { - results.insert(p, Err("page is out of range".to_owned())); - } else if let Some(text) = self.texts.get(id, p) { - results.insert(p, Ok(text)); - } else { - missing.push(p); - } - } - missing.sort_unstable(); - missing.dedup(); - if !missing.is_empty() { - for (p, text) in extract(&bytes, password, &missing) { - if let Ok(text) = &text { - self.texts.insert(id, p, &bytes, text.clone()); - } - results.insert(p, text); - } - } - // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A - // worker that fails to return a page is an explicit failure, never an empty success. - Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) - } - - fn text_word_count(&self, a: &Args) -> Result { - use pdfcraft_engine::word_count::{self, Counts, Options}; - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let options = Options { - exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), - exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), - exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), - }; - let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; - let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); - Ok(json!({ - "total": counts(report.total), - "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), - "pages_without_text": report.pages_without_text, - "unclassified_structure": report.unclassified_structure, - })) - } - - fn text_extract(&mut self, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let id = doc.id; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - // Column select (#740): only the text inside the rectangle, row by row. - let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); - let texts = self.page_texts(id, &pages)?; - let text = |t: &PageText| match rect { - Some(r) => t.column_text(&t.glyphs_in(r)), - None => t.plain_text(), - }; - let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); - Ok(json!({ "pages": out })) - } - - /// Matches in document order, extracting page text in growing batches and stopping as soon as - /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document - /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported - /// as bounded warnings; `complete` requires both no continuation and no failed pages. - fn text_find(&mut self, a: &Args) -> Result { - self.text_find_with(a, &mut extract_parallel) - } - - fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { - let doc = self.doc(a)?; - let (id, n) = (doc.id, doc.info.pages.len()); - let query = a.str("query")?; - let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); - let start = match a.opt_int("start_page")? { - None => 0, - Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), - Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), - }; - let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); - let mut matches = Vec::new(); - let mut next = None; - let mut warnings = Vec::new(); - let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); - 'search: while first < n { - let end = first.saturating_add(batch).min(n); - let pages: Vec = (first..end).collect(); - let texts = self.page_text_results(id, &pages, extract)?; - for (p, text) in texts { - let text = match text { - Ok(text) => text, - Err(mut error) => { - warning_count += 1; - if warnings.len() < FIND_MAX_WARNINGS { - let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); - let truncated = end < error.len(); - error.truncate(end); - warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); - } - continue; - } - }; - let skip_here = if p == start { skip } else { 0 }; - // One extra match proves that the caller needs a continuation. Include skipped - // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); - for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { - if matches.len() == limit { - next = Some(json!({ "start_page": p + 1, "skip": k })); - break 'search; - } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); - } - } - first = end; - batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); - } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, - "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); - } - Ok(out) - } - - fn command_list(&self, a: &Args) -> Result { - let active = match a.opt_int("doc")? { - Some(_) => Some(self.doc(a)?.id), - None => None, - }; - let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); - let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); - let list: Vec = commands::COMMANDS - .iter() - .map(|c| { - json!({ - "id": c.id, - "label": commands::current_label(c, &self.session, active), - "menu": c.menu, - "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), - "enabled": commands::is_enabled(c, &self.session, active), - "tool": tools::tool_for_command(c.id), - "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), - }) - }) - .collect(); - let list: Vec = list - .into_iter() - .filter(|c| { - (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) - && (filter.is_empty() - || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) - }) - .collect(); - Ok(json!({ "commands": list })) - } - - // ---- paths ------------------------------------------------------------------------------- - - /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that - /// does not exist yet (its nearest existing ancestor must be inside the root). - /// - /// Every path outside the root gets the same refusal, so a confined client can't learn what - /// exists out there (#136): `..` is resolved by name first, another network share or device - /// namespace is refused without touching it, and the deepest existing ancestor (links - /// followed) must be inside the root before anything about the rest is reported. - fn resolve(&self, path: &str, for_write: bool) -> Result { - let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; - self.resolve_for(path, access, for_write) - } - - /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that - /// is only read may still be missing (a path checked up front, whose absence is reported per - /// file later). The automation roots, if any, get the last word. - fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { - let target = self.resolve_in_root(path, may_be_missing)?; - self.check_roots(path, &target, access)?; - Ok(target) - } - - /// Hold `target`, which `path` named, to the automation read or write root. Always passes - /// without roots. - fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { - match &self.roots { - Some(roots) => roots.check_path(path, target, access).map_err(failed), - None => Ok(()), - } - } - - fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { - let p = Path::new(path); - let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; - let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); - let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { - return Err(outside()); - } - let mut existing = joined.as_path(); - let mut rest = Vec::new(); - while !existing.exists() { - if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { - return Err(outside()); // a broken link: going on would tell whether its target exists - } - match (existing.file_name(), existing.parent()) { - (Some(name), Some(parent)) => { - rest.push(name); - existing = parent; - } - _ => return Err(outside()), // not even a drive or share that exists - } - } - let mut real = existing.canonicalize().map_err(|_| outside())?; - if !real.starts_with(root) { - return Err(outside()); - } - if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say where it was looked for, so a path - // written for another working directory can be corrected (#1890), and otherwise - // why, as the system reports it. `joined` is under the root, so naming it reveals - // nothing outside. - real = joined.canonicalize().map_err(|e| { - let (looked, root) = (joined.display(), root.display()); - match e.kind() { - std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), - _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), - } - })?; - if !real.starts_with(root) { - return Err(outside()); // it appeared, as a link out, since the check above - } - return Ok(real); - } - real.extend(rest.iter().rev()); - Ok(real) - } -} - -/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does -/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb -/// above the start of the path. -fn lexical(path: &Path) -> Option { - use std::path::Component; - let mut parts: Vec = Vec::new(); - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => match parts.last() { - Some(Component::Normal(_)) => { - parts.pop(); - } - _ => return None, - }, - other => parts.push(other), - } - } - Some(parts.iter().collect()) -} - -// ---- JSON helpers ---------------------------------------------------------------------------- - -fn parse_style_ranges(value: Option<&Value>) -> Result> { - let Some(value) = value else { return Ok(Vec::new()) }; - let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; - values - .iter() - .map(|v| { - let a = Args(v); - let bounds = a - .get("range") - .and_then(Value::as_array) - .filter(|v| v.len() == 2) - .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; - let start = bounds - .first() - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let end = bounds - .get(1) - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let family = a - .opt_str("font")? - .map(|f| match f { - "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), - "times" => Ok(pdfcraft_engine::FontFamily::Times), - "courier" => Ok(pdfcraft_engine::FontFamily::Courier), - _ => Err(Args::wrong("font", "helvetica, times or courier")), - }) - .transpose()? - .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) - .transpose()?; - Ok(pdfcraft_engine::RangeStyle { - range: start..end, - family, - resource: a.opt_str("font_resource")?.map(str::to_owned), - size: a.opt_num("size")?, - color: a.opt_str("color")?.map(comments::parse_color).transpose()?, - underline: a.opt_bool("underline")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - }) - }) - .collect() -} - -/// Typed access to validated arguments. -struct Args<'a>(&'a Value); - -impl Args<'_> { - fn get(&self, key: &str) -> Option<&Value> { - self.0.get(key).filter(|v| !v.is_null()) - } - fn missing(key: &str) -> ToolError { - ToolError::InvalidArgs(format!("missing argument {key}")) - } - fn wrong(key: &str, what: &str) -> ToolError { - ToolError::InvalidArgs(format!("{key} must be {what}")) - } - fn str(&self, key: &str) -> Result<&str> { - self.opt_str(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_str(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() - } - fn int(&self, key: &str) -> Result { - self.opt_int(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_int(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() - } - fn opt_num(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() - } - fn opt_bool(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() - } - fn ints(&self, key: &str) -> Result> { - self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_ints(&self, key: &str) -> Result>> { - let Some(v) = self.get(key) else { return Ok(None) }; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; - arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) - } - /// A 1-based bookmark path → 0-based indices. - fn path(&self, key: &str) -> Result> { - let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; - if p.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); - } - Ok(p) - } - fn opt_path(&self, key: &str) -> Result>> { - let Some(v) = self.opt_ints(key)? else { return Ok(None) }; - v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) - } - /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error - /// when it is not a number or lies absurdly far off the page. - fn bookmark_coordinate(&self, key: &str) -> Result> { - match self.opt_num(key)? { - None => Ok(None), - Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), - Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), - } - } - fn strs(&self, key: &str) -> Result> { - let v = self.get(key).ok_or_else(|| Self::missing(key))?; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; - arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() - } -} - -/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. -fn document_attachment_keys(doc: &Document) -> Vec { - doc.info - .attachments - .iter() - .filter_map(|x| match &x.source { - pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), - pdfcraft_render::AttachmentSource::Annotation { .. } => None, - }) - .collect() -} - -/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the -/// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { - // The open rows, outermost first, with their depth and the children gathered so far. - let mut open: Vec<(usize, Value, Vec)> = Vec::new(); - let mut roots = Vec::new(); - for row in rows { - let depth = row.path.len().saturating_sub(1); - while open.last().is_some_and(|(d, _, _)| *d >= depth) { - close_row(&mut open, &mut roots); - } - let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); - // Only what differs from the defaults (black, regular), as the PDF itself stores it. - if let Some(color) = row.color { - fields["color"] = json!(color); - } - if row.bold { - fields["bold"] = json!(true); - } - if row.italic { - fields["italic"] = json!(true); - } - if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { - fields["destination"] = destination; - } - open.push((depth, fields, Vec::new())); - } - while !open.is_empty() { - close_row(&mut open, &mut roots); - } - roots -} - -/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page -/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its -/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the -/// reader's position and zoom). `None` for a destination without a type. -pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { - use pdfcraft_render::DestView; - let points = |left: Option, top: Option| { - let [x, y] = page.dest_fraction(left, top, 0); - let round = |v: f32| f64::from((v * 100.0).round() / 100.0); - (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) - }; - Some(match view { - DestView::Top => return None, - DestView::Xyz { left, top, zoom } => { - let (x, y) = points(*left, *top); - json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) - } - DestView::Fit => json!({ "fit": "Fit" }), - DestView::FitH { top } => { - let (x, y) = points(None, *top); - json!({ "fit": "FitH", "left": x, "top": y }) - } - DestView::FitV { left } => { - let (x, y) = points(*left, None); - json!({ "fit": "FitV", "left": x, "top": y }) - } - DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), - }) -} - -/// Closes the innermost open row: it joins the row above it as a child, or the top level. -fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { - if let Some((_, mut fields, children)) = open.pop() { - fields["children"] = Value::Array(children); - match open.last_mut() { - Some((_, _, siblings)) => siblings.push(fields), - None => roots.push(fields), - } - } -} - -fn one_based(pages: &[i64]) -> Result> { - pages - .iter() - .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) - .collect() -} - -/// The document's security as the next save writes it (never includes passwords). -fn security(d: &Document) -> Value { - match d.security_summary() { - None => json!({ "protected": false }), - Some(s) => { - let p = s.permissions; - json!({ - "protected": true, - "method": s.method, - "pending": s.pending, - "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), - "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), - }) - } - } -} - -/// What every tool that changes a document returns. -fn summary(d: &Document) -> Value { - json!({ - "doc": d.id.0, - "name": d.name, - "path": d.path, - "pages": d.info.pages.len(), - "dirty": d.dirty, - "editable": d.read_only_reason.is_none(), - "read_only_reason": d.read_only_reason, - "encrypted": d.info.encrypted, - "undo": d.can_undo(), - "redo": d.can_redo(), - }) -} - -fn info(d: &Document) -> Value { - let i = &d.info; - let page1 = |p: usize| p + 1; - let mut security = security(d); - if let Some(s) = d.security_summary() { - security["opened_as_owner"] = json!(s.owner); - } - json!({ - "document": summary(d), - "pdf_version": i.pdf_version, - "file_size": i.file_size, - "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, - // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the - // ones the spec names, set and cleared with doc_set_info. - "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, - "tagged": i.tagged, - "has_javascript": i.has_javascript, - // Where the document opens (/OpenAction, named destinations resolved): 1-based page and - // destination, null without one. - "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), - // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" - // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). - "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), - // A script of the form ran too long and was abandoned: its scripts no longer run in this - // document (calculations, buttons) until it is opened again. - "xfa_scripts_off": d.xfa_scripts_off(), - // What was rewritten from, or could not be written to, the XFA data. - "xfa_warnings": d.xfa_warnings, - "security": security, - "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ - "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, - })).collect::>(), - "outline": outline(&i.outline), - "outline_more": i.outline_more, - // Rectangles use the tools' convention (top-left of the displayed page, like - // comment_list and link_list), not raw PDF user space, so they can be fed back to - // geometry-taking tools (#129). - "annotations": i.annotations.iter().map(|a| json!({ - "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, - "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), - })).collect::>(), - "fields": i.fields.iter().map(|f| json!({ - "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), - "tooltip": f.tooltip, "has_actions": f.has_actions, - })).collect::>(), - "links": i.links.iter().map(|l| json!({ - "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, - })).collect::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), - // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with - // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. - "transitions": i.transitions.iter().map(|t| json!({ - "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, - "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, - "display": t.display, - })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), - // `substitute` names the face standing in for a program the document does not embed, and - // `substitution` says why. Both are null for an embedded font: nothing stands in. A null - // `substitute` with a non-null `substitution` means no face was available at all, so - // nothing is drawn for that font -- which is the answer to "why is my text missing?". - "fonts": i.fonts.iter().map(|f| json!({ - "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, - "substitute": f.substitute, "substitution": f.substitution, - })).collect::>(), - "warnings": i.warnings, - "repairs": d.repair_log(), - }) -} - -/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the -/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports -/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). -/// Public so `pdfcraft-cli` reports the same rectangles as the tools. -pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { - i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) -} - -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) -} - -/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). -fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) -} - -/// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { - let extract = |r: &mut PageRenderer, p: usize| { - let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - match out.error { - Some(e) => Err(e), - None => Ok(out.text.unwrap_or_default()), - } - }; - let config = RenderConfig { password, ..Default::default() }; - let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; - // Small jobs aren't worth a second parse of the document. - if workers == 1 || pages.len() < 8 { - let mut r = PageRenderer::new(bytes.clone(), config); - return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); - } - let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: PageTexts = std::thread::scope(|s| { - let handles: Vec<_> = (0..workers) - .map(|_| { - s.spawn(|| { - let mut r = PageRenderer::new(bytes.clone(), config.clone()); - let mut done = Vec::new(); - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(p) = pages.get(i) else { break }; - done.push((*p, extract(&mut r, *p))); - } - done - }) - }) - .collect(); - handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() - }); - out.sort_by_key(|(p, _)| *p); - out -} - -/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, -/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots -/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. -fn child(dir: &Path, name: &str) -> PathBuf { - let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); - if safe.chars().all(|c| c == '.') { - safe.insert(0, '_'); - } - dir.join(safe) -} - -/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. -fn unused(dir: &Path, stem: &str) -> PathBuf { - let first = child(dir, &format!("{stem}.pdf")); - if !first.exists() { - return first; - } - (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). -pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; - -/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. -pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { - use std::io::Read as _; - let file = std::fs::File::open(path)?; - if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - let mut bytes = Vec::new(); - file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_INPUT { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); - } - Ok(bytes) -} - -pub(crate) fn read_input(path: &Path) -> Result> { - read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) -} - -/// Write and sync a temporary file in the same directory, then rename over the target. -/// This flushes staged contents; it does not promise parent-directory durability after a -/// power loss. Filesystems that refuse sync cause the save to fail before publication. -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - write_atomic_with(path, bytes, staging_suffixes()) -} - -/// [`write_atomic`], trying the staging names that `suffixes` give. -fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { - write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) -} - -fn write_atomic_synced( - path: &Path, - bytes: &[u8], - suffixes: impl IntoIterator, - sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, -) -> Result<()> { - // A folder can't be replaced, and staging beside it could land outside the root: "." names - // the root itself, whose parent isn't ours to write in. - if path.is_dir() { - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; - std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // The writer itself is the platform crate's, shared with the desktop app and the CLI - // (#2166): the staging file is written, flushed (delayed writeback failures surface before - // the destination is replaced or a document marked clean), closed and renamed; a failure at - // any step removes it and leaves the destination as it was. - pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) - .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs new file mode 100644 index 000000000..d9ea9e5c4 --- /dev/null +++ b/crates/automation/src/pages.rs @@ -0,0 +1,363 @@ +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based}; +use super::*; + +/// Default and maximum resolution for `page_render`. +pub(crate) const DEFAULT_DPI: f64 = 96.0; + +pub(crate) const MAX_DPI: f64 = 600.0; + +/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a +/// match near the start returns without touching the rest of a long document (#971). +pub(crate) const FIND_FIRST_BATCH: usize = 8; + +const FIND_MAX_BATCH: usize = 128; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; + +pub(crate) const FIND_MAX_WARNINGS: usize = 100; + +pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; + +impl Automation { + // ---- pages and text ---------------------------------------------------------------------- + + /// 1-based page list → validated 0-based indices. + pub(crate) fn pages(&self, a: &Args, key: &str) -> Result> { + let n = self.doc(a)?.info.pages.len(); + let pages = one_based(&a.ints(key)?)?; + if pages.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); + } + if let Some(p) = pages.iter().find(|p| **p >= n) { + return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); + } + Ok(pages) + } + + /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. + pub(crate) fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + pub(crate) fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; + let n = self.doc(a)?.info.pages.len(); + if at < 1 || at as usize > n + 1 { + return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); + } + Ok(Some(at as usize - 1)) + } + + pub(crate) fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); + let entry = self.renderers.entry(id).or_insert_with(fresh); + if !Arc::ptr_eq(&entry.0, &doc.display) { + *entry = fresh(); + } + Ok(&mut entry.1) + } + + pub(crate) fn page_render(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let page = self.page(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 scale = (dpi / 72.0) as f32; + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { + if block < 1 { + return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); + } + // The number names the paragraph text_paragraphs listed, as it does for text_edit. + let doc = self.doc(a)?; + let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; + doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else { + self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) + }; + if let Some(e) = out.error { + return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); + } + let data = encode_png(out.width, out.height, &out.rgba)?; + Ok(Content::Png { data, width: out.width, height: out.height }) + } + + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + + pub(crate) fn page(&self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let p = a.int("page")?; + if p < 1 || p as usize > n { + return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); + } + Ok(p as usize - 1) + } + + /// The text of `pages` (0-based), from the cache or extracted in parallel. + pub(crate) fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + pub(crate) fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); + let password: Option> = doc.password.as_deref().map(Arc::from); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); + } + } + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) + } + + pub(crate) fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + + pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let id = doc.id; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + // Column select (#740): only the text inside the rectangle, row by row. + let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); + let texts = self.page_texts(id, &pages)?; + let text = |t: &PageText| match rect { + Some(r) => t.column_text(&t.glyphs_in(r)), + None => t.plain_text(), + }; + let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); + Ok(json!({ "pages": out })) + } + + /// Matches in document order, extracting page text in growing batches and stopping as soon as + /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. + pub(crate) fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + pub(crate) fn text_find_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let query = a.str("query")?; + let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); + let start = match a.opt_int("start_page")? { + None => 0, + Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), + Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), + }; + let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let mut matches = Vec::new(); + let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; + let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + 'search: while first < n { + let end = first.saturating_add(batch).min(n); + let pages: Vec = (first..end).collect(); + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; + // One extra match proves that the caller needs a continuation. Include skipped + // matches on the starting page when bounding the temporary per-page result. + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { + if matches.len() == limit { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + } + } + first = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); + if let (Some(next), Some(o)) = (next, out.as_object_mut()) { + o.insert("next".into(), next); + } + Ok(out) + } + + pub(crate) fn command_list(&self, a: &Args) -> Result { + let active = match a.opt_int("doc")? { + Some(_) => Some(self.doc(a)?.id), + None => None, + }; + let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); + let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); + let list: Vec = commands::COMMANDS + .iter() + .map(|c| { + json!({ + "id": c.id, + "label": commands::current_label(c, &self.session, active), + "menu": c.menu, + "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), + "enabled": commands::is_enabled(c, &self.session, active), + "tool": tools::tool_for_command(c.id), + "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), + }) + }) + .collect(); + let list: Vec = list + .into_iter() + .filter(|c| { + (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) + && (filter.is_empty() + || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) + }) + .collect(); + Ok(json!({ "commands": list })) + } +} + +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). +pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) +} + +/// Extract the text of `pages` (0-based) with one renderer per worker thread. +pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { + let extract = |r: &mut PageRenderer, p: usize| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + match out.error { + Some(e) => Err(e), + None => Ok(out.text.unwrap_or_default()), + } + }; + let config = RenderConfig { password, ..Default::default() }; + let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; + // Small jobs aren't worth a second parse of the document. + if workers == 1 || pages.len() < 8 { + let mut r = PageRenderer::new(bytes.clone(), config); + return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); + } + let next = std::sync::atomic::AtomicUsize::new(0); + let mut out: PageTexts = std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + s.spawn(|| { + let mut r = PageRenderer::new(bytes.clone(), config.clone()); + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(p) = pages.get(i) else { break }; + done.push((*p, extract(&mut r, *p))); + } + done + }) + }) + .collect(); + handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() + }); + out.sort_by_key(|(p, _)| *p); + out +} diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs new file mode 100644 index 000000000..fb514df69 --- /dev/null +++ b/crates/automation/src/paths.rs @@ -0,0 +1,180 @@ +use super::error::{Result, failed}; +use super::*; + +impl Automation { + // ---- paths ------------------------------------------------------------------------------- + + /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that + /// does not exist yet (its nearest existing ancestor must be inside the root). + /// + /// Every path outside the root gets the same refusal, so a confined client can't learn what + /// exists out there (#136): `..` is resolved by name first, another network share or device + /// namespace is refused without touching it, and the deepest existing ancestor (links + /// followed) must be inside the root before anything about the rest is reported. + pub(crate) fn resolve(&self, path: &str, for_write: bool) -> Result { + let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; + self.resolve_for(path, access, for_write) + } + + /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that + /// is only read may still be missing (a path checked up front, whose absence is reported per + /// file later). The automation roots, if any, get the last word. + pub(crate) fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { + let target = self.resolve_in_root(path, may_be_missing)?; + self.check_roots(path, &target, access)?; + Ok(target) + } + + /// Hold `target`, which `path` named, to the automation read or write root. Always passes + /// without roots. + pub(crate) fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { + match &self.roots { + Some(roots) => roots.check_path(path, target, access).map_err(failed), + None => Ok(()), + } + } + + fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { + let p = Path::new(path); + let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; + let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); + let joined = lexical(&root.join(p)).ok_or_else(outside)?; + if foreign_share(&joined, root) { + return Err(outside()); + } + let mut existing = joined.as_path(); + let mut rest = Vec::new(); + while !existing.exists() { + if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { + return Err(outside()); // a broken link: going on would tell whether its target exists + } + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + rest.push(name); + existing = parent; + } + _ => return Err(outside()), // not even a drive or share that exists + } + } + let mut real = existing.canonicalize().map_err(|_| outside())?; + if !real.starts_with(root) { + return Err(outside()); + } + if !rest.is_empty() && !for_write { + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; + if !real.starts_with(root) { + return Err(outside()); // it appeared, as a link out, since the check above + } + return Ok(real); + } + real.extend(rest.iter().rev()); + Ok(real) + } +} + +/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does +/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb +/// above the start of the path. +pub(crate) fn lexical(path: &Path) -> Option { + use std::path::Component; + let mut parts: Vec = Vec::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => match parts.last() { + Some(Component::Normal(_)) => { + parts.pop(); + } + _ => return None, + }, + other => parts.push(other), + } + } + Some(parts.iter().collect()) +} + +/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, +/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots +/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. +pub(crate) fn child(dir: &Path, name: &str) -> PathBuf { + let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); + if safe.chars().all(|c| c == '.') { + safe.insert(0, '_'); + } + dir.join(safe) +} + +/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. +pub(crate) fn unused(dir: &Path, stem: &str) -> PathBuf { + let first = child(dir, &format!("{stem}.pdf")); + if !first.exists() { + return first; + } + (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + +/// Write and sync a temporary file in the same directory, then rename over the target. +/// This flushes staged contents; it does not promise parent-directory durability after a +/// power loss. Filesystems that refuse sync cause the save to fail before publication. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + write_atomic_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomic`], trying the staging names that `suffixes` give. +pub(crate) fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +pub(crate) fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> Result<()> { + // A folder can't be replaced, and staging beside it could land outside the root: "." names + // the root itself, whose parent isn't ours to write in. + if path.is_dir() { + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 4a5651b54..fd4efa79d 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -207,8 +207,7 @@ fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { #[test] fn names_from_documents_become_one_plain_file_name() { let dir = std::env::temp_dir().join("out"); - for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] - { + for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] { let p = child(&dir, name); assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display()); assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display());