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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 23:41:59 -04:00
parent 245c468bc7
commit 4cdf74a4f2
15 changed files with 336 additions and 20 deletions

1
Cargo.lock generated
View File

@ -3100,6 +3100,7 @@ name = "printcraft-edit"
version = "0.1.0"
dependencies = [
"hayro-syntax",
"printcraft-annot",
"printcraft-cos",
"printcraft-fonts",
"printcraft-model",

View File

@ -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();

View File

@ -390,6 +390,9 @@ pub fn tools() -> Vec<ToolDef> {
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.")

View File

@ -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(_))));
}

View File

@ -16,3 +16,4 @@ thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
printcraft-annot = { workspace = true }

179
crates/edit/src/flatten.rs Normal file
View File

@ -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<Vec<f64>> {
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<ObjRef> {
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<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let mut drawn = 0;
let mut removed_widgets: HashSet<ObjRef> = 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<ObjRef> = HashSet::new();
let mut gone_inline: HashSet<usize> = 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<Object> = 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<ObjRef>) -> 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<ObjRef>, 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<Object> = 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<Object> = 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(())
}

View File

@ -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<u8>, 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<u8>, 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<MarkKind> {
found
}
mod flatten;
pub use flatten::flatten;
#[cfg(test)]
mod tests;

View File

@ -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");
}

View File

@ -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",

View File

@ -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<DocId>)
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()),

View File

@ -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::<Vec<_>>(), *comments, *fields)?;
}
Edit::Protect(p) => {
p.validate()?;
let seed = cx.entropy();

View File

@ -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);
}

View File

@ -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;

View File

@ -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));

View File

@ -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"