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:
parent
245c468bc7
commit
4cdf74a4f2
|
|
@ -3100,6 +3100,7 @@ name = "printcraft-edit"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"printcraft-annot",
|
||||
"printcraft-cos",
|
||||
"printcraft-fonts",
|
||||
"printcraft-model",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ thiserror = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
printcraft-annot = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue