From 4cdf74a4f27d4dc30f2fabf194735393926a6ea9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:41:59 -0400 Subject: [PATCH] M5.5/M6.7: Flatten comments and form fields: appearances (with /AS states) drawn into page content by Algorithm 8.1, pop-ups and replies removed, flattened widgets pruned from the field tree, links and hidden annotations kept; engine Flatten edit, comment.flatten/form.flatten commands, doc_flatten tool (50 tools) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/automation/src/lib.rs | 7 + crates/automation/src/tools.rs | 3 + crates/automation/tests/automation.rs | 12 ++ crates/edit/Cargo.toml | 1 + crates/edit/src/flatten.rs | 179 ++++++++++++++++++++++++++ crates/edit/src/lib.rs | 10 +- crates/edit/src/tests.rs | 32 +++++ crates/engine/src/catalog.rs | 14 +- crates/engine/src/commands.rs | 8 ++ crates/engine/src/lib.rs | 19 ++- crates/engine/src/tests.rs | 16 +++ crates/ui-egui/src/commands.rs | 11 ++ crates/ui-egui/tests/commands.rs | 13 +- parity/acrobat-features.toml | 30 ++--- 15 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 crates/edit/src/flatten.rs diff --git a/Cargo.lock b/Cargo.lock index 051ced87..4710c264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3100,6 +3100,7 @@ name = "printcraft-edit" version = "0.1.0" dependencies = [ "hayro-syntax", + "printcraft-annot", "printcraft-cos", "printcraft-fonts", "printcraft-model", diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6ae08a88..87a0714c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -217,6 +217,13 @@ impl Automation { } "page_set_box" => self.page_set_box(&a)?, "doc_create" => self.doc_create(&a)?, + "doc_flatten" => { + let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); + if !comments && !fields { + return Err(ToolError::InvalidArgs("nothing to flatten".into())); + } + self.apply(&a, Edit::Flatten { comments, fields })? + } "doc_reduce" => { let id = self.doc(&a)?.id; let before = self.doc(&a)?.bytes.len(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 63f48b5a..21fbc555 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -390,6 +390,9 @@ pub fn tools() -> Vec { t("doc_reduce", "Reduce file size", "Write a compacted copy of the document to `path` (identical fonts and images merged, unused objects dropped, compressed object streams). The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), + t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.") + .destructive() + .with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2e998d92..da0853cd 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -562,3 +562,15 @@ fn creating_and_reducing_through_tools() { assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists()); assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } + +#[test] +fn flattening_through_tools() { + let dir = workdir("flatten"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Approved" })); + ok(&mut a, "doc_flatten", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); + assert!(page_text(&mut a, doc)[0].contains("Approved"), "the text box is now page text"); + assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 0a11de9c..fb95f9ac 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -16,3 +16,4 @@ thiserror = { workspace = true } [dev-dependencies] hayro-syntax = { workspace = true } +printcraft-annot = { workspace = true } diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs new file mode 100644 index 00000000..d579d676 --- /dev/null +++ b/crates/edit/src/flatten.rs @@ -0,0 +1,179 @@ +//! Flatten (Acrobat: Fill & Sign ▸ flatten, Print production ▸ Flattener, Prepare form ▸ +//! Flatten): annotation appearances become ordinary page content and the annotations go away. +//! +//! Each visible annotation's normal appearance (for check boxes and radio buttons, the one its +//! `/AS` selects) is drawn into the page with the matrix of ISO 32000-2 §12.5.5 (Algorithm 8.1: +//! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden +//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened +//! comments take their pop-ups and replies with them; flattened widgets are removed from the +//! form's field tree. + +use std::collections::HashSet; + +use printcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::{EditError, check, n, page_list, place_tagged}; + +const HIDDEN: i64 = 2; +const NO_VIEW: i64 = 32; + +fn nums(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +/// The appearance stream to draw for an annotation, if it has one. +fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let n = ap.as_dict()?.get(b"N")?.clone(); + match &n { + Object::Ref(r) if matches!(&*doc.get(*r), Object::Stream(_)) => Some(*r), + _ => { + let states = doc.resolve(&n); + let state = d.name(b"AS")?; + states.as_dict()?.get(state)?.as_ref().filter(|r| matches!(&*doc.get(*r), Object::Stream(_))) + } + } +} + +/// Algorithm 8.1: the matrix that maps the form's bounding box onto the annotation rectangle. +fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { + let bbox = nums(doc, form.get(b"BBox")).filter(|b| b.len() == 4)?; + let m = nums(doc, form.get(b"Matrix")).filter(|m| m.len() == 6).unwrap_or_else(|| vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + let pts = [(bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[0], bbox[3]), (bbox[2], bbox[3])] + .map(|(x, y)| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])); + let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.0), b.max(p.0))); + let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.1), b.max(p.1))); + if x1 - x0 < 1e-9 || y1 - y0 < 1e-9 { + return None; // empty appearance (e.g. replies): nothing to draw + } + let (sx, sy) = ((rect[2] - rect[0]) / (x1 - x0), (rect[3] - rect[1]) / (y1 - y0)); + Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy]) +} + +/// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged +/// into page content. +pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut drawn = 0; + let mut removed_widgets: HashSet = HashSet::new(); + for &i in pages { + let page = all[i].clone(); + let annots_obj = page.dict.get(b"Annots").cloned(); + let Some(list) = annots_obj.as_ref().map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; + let mut content = String::new(); + let mut xobjects = Dict::new(); + let mut gone: HashSet = HashSet::new(); + let mut gone_inline: HashSet = HashSet::new(); + for (k, entry) in list.iter().enumerate() { + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or_default(); + let widget = subtype == b"Widget"; + if matches!(subtype, b"Link" | b"Popup") || (widget && !fields) || (!widget && !comments) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 { + continue; + } + let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); + if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) { + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + if let Some(m) = placement(doc, &form, rect) { + let name = format!("PCFl{drawn}"); + content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5]))); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + drawn += 1; + } + } + match entry.as_ref() { + Some(r) => { + gone.insert(r); + if widget { + removed_widgets.insert(r); + } + } + None => { + gone_inline.insert(k); + } + } + } + if gone.is_empty() && gone_inline.is_empty() { + continue; + } + // Pop-ups and replies of what was flattened go too. + for entry in &list { + let Some(r) = entry.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| gone.contains(&t)); + if points_at(b"Parent") && d.name(b"Subtype") == Some(b"Popup") || points_at(b"IRT") { + gone.insert(r); + } + } + let kept: Vec = list + .iter() + .enumerate() + .filter(|(k, e)| !gone_inline.contains(k) && !e.as_ref().is_some_and(|r| gone.contains(&r))) + .map(|(_, e)| e.clone()) + .collect(); + if !content.is_empty() { + // The page's own resources, with the appearances as XObjects. + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + for (k, v) in xobjects.iter() { + xo.set(k.clone(), v.clone()); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + let page = page_list(doc)[i].clone(); + place_tagged(doc, &page, "Flattened", content.into_bytes(), false)?; + } + let page_now = page_list(doc)[i].clone(); + match page_now.dict.get(b"Annots").and_then(|a| a.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { + Some(r) => doc.set(r, Object::Array(kept)), + None => doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?, + } + } + if !removed_widgets.is_empty() { + prune_fields(doc, &removed_widgets)?; + } + Ok(drawn) +} + +/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. +fn prune_fields(doc: &mut Document, widgets: &HashSet) -> Result<(), EditError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let af_ref = af.as_ref(); + let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); + let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + // Returns whether the node survives. + fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, depth: usize) -> bool { + if widgets.contains(&r) || depth > 64 { + return depth > 64; + } + let Some(d) = doc.get(r).as_dict().cloned() else { return true }; + let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true }; + let survivors: Vec = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect(); + if survivors.len() != kids.len() { + let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); + } + !survivors.is_empty() + } + let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect(); + af_dict.set(b"Fields".to_vec(), Object::Array(survivors)); + match af_ref { + Some(r) => doc.set(r, Object::Dict(af_dict)), + None => doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af_dict)))?, + } + Ok(()) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index eede695d..e9a66319 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -253,8 +253,13 @@ fn tagged(tag: &str) -> Dict { /// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the /// original content in q/Q). fn place(doc: &mut Document, page: &printcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { + place_tagged(doc, page, kind.tag(), content, behind) +} + +/// Put content on a page, tagged `tag` (see [`place`]). +fn place_tagged(doc: &mut Document, page: &printcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { let mut list = contents(doc, page)?; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(kind.tag()), &content)))); + let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); if behind { list.insert(0, mark); } else { @@ -481,5 +486,8 @@ pub fn marks_present(doc: &Document) -> Vec { found } +mod flatten; +pub use flatten::flatten; + #[cfg(test)] mod tests; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 205766fa..65febcbc 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -139,3 +139,35 @@ fn invalid_requests_change_nothing() { assert_eq!(add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0 }, false), Err(EditError::NoSuchPage(9))); assert!(!doc.is_modified()); } + +#[test] +fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { + use printcraft_annot::{Meta, NewAnnotation, NoteIcon, Shape, Style, add_annotation, add_reply}; + let mut doc = fixture(); + let meta = Meta { date: None, id: "x".into() }; + let add = |doc: &mut Document, shape: Shape| { + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: "c".into(), author: "a".into() }, &meta).unwrap() + }; + add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }); + let note = add(&mut doc, Shape::Note { at: [200.0, 700.0], icon: NoteIcon::Comment }); + add_reply(&mut doc, 0, note, "reply", "b", &meta).unwrap(); + let before = streams(&doc, 0); + let n = flatten(&mut doc, &[0], true, false).unwrap(); + assert_eq!(n, 2, "the rectangle and the note icon are drawn; the reply has nothing to draw"); + let doc = reopen(&doc); + let p = &printcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "comments, pop-up and reply are gone"); + let s = streams(&doc, 0); + assert_eq!(s.len(), before.len() + 3, "wrapped original + flattened content: {s:?}"); + let flat = s.last().unwrap(); + assert!(flat.contains("/PCFl0 Do") && flat.contains("/PCFl1 Do"), "{flat}"); + // The rectangle's appearance is drawn at its rectangle (bbox = rect, identity mapping). + assert!(flat.contains("q 1 0 0 1 0 0 cm /PCFl0 Do Q"), "{flat}"); + // The note's 20×20 icon box is mapped onto its rect at (200, 680). + assert!(flat.contains("1 0 0 1 200 680 cm /PCFl1 Do"), "{flat}"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + assert!(xo.as_dict().unwrap().contains(b"PCFl1")); + assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1f30dfe3..2efd3a5a 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -221,7 +221,13 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Stamp", "stamp", "comment.stamp", Planned("M5")), ], }, - ToolSection { title: "Review", items: &[item("Comment list", "message-square-text", "comment.list", Ready)] }, + ToolSection { + title: "Review", + items: &[ + item("Comment list", "message-square-text", "comment.list", Ready), + item("Flatten comments", "layers", "comment.flatten", Ready), + ], + }, ], }, ToolGroup { @@ -309,7 +315,11 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ sections: &[ ToolSection { title: "Fields", - items: &[item("Field list", "list", "form.fields", Ready), item("Clear form", "eraser", "form.clear", Ready)], + items: &[ + item("Field list", "list", "form.fields", Ready), + item("Clear form", "eraser", "form.clear", Ready), + item("Flatten form fields", "layers", "form.flatten", Ready), + ], }, ToolSection { title: "Add form components", diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c95d19d1..b2875118 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -26,6 +26,10 @@ pub enum Needs { Annotate, /// The document has form fields and its security allows filling them in. FillForms, + /// The document has comments and allows changes. + HasComments, + /// The document has form fields and allows changes. + HasFields, /// The document has page marks of this kind and allows changes. Marks(crate::MarkKind), /// The document's security may be changed (owner, or nothing restricted). @@ -168,6 +172,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.circle", "Draw an oval", None, None, Annotate, "circle"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), + c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), + c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), c("sign.fill.text", "Fill & Sign: add text", None, None, Annotate, "type"), c("sign.fill.check", "Fill & Sign: checkmark", None, None, Annotate, "check"), c("sign.fill.cross", "Fill & Sign: cross", None, None, Annotate, "x"), @@ -231,6 +237,8 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Annotate => doc.is_some_and(|d| d.allows_annotation()), FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()), Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)), + HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()), + HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()), Security => doc.is_some_and(|d| d.allows_security_change()), ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()), Undo => doc.is_some_and(|d| d.can_undo().is_some()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 61bb90eb..8dbae118 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -510,6 +510,11 @@ pub enum Edit { RemoveMarks { kind: MarkKind, }, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, /// Protect with passwords and permissions (written by the next save, which is a full rewrite). Protect(Protection), /// Remove password security (needs the owner password). @@ -559,6 +564,9 @@ 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::Flatten { comments: true, fields: false } => "Flatten comments".into(), + Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), + Edit::Flatten { .. } => "Flatten".into(), Edit::Protect(_) => "Protect with password".into(), Edit::RemoveProtection => "Remove security".into(), Edit::Batch { label, .. } => label.clone(), @@ -648,7 +656,12 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), Err(EditError::NotPermitted("changing security")) } } - Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } => { + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::Flatten { .. } => { if p.modify() { Ok(()) } else { @@ -784,6 +797,10 @@ 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::Flatten { comments, fields } => { + let n = printcraft_model::pages(doc).len(); + printcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + } Edit::Protect(p) => { p.validate()?; let seed = cx.entropy(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de3bc41b..70d76760 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -738,3 +738,19 @@ fn create_and_reduce() { let r = s3.open("r.pdf", None, reduced, None).unwrap(); assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); } + +#[test] +fn flattening_keeps_the_look_and_drops_the_objects() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); + s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); + let red_before = pixel(&s, id, 0, 100, 100); + s.apply(id, Edit::Flatten { comments: true, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.info.annotations.is_empty() && d.form.is_empty(), "no comments or fields remain"); + assert_eq!(pixel(&s, id, 0, 100, 100), red_before, "the rectangle is now page content"); + assert_eq!(page_texts(&s, id), ["Ada"], "the field's text is now page content"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.len(), 2); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 6f957719..a08e4819 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -23,6 +23,8 @@ impl PrintCraftApp { commands::Needs::Undo => "Nothing to undo".to_string(), commands::Needs::Redo => "Nothing to redo".to_string(), commands::Needs::FillForms if self.active.is_some() => "This document has no form fields you can fill in".to_string(), + commands::Needs::HasComments if self.active.is_some() => "This document has no comments to flatten".to_string(), + commands::Needs::HasFields if self.active.is_some() => "This document has no form fields to flatten".to_string(), commands::Needs::Marks(k) if self.active.is_some() => format!( "This document has no {} to change", match k { @@ -138,6 +140,15 @@ impl PrintCraftApp { } } "form.fields" => self.right = Some(RightPanel::Fields), + "comment.flatten" => { + self.apply_edit(Edit::Flatten { comments: true, fields: false }); + } + "form.flatten" => { + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.apply_edit(Edit::Flatten { comments: false, fields: true }); + } "form.clear" => { if let Some(i) = active { self.views[i].forms.focus = None; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index a418ecef..37a24376 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -52,7 +52,7 @@ fn every_registered_command_is_implemented() { continue; } let mut app = PrintCraftApp::new(); - if spec.id.starts_with("form.") { + if spec.id.starts_with("form.") || spec.id == "comment.flatten" { app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); } else { app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); @@ -70,6 +70,17 @@ fn every_registered_command_is_implemented() { app.apply_edit(Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "x".into(), ..Watermark::default() }, replace: false }); app.apply_edit(Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0; 3], opacity: 1.0 }, replace: false }); } + if spec.id == "comment.flatten" { + use printcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let shape = Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; + app.apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: String::new(), + })); + } if spec.id == "protect.remove" { let p = printcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() }; app.apply_edit(printcraft_engine::Edit::Protect(p)); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 678a6547..149a4856 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4091,11 +4091,11 @@ title = "Flatten comments into page content" area = "E" tier = "P1" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.flatten"] +tools = ["doc_flatten"] +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "appearances become page content; pop-ups and replies removed" [[feature]] id = "comment.migrate" @@ -4611,11 +4611,11 @@ title = "Flatten form fields" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.flatten"] +tools = ["doc_flatten"] +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "widgets drawn into content and pruned from the field tree" [[feature]] id = "form.prepare-form-mode" @@ -5187,11 +5187,11 @@ title = "Fill & Sign: flatten on save" area = "F" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["comment.flatten"] +tools = ["doc_flatten"] +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "Flatten comments flattens Fill & Sign items; no flatten-on-save option yet" [[feature]] id = "form.autofill-profile"