Files
pdfcraft/crates/automation/src/content.rs
T
Zinadin Zidan 7e8551e4bc Add text: gray or CMYK colour, painted with g or k (#6440)
page_add_text and content_update took only RGB, so text added to a CMYK
print file painted 0 0 0 rg, rich black on press. They now also take gray
(0 to 1) or cmyk ([c, m, y, k]), painted with g or k and kept in /PCAdded as
/Gray or /CMYK beside /Color, which holds the matching look. An ink applies
only while the colour still shows its look: picking another colour, also in
the Format panel, drops it for good. RGB text is written byte for byte as
before, and a stored ink that doesn't match its colour is ignored.
2026-11-07 01:01:33 +06:00

406 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit a PDF ▸ Add content tools: add text and images as page content, list what was added,
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
//! displayed page (y down), like every other tool.
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign, TextInk};
use serde_json::{Value, json};
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
/// Top-left view rect [x0 y0 x1 y1] ↔ display space [x0 y0 x1 y1] (y up).
fn to_display(r: [f64; 4], ph: f64) -> [f64; 4] {
[r[0].min(r[2]), ph - r[1].max(r[3]), r[0].max(r[2]), ph - r[1].min(r[3])]
}
fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] {
[r[0], ph - r[3], r[2], ph - r[1]]
}
fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
a.get(key)
.map(|v| {
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
<[f64; 4]>::try_from(r).map_err(|_| bad(format!("{key} must be 4 numbers")))
})
.transpose()
}
const INK_ARGS: &str = "give one of color (#RRGGBB or a name), gray (0 black to 1 white) and cmyk ([c, m, y, k], each 0 to 1)";
/// `gray` or `cmyk`: the text painted in DeviceGray or DeviceCMYK rather than RGB. At most one of
/// them and `color`.
fn ink_arg(a: &Args) -> Result<Option<TextInk>> {
if ["color", "gray", "cmyk"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
return Err(bad(INK_ARGS));
}
let unit = |v: Option<f64>, key: &str| v.filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad(format!("{key} takes numbers from 0 to 1")));
if let Some(g) = a.get("gray") {
return Ok(Some(TextInk::Gray(unit(g.as_f64(), "gray")?)));
}
let Some(c) = a.get("cmyk") else { return Ok(None) };
let parts = c.as_array().filter(|p| p.len() == 4).ok_or_else(|| bad("cmyk must be [c, m, y, k]"))?;
let mut cmyk = [0.0; 4];
for (out, v) in cmyk.iter_mut().zip(parts) {
*out = unit(v.as_f64(), "cmyk")?;
}
Ok(Some(TextInk::Cmyk(cmyk)))
}
/// Apply the style arguments to `t`.
fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(s) = a.opt_num("size")? {
t.size = s;
}
if let Some(f) = a.opt_str("font")? {
t.family = match f.to_ascii_lowercase().as_str() {
"helvetica" | "sans" | "arial" => FontFamily::Helvetica,
"times" | "serif" => FontFamily::Times,
"courier" | "mono" | "monospace" => FontFamily::Courier,
other => return Err(bad(format!("unknown font {other:?} (helvetica, times, courier)"))),
};
}
if let Some(b) = a.opt_bool("bold")? {
t.bold = b;
}
if let Some(i) = a.opt_bool("italic")? {
t.italic = i;
}
if let Some(ink) = ink_arg(a)? {
t.set_ink(Some(ink));
} else if let Some(c) = a.opt_str("color")? {
// An RGB colour paints in RGB, even one that is the ink's look.
t.set_ink(None);
t.set_color(parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
t.align = parse_align(al)?;
// The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
t.aligns.clear();
}
if let Some(aligns) = opt_aligns(a)? {
let count = pdfcraft_engine::paragraph_align::count(&t.text);
t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
return Err(bad(format!("char_spacing must be between -{ADDED_CHAR_SPACING_LIMIT} and {ADDED_CHAR_SPACING_LIMIT} points")));
}
t.char_spacing = c;
}
Ok(())
}
pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
Ok(match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
})
}
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
const MAX_ALIGNS: usize = 4096;
/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`.
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
let Some(v) = a.get("aligns") else { return Ok(None) };
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?;
if items.len() > MAX_ALIGNS {
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
}
items
.iter()
.map(|v| match v {
Value::Null => Ok(None),
Value::String(s) if s == "inherit" => Ok(None),
Value::String(s) => parse_align(s).map(Some),
_ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")),
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let mut per_page: std::collections::HashMap<usize, usize> = Default::default();
let items: Vec<Value> = doc
.added()
.iter()
.map(|it| {
let index = per_page.entry(it.page).or_default();
// Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`.
let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64));
let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) });
*index += 1;
match &it.content {
AddedContent::Text(t) => {
v["type"] = json!("text");
v["text"] = json!(t.text);
v["font"] = json!(t.family.label());
v["size"] = json!(t.size);
v["bold"] = json!(t.bold);
v["italic"] = json!(t.italic);
v["char_spacing"] = json!(t.char_spacing);
// Text painted in a print colour space says so; RGB text has neither.
match t.painted_ink() {
Some(TextInk::Gray(g)) => v["gray"] = json!(g),
Some(TextInk::Cmyk(c)) => v["cmyk"] = json!(c),
None => {}
}
}
AddedContent::Image(_) => v["type"] = json!("image"),
}
v
})
.collect();
Ok(json!({ "count": items.len(), "items": items }))
}
pub(crate) fn page_add_text(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let ph = self.doc(a)?.info.pages[page].height as f64;
let rect = match (rect_arg(a, "rect")?, a.get("at")) {
(Some(r), _) => to_display(r, ph),
(None, Some(v)) => {
let p: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let [x, y]: [f64; 2] = p.try_into().map_err(|_| bad("at must be [x, y]"))?;
let w = a.opt_num("width")?.unwrap_or(200.0);
[x, ph - y - 20.0, x + w, ph - y]
}
(None, None) => return Err(bad("pass at [x, y] (the text's top-left) or rect")),
};
let mut t = AddedText { rect, text: a.str("text")?.to_string(), ..AddedText::default() };
style(a, &mut t)?;
let mut out = self.apply(a, Edit::AddText { page, text: t })?;
out["index"] = json!(self.doc(a)?.added().iter().filter(|x| x.page == page).count());
Ok(out)
}
pub(crate) fn page_add_image(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let ph = self.doc(a)?.info.pages[page].height as f64;
let path = self.resolve(a.str("path")?, false)?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph));
let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?;
let doc = self.doc(a)?;
let mine: Vec<_> = doc.added().iter().filter(|x| x.page == page).collect();
out["index"] = json!(mine.len());
out["rect"] = json!(mine.last().map(|x| to_view(x.content.rect(), ph)));
Ok(out)
}
fn item(&self, a: &Args) -> Result<(usize, usize, AddedContent, f64)> {
let page = self.page(a)?;
let doc = self.doc(a)?;
let index = a.int("index")?;
let ph = doc.info.pages[page].height as f64;
let it = doc
.added()
.iter()
.filter(|x| x.page == page)
.nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
.filter(|_| index >= 1)
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
Ok((page, index as usize - 1, it.content.clone(), ph))
}
pub(crate) fn content_update(&mut self, a: &Args) -> Result<Value> {
let (page, index, content, ph) = self.item(a)?;
let mut content = match rect_arg(a, "rect")? {
Some(r) => content.with_rect(to_display(r, ph)),
None => content,
};
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
// Each paragraph keeps its own alignment through the retyping.
t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
if ["rotate", "flip_h", "flip_v", "crop", "image"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("rotate, flip, crop and image apply to images"));
}
}
AddedContent::Image(i) => {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "gray", "cmyk", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, gray, cmyk, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
return Err(bad("rotate must be a multiple of 90"));
}
// Reduce untrusted degrees before adding the current quarter turns.
i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8;
}
if a.opt_bool("flip_h")? == Some(true) {
i.flip_h = !i.flip_h;
}
if a.opt_bool("flip_v")? == Some(true) {
i.flip_v = !i.flip_v;
}
if let Some(c) = rect_arg(a, "crop")? {
if !pdfcraft_engine::AddedImage::crop_valid(c) {
return Err(bad(
"crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95",
));
}
i.crop = c;
}
if let Some(p) = a.opt_str("image")? {
let path = self.resolve(p, false)?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
// Box and transforms first, then the new picture.
self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?;
return self.apply(a, Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) });
}
}
}
self.apply(a, Edit::UpdateContent { page, index, content })
}
pub(crate) fn content_delete(&mut self, a: &Args) -> Result<Value> {
let (page, index, _, _) = self.item(a)?;
self.apply(a, Edit::DeleteContent { page, index })
}
/// Edit ▸ Copy, then Paste: an added item onto `to_page` of `to_doc` (its own page and
/// document by default), as it is, at `at` (its top-left) or where it was, stepped down and to
/// the right past copies of it already on that page.
pub(crate) fn content_copy(&mut self, a: &Args) -> Result<Value> {
let (page, index, content, _) = self.item(a)?;
let source = self.doc(a)?;
source.check_copying()?;
let from = source.id;
let file = match &content {
AddedContent::Text(_) => None,
AddedContent::Image(_) => {
let (ext, bytes) = source.added_image_file(page, index).map_err(failed)?;
Some((format!("Pasted image.{ext}"), std::sync::Arc::new(bytes)))
}
};
let to_doc = match a.opt_int("to_doc")? {
Some(d) => {
let d = u64::try_from(d).map_err(|_| bad("to_doc must be positive"))?;
self.session.get(pdfcraft_engine::DocId(d)).ok_or_else(|| failed(format!("no open document with id {d} (see doc_list)")))?.id
}
None => from,
};
let doc = self.session.get(to_doc).ok_or_else(|| failed("the document is no longer open"))?;
let n = doc.info.pages.len();
let to_page = match a.opt_int("to_page")? {
Some(p) => usize::try_from(p)
.ok()
.and_then(|p| p.checked_sub(1))
.filter(|p| *p < n)
.ok_or_else(|| bad(format!("to_page {p} is out of range: the document has {n} pages")))?,
None if page < n => page,
None => return Err(bad(format!("page {} is out of range in the target: it has {n} pages; pass to_page", page + 1))),
};
let info = doc.info.pages.get(to_page).ok_or_else(|| bad("the page no longer exists"))?;
let (pw, ph) = (f64::from(info.width), f64::from(info.height));
let at = match a.get("at") {
Some(v) => {
let p: Option<Vec<f64>> = v.as_array().and_then(|x| x.iter().map(Value::as_f64).collect());
let [x, y]: [f64; 2] = p.and_then(|p| p.try_into().ok()).ok_or_else(|| bad("at must be [x, y]"))?;
Some([x, ph - y])
}
None => None,
};
let on_page = || doc.added().iter().filter(|x| x.page == to_page);
let rect = match at {
Some(at) => pdfcraft_engine::added_paste_rect(content.rect(), [pw, ph], 0, Some(at)),
None => pdfcraft_engine::added_paste_place(on_page().map(|x| &x.content), &content, [pw, ph]),
};
let before = on_page().count();
let target = json!({ "doc": to_doc.0 });
let mut out = self.apply(&Args(&target), Edit::PasteContent { page: to_page, content: content.with_rect(rect), file })?;
out["page"] = json!(to_page + 1);
out["index"] = json!(before + 1);
out["rect"] = json!(to_view(rect, ph).map(|x| (x * 100.0).round() / 100.0));
Ok(out)
}
}
impl Automation {
/// One mixed inventory, with exact source references and the current document generation.
pub(crate) fn object_list(&self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let doc = self.doc(a)?;
let info = doc.info.pages.get(page).ok_or_else(|| bad("the page no longer exists"))?;
let objects = doc.editable_objects(page).map_err(failed)?;
let items = objects
.iter()
.map(|o| {
let rect = crate::comments::rect_to_view(info, o.rect.map(|v| v as f32));
json!({"kind":o.target.kind.as_str(),"index":o.target.index.saturating_add(1),"type":o.content_type,
"rect":rect.map(|v|(f64::from(v)*100.0).round()/100.0),"text":o.text})
})
.collect::<Vec<_>>();
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
if row.keys().any(|k| k != "kind" && k != "index") {
return Err(bad("an object reference accepts only kind and index"));
}
let kind = match row.get("kind").and_then(Value::as_str) {
Some("added") => ObjectKind::Added,
Some("text") => ObjectKind::Text,
Some("image") => ObjectKind::Image,
_ => return Err(bad("object kind must be added, text or image (see object_list)")),
};
let index = row
.get("index")
.and_then(Value::as_u64)
.and_then(|i| i.checked_sub(1))
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
let delta = a
.get("offset")
.and_then(Value::as_array)
.filter(|v| v.len() == 2)
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
}