diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b4337b2a66..9afb1f3537 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,12 +4,14 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (213) +## In this repository (215) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db28bd6f24..f87e63afba 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -30,6 +30,28 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "30d5abcbed2176ee33b2844c8cdde6fc379c3e9ec28a6a22a1fa916a356c1dcb" +[[asset]] +path = "assets/icons/align-center.svg" +title = "Lucide icon \"align-center\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "bc70fba645343be0760d1a87f9e0975a10ead2b8d95883314f4cea007376e79f" + +[[asset]] +path = "assets/icons/align-right.svg" +title = "Lucide icon \"align-right\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "70ef15bcaa5721820aef032ad1e65370fb2667fd2e2d63eb24fbf3c2f57346cc" + [[asset]] path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" diff --git a/assets/icons/align-center.svg b/assets/icons/align-center.svg new file mode 100644 index 0000000000..8204bfedc9 --- /dev/null +++ b/assets/icons/align-center.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-right.svg b/assets/icons/align-right.svg new file mode 100644 index 0000000000..5e756411dc --- /dev/null +++ b/assets/icons/align-right.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs new file mode 100644 index 0000000000..454c33a0b7 --- /dev/null +++ b/crates/automation/src/content.rs @@ -0,0 +1,167 @@ +//! 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 printcraft_engine::{AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use serde_json::{Value, json}; + +use crate::comments::parse_color; +use crate::{Args, Automation, Result, ToolError, failed}; + +fn bad(m: impl Into) -> 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> { + a.get(key) + .map(|v| { + let r: Vec = 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() +} + +/// 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(c) = a.opt_str("color")? { + t.color = parse_color(c)?; + } + if let Some(al) = a.opt_str("align")? { + t.align = match al { + "left" => TextAlign::Left, + "center" | "centre" => TextAlign::Center, + "right" => TextAlign::Right, + other => return Err(bad(format!("unknown align {other:?}"))), + }; + } + Ok(()) +} + +impl Automation { + pub(crate) fn content_list(&self, a: &Args) -> Result { + let doc = self.doc(a)?; + let mut per_page: std::collections::HashMap = Default::default(); + let items: Vec = doc + .added + .iter() + .map(|it| { + let index = per_page.entry(it.page).or_default(); + let ph = doc.info.pages[it.page].height as f64; + let mut v = + json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).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); + } + 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 { + 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 = 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 { + 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 = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + 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((index - 1).max(0) as usize) + .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 { + 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, + }; + if let AddedContent::Text(t) = &mut content { + if let Some(s) = a.opt_str("text")? { + t.text = s.to_string(); + } + style(a, t)?; + } else if a.get("text").is_some() { + return Err(bad("images have no text")); + } + self.apply(a, Edit::UpdateContent { page, index, content }) + } + + pub(crate) fn content_delete(&mut self, a: &Args) -> Result { + let (page, index, _, _) = self.item(a)?; + self.apply(a, Edit::DeleteContent { page, index }) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9a93c9b48a..4712569c61 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -10,6 +10,7 @@ //! - An optional root directory confines every path a tool reads or writes. mod comments; +mod content; mod forms; #[cfg(feature = "mcp")] pub mod mcp; @@ -263,6 +264,11 @@ impl Automation { "redact_clear" => self.redact_clear(&a)?, "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, + "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)?, "doc_print" => self.doc_print(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 668bd0fad7..a5ba6a22b4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -341,6 +341,51 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "page_add_text", + "Add text to a page", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. It stays editable with content_update. Undoable.", + ) + .cmd("edit.text") + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "text": { "type": "string", "minLength": 1 }, + "at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, + "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, + "align": { "type": "string", "enum": ["left", "center", "right"] }, + }), + &["doc", "page", "text"], + )), + t( + "page_add_image", + "Add an image to a page", + "Add an image file (PNG, JPEG, TIFF, GIF, BMP) as page content: in rect (points from the top-left of the page), or centred at its natural size (shrunk to fit). Undoable; movable with content_update.", + ) + .cmd("edit.image") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }), + &["doc", "page", "path"], + )), + t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Undoable.") + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" }, + "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, + "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, + "align": { "type": "string", "enum": ["left", "center", "right"] }, + }), + &["doc", "page", "index"], + )), + t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") + .destructive() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 204759fb92..0fdc5fb6e7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -685,3 +685,31 @@ fn printing_through_tools() { assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn adding_content_through_tools() { + let dir = workdir("content"); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 40, 20); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[200u8; 2400]).unwrap(); + } + std::fs::write(dir.join("logo.png"), png).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "CONFIDENTIAL", "at": [20, 20], "size": 18, "bold": true, "color": "red" })); + let r = ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] })); + assert_eq!(r["rect"], json!([100.0, 200.0, 180.0, 240.0])); + assert!(page_text(&mut a, doc)[0].contains("CONFIDENTIAL")); + let list = ok(&mut a, "content_list", json!({ "doc": doc })); + assert_eq!(list["count"], 2); + assert_eq!((list["items"][0]["type"].as_str(), list["items"][0]["bold"].as_bool()), (Some("text"), Some(true))); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "text": "DRAFT", "font": "times" })); + let text = page_text(&mut a, doc)[0].clone(); + assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); + ok(&mut a, "content_delete", json!({ "doc": doc, "page": 1, "index": 2 })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); + assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); +} diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 49ecd127e5..bfe12d7b7a 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -303,6 +303,24 @@ fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { } } +/// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the +/// object and the image's natural size in points (from its resolution). +pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> { + let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?; + let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1); + let mut d = img.dict; + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64)); + d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64)); + if let Some((m, alpha)) = img.smask { + let mr = doc.add(Object::Stream(Stream::flate(m, &alpha))); + d.set(b"SMask".to_vec(), Object::Ref(mr)); + } + let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) }; + Ok((doc.add(Object::Stream(stream)), size)) +} + /// One page per image, each the size of its image at the image's resolution. pub fn from_images(images: &[(String, Vec)]) -> Result { if images.is_empty() { diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs new file mode 100644 index 0000000000..852b1b77c9 --- /dev/null +++ b/crates/edit/src/added.rs @@ -0,0 +1,421 @@ +//! Edit a PDF ▸ Add content: text and images added as page content (execution plan M7.4). +//! +//! Each added item is its own content stream on the page, tagged `/PCMark /Added`, with its +//! parameters (text, font, size, colour, alignment, box; or image and box) kept in the stream +//! dictionary under `/PCAdded`. That keeps the item editable later (move, resize, retype, +//! reformat, delete) without content-stream surgery, while every viewer sees ordinary page +//! content. Boxes are in display space (origin at the bottom-left of the page as shown, after +//! `/Rotate`), so items stay upright on rotated pages. + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use printcraft_fonts::{helvetica_width, literal, win_ansi}; + +use crate::{EditError, check, contents, n, page_list, place_tagged}; + +const TAG: &str = "Added"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Family { + #[default] + Helvetica, + Times, + Courier, +} + +impl Family { + pub fn label(self) -> &'static str { + match self { + Family::Helvetica => "Helvetica", + Family::Times => "Times", + Family::Courier => "Courier", + } + } + + /// The standard-14 font name for this family and style. + pub fn base_font(self, bold: bool, italic: bool) -> &'static str { + match (self, bold, italic) { + (Family::Helvetica, false, false) => "Helvetica", + (Family::Helvetica, true, false) => "Helvetica-Bold", + (Family::Helvetica, false, true) => "Helvetica-Oblique", + (Family::Helvetica, true, true) => "Helvetica-BoldOblique", + (Family::Times, false, false) => "Times-Roman", + (Family::Times, true, false) => "Times-Bold", + (Family::Times, false, true) => "Times-Italic", + (Family::Times, true, true) => "Times-BoldItalic", + (Family::Courier, false, false) => "Courier", + (Family::Courier, true, false) => "Courier-Bold", + (Family::Courier, false, true) => "Courier-Oblique", + (Family::Courier, true, true) => "Courier-BoldOblique", + } + } + + fn from_base(base: &[u8]) -> (Family, bool, bool) { + let s = String::from_utf8_lossy(base); + let family = if s.starts_with("Times") { + Family::Times + } else if s.starts_with("Courier") { + Family::Courier + } else { + Family::Helvetica + }; + (family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique")) + } + + /// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths, + /// scaled for Times; Courier is monospaced). + pub fn width(self, s: &str, size: f64, bold: bool) -> f64 { + let w = match self { + Family::Courier => s.chars().count() as f64 * 0.6 * size, + Family::Times => helvetica_width(s, size) * 0.9, + Family::Helvetica => helvetica_width(s, size), + }; + if bold { w * 1.05 } else { w } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Align { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AddedText { + /// The text box in display space; its width wraps the text and its top is the first line's + /// top. The height follows the text. + pub rect: [f64; 4], + pub text: String, + pub family: Family, + pub bold: bool, + pub italic: bool, + pub size: f64, + pub color: [f64; 3], + pub align: Align, +} + +impl Default for AddedText { + fn default() -> Self { + AddedText { + rect: [0.0; 4], + text: String::new(), + family: Family::Helvetica, + bold: false, + italic: false, + size: 12.0, + color: [0.0; 3], + align: Align::Left, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AddedImage { + /// Display space. + pub rect: [f64; 4], + /// The image XObject. + pub image: ObjRef, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + Text(AddedText), + Image(AddedImage), +} + +impl Content { + pub fn rect(&self) -> [f64; 4] { + match self { + Content::Text(t) => t.rect, + Content::Image(i) => i.rect, + } + } + + /// The same item moved/resized to `rect` (text keeps its computed height). + pub fn with_rect(&self, rect: [f64; 4]) -> Content { + match self { + Content::Text(t) => Content::Text(AddedText { rect, ..t.clone() }), + Content::Image(i) => Content::Image(AddedImage { rect, ..i.clone() }), + } + } +} + +/// An added item on a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Added { + pub page: usize, + /// The item's content stream. + pub obj: ObjRef, + pub content: Content, +} + +fn nums(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() +} + +fn norm(r: [f64; 4]) -> [f64; 4] { + [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] +} + +/// The lines of a text item after wrapping to its box width. +pub fn lines(t: &AddedText) -> Vec { + let width = (t.rect[2] - t.rect[0]).max(t.size); + let mut out = Vec::new(); + for para in t.text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if !line.is_empty() && t.family.width(&candidate, t.size, t.bold) > width { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } else { + line = candidate; + } + } + out.push(line); + } + out +} + +/// The box a text item occupies (height from its lines). +pub fn text_rect(t: &AddedText) -> [f64; 4] { + let r = norm(t.rect); + let h = lines(t).len().max(1) as f64 * t.size * 1.2; + [r[0], r[3] - h, r[2], r[3]] +} + +fn font_name(base: &str) -> String { + format!("PCF{}", base.replace('-', "")) +} + +/// Content and resources for an item; `view` maps display space to user space. +fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec, Dict), EditError> { + let mut res = Dict::new(); + let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes(); + match c { + Content::Text(t) => { + let base = t.family.base_font(t.bold, t.italic); + let name = font_name(base); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + if t.family != Family::Courier || !t.text.is_ascii() { + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + } + let mut fonts = Dict::new(); + fonts.set(name.clone().into_bytes(), Object::Dict(font)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let r = text_rect(t); + let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); + out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); + for (i, line) in lines(t).iter().enumerate() { + let w = t.family.width(line, t.size, t.bold); + let x = match t.align { + Align::Left => r[0], + Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, + Align::Right => r[2] - w, + }; + // Baseline: 0.8 em below the line top. + let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(line))); + out.extend_from_slice(b" Tj\n"); + } + out.extend_from_slice(b"ET\n"); + } + Content::Image(i) => { + if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) { + return Err(EditError::Invalid("not an image".into())); + } + let r = norm(i.rect); + let name = format!("PCImg{}", i.image.num); + let mut xo = Dict::new(); + xo.set(name.clone().into_bytes(), Object::Ref(i.image)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + out.extend(format!("{} 0 0 {} {} {} cm /{name} Do\n", n(r[2] - r[0]), n(r[3] - r[1]), n(r[0]), n(r[1])).bytes()); + } + } + out.extend_from_slice(b"Q\n"); + Ok((out, res)) +} + +fn params(c: &Content) -> Dict { + let mut d = Dict::new(); + let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + match c { + Content::Text(t) => { + d.set(b"Kind".to_vec(), Object::name("Text")); + d.set(b"Text".to_vec(), PdfString::text(&t.text)); + d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic))); + d.set(b"Size".to_vec(), Object::Real(t.size)); + d.set(b"Color".to_vec(), arr(&t.color)); + d.set( + b"Align".to_vec(), + Object::Int(match t.align { + Align::Left => 0, + Align::Center => 1, + Align::Right => 2, + }), + ); + d.set(b"Rect".to_vec(), arr(&text_rect(t))); + } + Content::Image(i) => { + d.set(b"Kind".to_vec(), Object::name("Image")); + d.set(b"Image".to_vec(), Object::Ref(i.image)); + d.set(b"Rect".to_vec(), arr(&norm(i.rect))); + } + } + d +} + +fn parse(doc: &Document, d: &Dict) -> Option { + let r = nums(doc, d.get(b"Rect")); + let rect: [f64; 4] = r.try_into().ok()?; + match d.name(b"Kind")? { + b"Text" => { + let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica")); + let c = nums(doc, d.get(b"Color")); + Some(Content::Text(AddedText { + rect, + text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(), + family, + bold, + italic, + size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0), + color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] }, + align: match d.int(b"Align") { + Some(1) => Align::Center, + Some(2) => Align::Right, + _ => Align::Left, + }, + })) + } + b"Image" => Some(Content::Image(AddedImage { rect, image: d.get(b"Image")?.as_ref()? })), + _ => None, + } +} + +fn validate(c: &Content) -> Result<(), EditError> { + let r = c.rect(); + if !r.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("invalid position".into())); + } + match c { + Content::Text(t) => { + if t.text.trim().is_empty() { + return Err(EditError::Invalid("type some text first".into())); + } + if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) { + return Err(EditError::Invalid("the font size must be between 1 and 500 points".into())); + } + if (r[2] - r[0]).abs() < 1.0 { + return Err(EditError::Invalid("the text box is too narrow".into())); + } + } + Content::Image(_) => { + if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 { + return Err(EditError::Invalid("the image is too small".into())); + } + } + } + Ok(()) +} + +/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page. +fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> Result { + validate(c)?; + let all = page_list(doc); + check(&[page], all.len())?; + let p = &all[page]; + let (content, res) = draw(doc, c, p.view_matrix(doc))?; + // The page gets its own copy of its resources with the item's fonts and images added. + let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + for (k, v) in res.iter() { + let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); + for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { + sub.set(n2.clone(), o.clone()); + } + pres.set(k.clone(), Object::Dict(sub)); + } + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?; + let mut sd = Dict::new(); + sd.set(b"PCMark".to_vec(), Object::name(TAG)); + sd.set(b"PCAdded".to_vec(), Object::Dict(params(c))); + let stream = Stream::flate(sd, &content); + match obj { + Some(r) => { + doc.set(r, Object::Stream(stream)); + Ok(r) + } + None => { + // place_tagged appends a new stream; recover its reference. + let p = page_list(doc).swap_remove(page); + place_tagged(doc, &p, TAG, content.clone(), false)?; + let p = page_list(doc).swap_remove(page); + let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?; + doc.set(r, Object::Stream(stream)); + Ok(r) + } + } +} + +/// Every added item, page by page, in drawing order. +pub fn list_added(doc: &Document) -> Vec { + let mut out = Vec::new(); + for (pi, p) in page_list(doc).iter().enumerate() { + let list: Vec = match p.dict.get(b"Contents") { + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + None => Vec::new(), + }; + for o in list { + let Some(r) = o.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + if d.name(b"PCMark") != Some(TAG.as_bytes()) { + continue; + } + if let Some(c) = d.get(b"PCAdded").and_then(|p| doc.resolve(p).as_dict().cloned()).and_then(|p| parse(doc, &p)) { + out.push(Added { page: pi, obj: r, content: c }); + } + } + } + out +} + +/// Add an item to a page (0-based). Returns its index among the page's added items. +pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result { + write(doc, page, c, None)?; + Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1) +} + +fn find(doc: &Document, page: usize, index: usize) -> Result { + list_added(doc) + .into_iter() + .filter(|a| a.page == page) + .nth(index) + .ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1))) +} + +/// Replace item `index` of `page` (moved, resized, retyped or reformatted). +pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> { + let a = find(doc, page, index)?; + if std::mem::discriminant(&a.content) != std::mem::discriminant(c) { + return Err(EditError::Invalid("an item can't change between text and image".into())); + } + write(doc, page, c, Some(a.obj))?; + Ok(()) +} + +/// Remove item `index` of `page`. +pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let a = find(doc, page, index)?; + let p = page_list(doc).swap_remove(page); + let list: Vec = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect(); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + Ok(()) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7f380c094a..9017b94ce5 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -499,6 +499,8 @@ pub fn marks_present(doc: &Document) -> Vec { mod flatten; pub use flatten::flatten; +pub mod added; +pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, list_added, update_content}; #[cfg(test)] mod tests; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 65febcbc07..5d0874fdd3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -171,3 +171,55 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(xo.as_dict().unwrap().contains(b"PCFl1")); assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } + +#[test] +fn added_text_and_images_are_page_content_that_stays_editable() { + let mut doc = fixture(); + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Approved by Ada\nSecond line".into(), size: 14.0, ..AddedText::default() }; + assert_eq!(add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(), 0); + // An 1×1 gray image, placed on the rotated page. + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(1)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128]))); + add_content(&mut doc, 1, &Content::Image(AddedImage { rect: [10.0, 10.0, 110.0, 60.0], image: img })).unwrap(); + let doc2 = reopen(&doc); + let all = list_added(&doc2); + assert_eq!(all.len(), 2); + let Content::Text(t) = &all[0].content else { panic!() }; + assert_eq!((t.text.as_str(), t.size), ("Approved by Ada\nSecond line", 14.0)); + assert_eq!(t.rect, [72.0, 700.0 - 2.0 * 14.0 * 1.2, 300.0, 700.0], "the box height follows the two lines"); + let page0 = streams(&doc2, 0).join("\n"); + assert!(page0.contains("(Approved by Ada) Tj") && page0.contains("(Second line) Tj") && page0.contains("/PCFHelvetica 14 Tf"), "{page0}"); + // The rotated page draws in display space: the view matrix comes first. + let page1 = streams(&doc2, 1).join("\n"); + assert!(page1.contains("q 0 1 -1 0 600 0 cm") && page1.contains(&format!("/PCImg{} Do", img.num)), "{page1}"); + // Edit: move, restyle, retype; then delete. + let mut doc = doc2; + let moved = AddedText { + rect: [100.0, 500.0, 300.0, 520.0], + text: "Approved".into(), + bold: true, + family: Family::Times, + align: Align::Right, + color: [1.0, 0.0, 0.0], + ..text + }; + update_content(&mut doc, 0, 0, &Content::Text(moved)).unwrap(); + let page0 = streams(&doc, 0).join("\n"); + assert!(page0.contains("/PCFTimesBold 14 Tf 1 0 0 rg") && !page0.contains("Second line"), "{page0}"); + assert!( + update_content(&mut doc, 1, 0, &Content::Text(AddedText { text: "x".into(), rect: [0.0, 0.0, 50.0, 10.0], ..AddedText::default() })).is_err(), + "kinds don't change" + ); + delete_content(&mut doc, 0, 0).unwrap(); + assert_eq!(list_added(&doc).len(), 1); + assert!(!streams(&doc, 0).join("").contains("Approved")); + assert!(add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 0.0, 100.0, 10.0], ..AddedText::default() })).is_err(), "empty text"); + // Page marks ignore added items. + assert!(marks_present(&doc).is_empty()); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 2f8324333e..90b748c536 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -120,12 +120,12 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Add content", items: &[ - item("Text", "type", "edit.text", Planned("M7")), - item("Image", "image-plus", "edit.image", Planned("M7")), + item("Text", "type", "edit.text", Ready), + item("Image", "image-plus", "edit.image", Ready), item("Header and footer", "heading", "edit.header_footer", Ready), item("Watermark", "stamp", "edit.watermark", Ready), item("Link", "link-2", "edit.link", Planned("M7")), - item("Bates numbering", "hash", "edit.bates", Planned("M7")), + item("Bates numbering", "hash", "edit.bates", Ready), item("Background", "palette", "edit.background", Ready), item("Attach file", "paperclip", "edit.attach", Planned("M12")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 194becd5a8..04bf29122b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -178,6 +178,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"), + c("edit.text", "Add text", None, None, Modification, "type"), + c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), + c("edit.image", "Add image…", None, None, Modification, "image-plus"), c("redact.mark", "Redact text and images", None, None, Modification, "rectangle-horizontal"), c("redact.pages", "Redact pages…", None, None, Modification, "file-x"), c("redact.search", "Find text and redact…", None, None, Modification, "file-search"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1b7d069e62..f4c4e188ff 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,7 +21,9 @@ pub use printcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges}; pub use printcraft_organize::LabelStyle; pub use printcraft_cos::Algorithm; -pub use printcraft_edit::{Background, HeaderFooter, MarkKind, Watermark}; +pub use printcraft_edit::{ + Added, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, +}; pub use printcraft_forms::{ Field as FormField, FieldKind as FormFieldKind, FieldProps, FieldValue, NewField, Widget as FormWidget, flags as field_flags, }; @@ -122,6 +124,8 @@ pub struct Document { pub form: Arc>, /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. pub marks: Vec, + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub added: Vec, editor: Option, config: RenderConfig, } @@ -555,6 +559,29 @@ pub enum Edit { RemoveMarks { kind: MarkKind, }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. ApplyRedactions { pages: Option>, @@ -625,6 +652,10 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(), Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(), Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(), + Edit::AddText { .. } => "Add text".into(), + Edit::AddImage { .. } => "Add image".into(), + Edit::UpdateContent { .. } => "Edit content".into(), + Edit::DeleteContent { .. } => "Delete content".into(), Edit::ApplyRedactions { .. } => "Apply redactions".into(), Edit::ClearRedactions => "Remove redaction marks".into(), Edit::RemoveHidden { .. } => "Remove hidden information".into(), @@ -730,6 +761,10 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::RemoveMarks { .. } | Edit::AddField { .. } | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::Sanitize @@ -882,6 +917,25 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - return Err(EditError::Edit(printcraft_edit::EditError::Invalid("there is nothing to remove".into()))); } } + Edit::AddText { page, text } => { + printcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = printcraft_create::image_xobject(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = printcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage { rect, image }))?; + } + Edit::UpdateContent { page, index, content } => printcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => printcraft_edit::delete_content(doc, *page, *index)?, Edit::ApplyRedactions { pages } => { printcraft_redact::apply(doc, pages.as_deref())?; } @@ -1098,6 +1152,7 @@ impl Session { }; let form = editor.as_ref().map(|e| printcraft_forms::fields(&e.cos)).unwrap_or_default(); let marks = editor.as_ref().map(|e| printcraft_edit::marks_present(&e.cos)).unwrap_or_default(); + let added = editor.as_ref().map(|e| printcraft_edit::list_added(&e.cos)).unwrap_or_default(); self.next_id += 1; let id = DocId(self.next_id); self.docs.push(Document { @@ -1114,6 +1169,7 @@ impl Session { read_only_reason, form: Arc::new(form), marks, + added, editor, config, }); @@ -1258,6 +1314,7 @@ impl Session { doc.info = info; doc.form = Arc::new(printcraft_forms::fields(&editor.cos)); doc.marks = printcraft_edit::marks_present(&editor.cos); + doc.added = printcraft_edit::list_added(&editor.cos); doc.bytes = bytes.clone(); doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone()); Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4fd3c9e5a6..0caa76c0d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -852,3 +852,31 @@ fn pages_export_as_png_jpeg_and_tiff() { let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); assert_eq!((decoded.width(), decoded.height()), (400, 600)); } + +#[test] +fn added_text_and_images_render_and_stay_editable() { + let (mut s, id) = session_with(1); + let text = AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), size: 12.0, ..AddedText::default() }; + s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text")); + assert!(page_texts(&s, id)[0].contains("Added note"), "the renderer's extractor sees page content"); + // A PNG, centred at its natural size. + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0u8; 600]).unwrap(); + } + s.apply(id, Edit::AddImage { page: 0, rect: None, name: "dot.png".into(), bytes: Arc::new(png) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.added.len(), 2); + assert_eq!(d.added[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300"); + let moved = d.added[0].content.with_rect([30.0, 100.0, 190.0, 130.0]); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: moved }).unwrap(); + assert_eq!(s.get(id).unwrap().added[0].content.rect()[3], 130.0); + s.apply(id, Edit::DeleteContent { page: 0, index: 1 }).unwrap(); + assert_eq!(s.get(id).unwrap().added.len(), 1); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().added.len(), 2); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1a62212846..1b0d890b11 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -132,6 +132,8 @@ pub struct DocView { pub forms: crate::forms_ui::FormView, /// Prepare a form: the selected field and the gesture in progress. pub prepare: crate::prepare::PrepareView, + /// Edit a PDF: the selected added item, the text being typed. + pub content: crate::content_ui::ContentView, /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. pub redact_drag: crate::redact_ui::AreaDrag, pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, @@ -209,6 +211,7 @@ impl DocView { comments: Default::default(), forms: Default::default(), prepare: Default::default(), + content: Default::default(), redact_drag: None, pending_redaction: None, crop_drag: None, @@ -733,6 +736,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize || app.dialog == Some(crate::Dialog::Print); // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = (app.left_open && app.left == crate::LeftPanel::Tool("form")) || matches!(app.quick_tool, QuickTool::Field(_)); + // Edit a PDF: added text and images can be selected, moved and edited. + let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || app.quick_tool == QuickTool::AddText; + let text_style = app.text_style.clone(); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); @@ -801,7 +807,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::Comment(t) => t.markup().is_some(), QuickTool::Select => !preparing, QuickTool::Redact => true, - QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) => false, + QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) | QuickTool::AddText => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -825,6 +831,13 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.prepare.selected = None; } + let added = doc.added.clone(); + let mut content_done = false; + if editing_content { + crate::content_ui::after_refresh(view, &added); + } else { + view.content.selected = None; + } let out = scroll.show_viewport(ui, |ui, viewport| { let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); @@ -977,7 +990,12 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { }; crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) }; - let consumed = boxing || on_field || comments::page_input(ui, &resp, &pcx, view); + let on_content = editing_content && can_modify && { + let o = crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view); + content_done |= o.done; + o.consumed || tool == QuickTool::AddText + }; + let consumed = on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); @@ -1052,6 +1070,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { crate::redact_ui::paint(ui, painter, i, view); } comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); + } if preparing { crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); } else { @@ -1192,6 +1213,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { view.pending_edit = Some(e); } + let typed_text = view.content.draft.is_some(); + if let Some(e) = crate::content_ui::editor(ui.ctx(), view, info, &added) { + view.pending_edit = Some(e); + } + content_done |= typed_text && view.content.draft.is_none(); let form_notice = view.forms.notice.take(); // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, printcraft_engine::Edit::SetPageBox { .. })); @@ -1200,6 +1226,13 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if preparing { crate::prepare::keys(ui.ctx(), view); } + if editing_content { + crate::content_ui::keys(ui.ctx(), view); + } + // One text box, then back to selecting (as Acrobat does). + if content_done && tool == QuickTool::AddText { + tool = QuickTool::Select; + } // One field, then back to selecting (Acrobat's default without "Keep tools pinned"). if field_placed { tool = QuickTool::Select; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1d0ece2726..75c71f0782 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -199,6 +199,16 @@ impl PrintCraftApp { self.marks_draft.replace = id.ends_with(".update"); self.dialog = Some(Dialog::Marks(kind)); } + "edit.bates" => { + // Bates numbering ▸ Add: the header & footer dialog with a Bates number in the + // bottom-right box (6 digits from 1), ready to edit. + self.marks_draft.replace = false; + if self.marks_draft.hf.text.iter().all(|t| !t.contains("< { use printcraft_engine::MarkKind as K; let kind = match id { @@ -210,6 +220,12 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + "edit.text" => { + self.quick_tool = crate::QuickTool::AddText; + self.left = crate::LeftPanel::Tool("edit"); + self.left_open = true; + } + "edit.image" => self.add_image_dialog(), "redact.mark" => { self.quick_tool = crate::QuickTool::Redact; self.left = crate::LeftPanel::Tool("redact"); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs new file mode 100644 index 0000000000..7893b2b53c --- /dev/null +++ b/crates/ui-egui/src/content_ui.rs @@ -0,0 +1,414 @@ +//! Edit a PDF ▸ Add content: the Text tool (click on the page and type), Image (pick a file; +//! it lands in the middle of the page), and editing what was added: select, move, resize +//! (images keep their proportions), double-click text to retype it, format it from the panel, +//! Delete to remove it. Each change is one undoable engine edit. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; +use printcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::Tokens; +use crate::widgets; + +const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +/// The text being typed or retyped in place. +#[derive(Clone, Debug, PartialEq)] +pub struct TextDraft { + pub page: usize, + /// `None` for new text; the item index for retyping. + pub index: Option, + /// Display space. + pub rect: [f64; 4], + pub text: String, + pub style: AddedText, + pub focus: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + Move, + /// Which corner: (left, top) in screen terms. + Corner(bool, bool), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ContentView { + /// The selected added item: (page, index among the page's items). + pub selected: Option<(usize, usize)>, + pub draft: Option, + /// Select the item an add creates (the page's item count before it). + pub select_added: Option<(usize, usize)>, + grab: Option<(usize, Grab, Pos2)>, +} + +/// Text style for new text (the panel's Format controls set it). +pub fn default_style() -> AddedText { + AddedText { size: 12.0, ..AddedText::default() } +} + +/// Display-space rect → screen. +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { + let ph = info.pages[page].height as f64; + xf.view_rect([r[0] as f32, (ph - r[3]) as f32, r[2] as f32, (ph - r[1]) as f32]) +} + +/// Screen point → display space. +fn to_display(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + [vx as f64, info.pages[page].height as f64 - vy as f64] +} + +fn on_page(added: &[Added], page: usize) -> Vec<(usize, &Added)> { + added.iter().filter(|a| a.page == page).enumerate().collect() +} + +fn handles(r: Rect) -> [(Pos2, bool, bool); 4] { + [(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)] +} + +/// The rect while moving or resizing (images keep their proportions). +fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { + match grab { + Grab::Move => r.translate(d), + Grab::Corner(left, top) => { + let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y }); + if keep_aspect && r.width() > 0.0 && r.height() > 0.0 { + let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05); + dx = r.width() * k - r.width(); + dy = r.height() * k - r.height(); + } + let mut out = r; + if left { + out.min.x -= dx; + } else { + out.max.x += dx; + } + if top { + out.min.y -= dy; + } else { + out.max.y += dy; + } + Rect::from_two_pos(out.min, out.max) + } + } +} + +pub(crate) struct Outcome { + pub consumed: bool, + /// A text item was placed or edited: go back to Select. + pub done: bool, +} + +/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). +#[allow(clippy::too_many_arguments)] +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + added: &[Added], + adding_text: bool, + style: &AddedText, + view: &mut DocView, +) -> Outcome { + let mut out = Outcome { consumed: false, done: false }; + let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let cv = &mut view.content; + if let Some((gp, grab, start)) = cv.grab + && gp == page + { + out.consumed = true; + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + cv.grab = None; + let end = pointer.unwrap_or(start); + if let Some((sp, si)) = cv.selected + && sp == page + && let Some((_, a)) = on_page(added, page).into_iter().find(|(i, _)| *i == si) + && (end - start).length() >= 1.0 + { + let keep = matches!(a.content, AddedContent::Image(_)); + let r = dragged(screen_rect(xf, info, page, a.content.rect()), grab, end - start, keep); + let (p0, p1) = (to_display(xf, info, page, r.left_bottom()), to_display(xf, info, page, r.right_top())); + let mut rect = [p0[0], p0[1], p1[0], p1[1]]; + if let AddedContent::Text(t) = &a.content + && grab == Grab::Move + { + // Text height follows its lines: keep the top edge's offset only. + rect = [rect[0], rect[3] - (t.rect[3] - t.rect[1]), rect[2], rect[3]]; + } + view.pending_edit = Some(Edit::UpdateContent { page, index: si, content: a.content.with_rect(rect) }); + } + } + return out; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + let items = on_page(added, page); + let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a)); + let selected_rect = cv + .selected + .filter(|s| s.0 == page) + .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) + .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); + let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); + if adding_text && hit.is_none() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + if resp.clicked() { + let at = to_display(xf, info, page, p); + let mut style = style.clone(); + style.text.clear(); + cv.draft = Some(TextDraft { + page, + index: None, + rect: [at[0], at[1] - style.size * 1.2, at[0] + 200.0, at[1]], + text: String::new(), + style, + focus: true, + }); + out.consumed = true; + } + return out; + } + if let Some((_, left, top)) = corner { + ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + } else if hit.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { + cv.grab = Some((page, Grab::Corner(left, top), origin)); + out.consumed = true; + } else if let Some((i, _)) = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(origin)) { + cv.selected = Some((page, *i)); + cv.grab = Some((page, Grab::Move, origin)); + out.consumed = true; + } + return out; + } + if resp.double_clicked() + && let Some((i, a)) = hit + && let AddedContent::Text(t) = &a.content + { + cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); + cv.selected = Some((page, i)); + out.consumed = true; + return out; + } + if resp.clicked() { + match hit { + Some((i, _)) => { + cv.selected = Some((page, i)); + out.consumed = true; + } + None if corner.is_none() => cv.selected = None, + None => out.consumed = true, + } + } + out.consumed |= hit.is_some() && resp.is_pointer_button_down_on(); + out +} + +/// Outlines of added items (hover and selection with handles) and the box being dragged. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) { + let cv = &view.content; + let pointer = ui.input(|i| i.pointer.hover_pos()); + for (i, a) in on_page(added, page) { + if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { + continue; + } + let mut r = screen_rect(xf, info, page, a.content.rect()); + let selected = cv.selected == Some((page, i)); + if selected + && let (Some((gp, g, start)), Some(p)) = (cv.grab, pointer) + && gp == page + { + r = dragged(r, g, p - start, matches!(a.content, AddedContent::Image(_))); + } + if selected { + painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Outside); + for (c, ..) in handles(r.expand(1.0)) { + painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); + } + } else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) { + painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + } +} + +/// Delete removes the selected item; Escape clears the selection. +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) { + if view.content.selected.is_none() || view.content.draft.is_some() || ctx.egui_wants_keyboard_input() { + return; + } + let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); + if del && let Some((page, index)) = view.content.selected.take() { + view.pending_edit = Some(Edit::DeleteContent { page, index }); + } else if esc { + view.content.selected = None; + } +} + +/// After an add: select the new item. +pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { + if let Some((page, before)) = view.content.select_added.take() + && on_page(added, page).len() > before + { + view.content.selected = Some((page, before)); + } + if let Some((p, i)) = view.content.selected + && on_page(added, p).len() <= i + { + view.content.selected = None; + } +} + +/// The in-place text editor. Returns the edit once committed (click away; Escape cancels). +pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> Option { + let d = view.content.draft.clone()?; + let xf = view.page_xform(d.page)?; + let r = screen_rect(&xf, info, d.page, d.rect); + let zoom = xf.rect.width() / xf.pw.max(1.0); + let (mut commit, mut cancel) = (false, false); + egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + let t = view.content.draft.as_mut().expect("checked"); + let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + let resp = ui.add( + egui::TextEdit::multiline(&mut t.text) + .font(egui::FontId::proportional((t.style.size as f32 * zoom).max(8.0))) + .desired_width(r.width().max(60.0)) + .desired_rows(1) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235)) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text("Type text") + .id_salt("added-text-edit"), + ); + if t.focus { + resp.request_focus(); + t.focus = false; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } else if resp.lost_focus() { + commit = true; + } + }); + if cancel { + view.content.draft = None; + return None; + } + if !commit { + return None; + } + let d = view.content.draft.take()?; + let text = d.text.trim_end().to_string(); + match d.index { + None if text.trim().is_empty() => None, + None => { + view.content.select_added = Some((d.page, on_page(added, d.page).len())); + Some(Edit::AddText { page: d.page, text: AddedText { rect: d.rect, text, ..d.style } }) + } + Some(i) if text.trim().is_empty() => Some(Edit::DeleteContent { page: d.page, index: i }), + Some(i) => { + let (_, a) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)?; + let AddedContent::Text(old) = &a.content else { return None }; + (old.text != text).then(|| Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(AddedText { text, ..old.clone() }) }) + } + } +} + +/// Format text (shown in the Edit panel while a text item is selected, or for new text). +/// Returns the changed style; for a selected item the caller turns it into an update. +pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { + let mut s = style.clone(); + widgets::section_title(ui, "Format text"); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + ui.selectable_value(&mut s.family, f, f.label()); + } + }); + // A list rather than a drag value: every change is an undo step. + egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { + for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { + ui.selectable_value(&mut s.size, size, format!("{size} pt")); + } + }); + }); + ui.horizontal(|ui| { + if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text("Bold").clicked() { + s.bold = !s.bold; + } + if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text("Italic").clicked() { + s.italic = !s.italic; + } + ui.separator(); + for (a, icon, tip) in [ + (TextAlign::Left, "align-left", "Align left"), + (TextAlign::Center, "align-center", "Centre"), + (TextAlign::Right, "align-right", "Align right"), + ] { + if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { + s.align = a; + } + } + }); + let c = s.color; + if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { + s.color = picked; + } + ui.label(egui::RichText::new("Standard fonts; text outside Windows-1252 isn't supported yet.").small().color(t.text_faint)); + (s != *style).then_some(s) +} + +/// How to use the tools, under the Add content list. +pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { + ui.label( + egui::RichText::new("Click on the page to add text. Drag items to move them, drag a corner to resize, double-click text to edit it.") + .small() + .color(t.text_faint), + ); + ui.add_space(4.0); +} + +impl crate::PrintCraftApp { + /// Edit a PDF ▸ Add content ▸ Image: pick a file and place it in the middle of the current page. + pub fn add_image_dialog(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let picked = match self.save_override.clone() { + Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), + // Tests and automation set `save_override` and never see a native dialog. + Some(_) => None, + None => rfd::FileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .set_title("Choose an image") + .pick_file(), + }; + let Some(path) = picked else { return }; + match std::fs::read(&path) { + Ok(bytes) => { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.add_image(name, bytes); + } + Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + } + } + #[cfg(target_arch = "wasm32")] + self.notify("Adding images arrives on the web with file pickers for images"); + } + + /// Place image `bytes` in the middle of the current page and select it. + pub fn add_image(&mut self, name: String, bytes: Vec) { + let Some((i, id)) = self.active_ids() else { return }; + let page = self.views[i].current; + let before = self.session.get(id).map_or(0, |d| d.added.iter().filter(|a| a.page == page).count()); + if self.apply_edit(Edit::AddImage { page, rect: None, name, bytes: std::sync::Arc::new(bytes) }) { + self.views[i].content.select_added = Some((page, before)); + self.left = crate::LeftPanel::Tool("edit"); + self.left_open = true; + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 13b22a062f..be0703b3df 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -539,6 +539,7 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Hand => "hand".to_string(), crate::QuickTool::Crop => "crop".to_string(), crate::QuickTool::Redact => "redact".to_string(), + crate::QuickTool::AddText => "add-text".to_string(), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(), diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index dd69a0648c..23e0d3f425 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -3,7 +3,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("accessibility", include_bytes!("../../../assets/icons/accessibility.svg")), + ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), + ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), ("arrow-up-down", include_bytes!("../../../assets/icons/arrow-up-down.svg")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9fb0b26fdf..94a094790b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ mod marks_ui; pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; } +mod content_ui; mod dialogs; mod editing; mod files; @@ -92,6 +93,8 @@ pub enum QuickTool { Field(prepare::FieldTool), /// Redact text and images (drag across text or draw a box). Redact, + /// Edit a PDF ▸ Add content ▸ Text. + AddText, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -237,6 +240,8 @@ pub struct PrintCraftApp { pub redact_search: RedactSearchDraft, pub hidden_draft: HiddenDraft, pub print_draft: PrintDraft, + /// The style new text gets (Edit a PDF ▸ Format text). + pub text_style: printcraft_engine::AddedText, /// The Replace Pages dialog's state. pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). @@ -315,6 +320,7 @@ impl PrintCraftApp { redact_search: RedactSearchDraft::default(), hidden_draft: HiddenDraft::default(), print_draft: PrintDraft::default(), + text_style: content_ui::default_style(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } @@ -691,6 +697,7 @@ impl PrintCraftApp { "hand" => QuickTool::Hand, "crop" => QuickTool::Crop, "redact" => QuickTool::Redact, + "add-text" => QuickTool::AddText, field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 3cf02ae4a5..ade753bd90 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -125,6 +125,10 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat ); }); } + // Edit a PDF shows Format text at the top while text is selected or being added. + if g.id == "edit" { + format_section(app, ui, t); + } let mut run = None; // Redact a PDF has Acrobat's footer: Clear all / Redact all. let footer = g.id == "redact"; @@ -186,6 +190,37 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat } } +/// Edit a PDF ▸ Format text: for the selected added text (one undoable change), or the style +/// new text gets. +fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) { + let selected = app.active_ids().and_then(|(i, id)| { + let (page, index) = app.views[i].content.selected?; + let doc = app.session.get(id)?; + let a = doc.added.iter().filter(|a| a.page == page).nth(index)?; + match &a.content { + printcraft_engine::AddedContent::Text(text) => Some((page, index, text.clone())), + _ => None, + } + }); + match selected { + Some((page, index, text)) => { + if let Some(style) = crate::content_ui::format_panel(ui, t, &text) { + app.text_style = printcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() }; + app.apply_edit(printcraft_engine::Edit::UpdateContent { page, index, content: printcraft_engine::AddedContent::Text(style) }); + } + } + None if app.quick_tool == crate::QuickTool::AddText => { + crate::content_ui::hint(ui, t); + if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) { + app.text_style = style; + } + } + None => return, + } + ui.add_space(6.0); + ui.separator(); +} + // ───────────────────────────────────────────────────────────────────────────── right panels pub(crate) enum Nav { diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs new file mode 100644 index 0000000000..e5b933ae49 --- /dev/null +++ b/crates/ui-egui/tests/content.rs @@ -0,0 +1,95 @@ +//! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it, +//! restyle it from the panel, add an image, delete with the keyboard. + +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{PrintCraftApp, QuickTool}; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h +} + +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, y * k) +} + +fn click(h: &mut Harness<'static, PrintCraftApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +fn added(h: &Harness<'static, PrintCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().added.clone() +} + +#[test] +fn typing_moving_styling_and_deleting_added_content() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + h.get_by_label("Edit a PDF"); + let p = at(&h, 40.0, 300.0); + click(&mut h, p); + assert!(h.state().views[0].content.draft.is_some(), "the editor opened"); + h.event(egui::Event::Text("Reviewed".into())); + h.run_steps(2); + // Click elsewhere on the page commits and goes back to Select. + let p = at(&h, 250.0, 380.0); + click(&mut h, p); + let a = added(&h); + assert_eq!(a.len(), 1); + let printcraft_engine::AddedContent::Text(t) = &a[0].content else { panic!() }; + assert_eq!(t.text, "Reviewed"); + assert_eq!(h.state().quick_tool, QuickTool::Select); + h.run_steps(2); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected"); + // Bold from the Format panel: one undoable change. + h.get_by_label("B").click(); + h.run_steps(3); + + let printcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() }; + assert!(t.bold); + { + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit content")); + } + // Delete. + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert!(added(&h).is_empty()); + // An image lands in the middle of the page, selected. + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 30, 20); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[90u8; 1800]).unwrap(); + } + h.state_mut().add_image("logo.png".into(), png); + h.run_steps(3); + let a = added(&h); + assert_eq!(a.len(), 1); + assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]); + assert_eq!(h.state().views[0].content.selected, Some((0, 0))); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6fe8a87ffa..0020067bcd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1827,11 +1827,11 @@ title = "Change font, size and colour" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.text"] +tools = ["content_update"] +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable"] +notes = "for added text: family, size, bold, italic, colour; existing text needs the M7 editor" [[feature]] id = "edit.text-format-style" @@ -1863,11 +1863,11 @@ title = "Paragraph alignment (left, centre, right, justify)" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.text"] +tools = ["content_update"] +evidence = ["crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable"] +notes = "left/centre/right for added text" [[feature]] id = "edit.line-paragraph-spacing" @@ -1911,11 +1911,11 @@ title = "Add a new text box as page content" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.text"] +tools = ["page_add_text", "content_update"] +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text" [[feature]] id = "edit.find-replace" @@ -1971,11 +1971,11 @@ title = "Add an image" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.image"] +tools = ["page_add_image"] +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect" [[feature]] id = "edit.image-replace" @@ -2019,11 +2019,11 @@ title = "Move and resize images" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.image"] +tools = ["content_update"] +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +notes = "images added in PrintCraft (aspect kept on corner drags); existing page images need the M7 content editor" [[feature]] id = "edit.image-extract" @@ -2428,10 +2428,10 @@ area = "C" tier = "P1" milestone = "M7" status = "partial" -commands = ["edit.header_footer"] +commands = ["edit.bates", "edit.header_footer"] tools = ["doc_header_footer"] evidence = ["crates/edit/src/tests.rs::tokens_expand"] -notes = "Bates tokens in headers and footers within one document; no multi-file batch yet" +notes = "Bates ▸ Add opens Header & Footer with a Bates number; no multi-file batch yet" [[feature]] id = "edit.bates-multi-file"