From 56b37380890d5b1cfebd5aa85873be2a3c8c083d Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 20:06:39 +0800 Subject: [PATCH] M5: FDF carries a stamp's appearance only, and the intent goes out as intent and IT (comment.export-fdf-xfdf, comment.import-fdf-xfdf) FDF export now leaves /AP out of every comment but a stamp, as XFDF 3.0 gives to stamps only: a text box, square or line carries its formatting entries (/DA, /DS, /RC, /Q, /Rotate, /BS, /RD) and is drawn where it is imported. FDF import still keeps any /AP the file carries. XFDF export writes a comment's /IT under both spellings XFDF files use, `intent` and `IT`, and import reads either, so a typewriter or callout text box from a file that writes `IT` keeps its intent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- ROADMAP.md | 2 +- crates/xfdf/README.md | 7 +++--- crates/xfdf/src/fdf.rs | 10 +++++--- crates/xfdf/src/tests_format.rs | 44 +++++++++++++++++++++++++++++++++ crates/xfdf/src/xfdf.rs | 9 ++++--- parity/acrobat-features.toml | 8 +++--- 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3270d42bdb..83ff9112e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -230,7 +230,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log -- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export and import keep each comment's /AP with its forms and images (ISO 32000-2 §12.7.8.3.4). An unreadable appearance is recorded in the import report and the comment is drawn by PdfCraft; a redaction mark is always drawn by PdfCraft. +- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable appearance is recorded in the import report and the comment is drawn by PdfCraft; a redaction mark is always drawn by PdfCraft. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index b5a6d8d65e..7ac67fd0f0 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -21,6 +21,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o `left`/`centered`/`right`, redaction `0`–`2`), `fringe` (`/RD`) on every type, line leaders and captions (`leaderLength`, `leaderExtend`, `leader-offset`, `caption`, `caption-style`, `caption-offset-h`/`-v`), the caret `symbol` and redaction `overlay-text`/`overlay-text-repeat`. + The intent (`/IT`) is written as both `intent` and `IT`, and either is read. - **Appearances** (``, `appearance.rs`): a stamp's `/AP`, as XFDF 3.0 defines it for stamps, base 64 of the dictionary written as `DICT`/`STREAM`/`ARRAY`/`NAME`/`INT`/`FIXED`/`BOOL`/ `STRING`/`NULL` elements with stream data in `DATA` (XFDF 3.0 "Stream encoding": `filtered`/`ascii` @@ -30,9 +31,9 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o `Report::skipped`; it refuses one over 16 MiB, 64 MiB per import, 64 nesting levels or 100,000 values. Export leaves out one past those limits or whose rect has no area. - **FDF**: fields (with `/Kids` hierarchies) and indirect comments. Nested annotation values - are copied under FDF object numbers; shared references and cycles remain shared. Appearances - (`/AP`, with the forms and images they draw) are copied and come back on import as objects of - the document (a stream shared by several comments once); document links are excluded. Source `/Page` and `/Pages` dictionaries become null + are copied under FDF object numbers; shared references and cycles remain shared. A stamp's appearance + (`/AP`, with the forms and images it draws) is copied, as in XFDF, and an `/AP` in the file comes + back on import as objects of the document (a stream shared by several comments once); document links are excluded. Source `/Page` and `/Pages` dictionaries become null at every copy depth, so nested destination actions cannot carry the source page tree or content. Type names may be indirect; lookup checks each reference generation, is iterative, stops at cycles and refuses chains beyond diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs index 42d0d28ecc..7f60907471 100644 --- a/crates/xfdf/src/fdf.rs +++ b/crates/xfdf/src/fdf.rs @@ -86,14 +86,16 @@ impl<'a> FdfCopy<'a> { Ok(()) } - /// Skip source-document links without cloning their values. The appearance (`/AP`) is - /// copied with its streams and resources, so the comment looks the same where it is imported. + /// Skip source-document links without cloning their values. A stamp's appearance (`/AP`) is + /// copied with its streams and resources, so the stamp looks the same where it is imported; + /// other comments carry their formatting entries and leave the appearance out, as XFDF does. /// The dictionary and every retained entry are charged before any owned copy is made. fn annotation_dict(&mut self, dict: &Dict) -> Result { self.charge_value(0)?; + let stamp = dict.name(b"Subtype") == Some(b"Stamp".as_slice()); let mut out = Dict::new(); for (key, value) in dict.iter() { - if matches!(key.as_slice(), b"P" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") { + if matches!(key.as_slice(), b"P" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") || (key.as_slice() == b"AP" && !stamp) { continue; } self.dict_entry(&mut out, key, value, 1)?; @@ -203,7 +205,7 @@ impl<'a> FdfCopy<'a> { } } -/// FDF with field values and/or comments, each with its appearance. Each comment is an +/// FDF with field values and/or comments, a stamp with its appearance. Each comment is an /// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there /// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. /// Source `/Page` and `/Pages` dictionaries become null at every copy depth. diff --git a/crates/xfdf/src/tests_format.rs b/crates/xfdf/src/tests_format.rs index da0aa932f3..28fbfbde06 100644 --- a/crates/xfdf/src/tests_format.rs +++ b/crates/xfdf/src/tests_format.rs @@ -396,6 +396,50 @@ fn file_export_carries_only_stamp_appearances_and_copies_carry_every_one() { assert_eq!(f64s(&dst, d.get(b"Rect")), vec![110.0, 490.0, 310.0, 590.0]); } +#[test] +fn fdf_carries_only_stamp_appearances() { + let mut src = pages(1); + image_stamp(&mut src, "sq", "Square"); + let mut ft = formatted_freetext(); + ft.set(b"NM".to_vec(), PdfString::text("box")); + let ft = put(&mut src, ft); + let n = sub_dict(&src, &named(&src, "sq"), b"AP"); + src.update_dict(ft, |d| d.set(b"AP".to_vec(), Object::Dict(n.clone()))).unwrap(); + let fdf = export_fdf(&src, true, false, "src.pdf").unwrap(); + assert!(!fdf.windows(3).any(|w| w == b"/AP"), "a text box and a square carry their formatting, not their appearance"); + let mut dst = pages(1); + import(&mut dst, &fdf).unwrap(); + let ft = named(&dst, "box"); + assert_eq!((ft.int(b"Q"), ft.int(b"Rotate")), (Some(2), Some(90))); + assert_eq!(text(&dst, &ft, b"DS").as_deref(), text(&src, &formatted_freetext(), b"DS").as_deref()); + let mut stamp = pages(1); + image_stamp(&mut stamp, "st", "Stamp"); + assert!(export_fdf(&stamp, true, false, "src.pdf").unwrap().windows(3).any(|w| w == b"/AP")); +} + +/// The intent (/IT) goes out under both spellings XFDF files use, and either comes back. +#[test] +fn the_intent_is_written_and_read_as_intent_or_it() { + let mut src = pages(1); + let mut d = annot("FreeText", "tw", [10.0, 10.0, 90.0, 30.0]); + d.set(b"IT".to_vec(), Object::name("FreeTextTypewriter")); + put(&mut src, d); + let xml = export_xfdf(&src, true, false, "src.pdf"); + assert!(xml.contains(r#"intent="FreeTextTypewriter" IT="FreeTextTypewriter""#), "{xml}"); + let only_it = r#" + + /Helvetica 9 Tffont: Helvetica ,sans-serif 9.00pt;color:#FF0000 + +"#; + let mut dst = pages(1); + import(&mut dst, only_it.as_bytes()).unwrap(); + let a = named(&dst, "a"); + assert_eq!(a.name(b"IT"), Some(b"FreeTextTypewriter".as_slice())); + assert_eq!((a.int(b"Q"), a.int(b"Rotate"), text(&dst, &a, b"Subj").as_deref()), (Some(0), Some(0), Some("Typewriter"))); + assert_eq!(f64s(&dst, a.get(b"Rect")), vec![71.0004, 737.255, 138.977, 747.299]); + assert_eq!(named(&dst, "b").name(b"IT"), Some(b"FreeTextCallout".as_slice())); +} + #[test] fn a_stamp_without_area_exports_no_appearance() { let mut src = pages(1); diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index 1988786a37..27d70d7d17 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -496,8 +496,9 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u } } // What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow. + // Written under both spellings XFDF files use for it, `intent` and `IT`. if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + let _ = write!(attrs, " intent=\"{0}\" IT=\"{0}\"", esc(&it)); } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); @@ -902,12 +903,14 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } - if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { + // The intent (/IT), spelt `intent` or `IT`. + let intent = node.attribute("intent").filter(|it| !it.is_empty()).or_else(|| node.attribute("IT").filter(|it| !it.is_empty())); + if let Some(it) = intent { d.set(b"IT".to_vec(), Object::name(it)); } if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { d.set(b"CL".to_vec(), arr(&cl)); - d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); + d.set(b"IT".to_vec(), Object::name(intent.unwrap_or("FreeTextCallout"))); // A callout's /LE is one name, not the head/tail pair of a line. match node.attribute("head") { Some(h) => d.set(b"LE".to_vec(), Object::name(h)), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0db748af2b..15de1b8b81 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4334,8 +4334,8 @@ milestone = "M5" status = "shipped" commands = ["comment.import"] tools = ["doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests_format.rs::formatting_written_by_another_tool_is_imported", "crates/xfdf/src/tests_format.rs::an_appearance_written_by_another_tool_is_imported", "crates/xfdf/src/tests_format.rs::unreadable_appearances_are_skipped_recorded_and_drawn_again", "crates/xfdf/src/tests_format.rs::fdf_carries_appearances_with_their_resources"] -notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces. XFDF formatting comes in as XFDF 3.0 names it: contents-richtext (/RC; plain text in it, or markup without , also gives /Contents), defaultstyle, width/style/dashes (a dashed border stays dashed), cloudy style and intensity, rotation, justification, fringe, leaderLength/leaderExtend/leader-offset, caption attributes, caret symbol, redaction overlay text. A stamp's (base 64 of the DICT/STREAM/DATA appearance XML other tools write) is kept, as are FDF /AP streams with their resources; an unreadable or oversized one (16 MiB each, 64 MiB per import, 64 levels, 100,000 values) is recorded in the import report and the comment is drawn by PdfCraft. A redaction mark is always drawn by PdfCraft. Rich text is kept as data; When PdfCraft draws a text box itself (no appearance in the file), it draws the plain text with /DA, not the rich text's fonts." +evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests_format.rs::formatting_written_by_another_tool_is_imported", "crates/xfdf/src/tests_format.rs::an_appearance_written_by_another_tool_is_imported", "crates/xfdf/src/tests_format.rs::unreadable_appearances_are_skipped_recorded_and_drawn_again", "crates/xfdf/src/tests_format.rs::fdf_carries_appearances_with_their_resources", "crates/xfdf/src/tests_format.rs::the_intent_is_written_and_read_as_intent_or_it"] +notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces. XFDF formatting comes in as XFDF 3.0 names it: contents-richtext (/RC; plain text in it, or markup without , also gives /Contents), defaultstyle, width/style/dashes (a dashed border stays dashed), cloudy style and intensity, rotation, justification, fringe, leaderLength/leaderExtend/leader-offset, caption attributes, caret symbol, redaction overlay text, the intent (`intent` or `IT`). A stamp's (base 64 of the DICT/STREAM/DATA appearance XML other tools write) is kept, as are FDF /AP streams with their resources; an unreadable or oversized one (16 MiB each, 64 MiB per import, 64 levels, 100,000 values) is recorded in the import report and the comment is drawn by PdfCraft. A redaction mark is always drawn by PdfCraft. Rich text is kept as data; When PdfCraft draws a text box itself (no appearance in the file), it draws the plain text with /DA, not the rich text's fonts." [[feature]] id = "comment.export-fdf-xfdf" @@ -4346,8 +4346,8 @@ milestone = "M5" status = "shipped" commands = ["comment.export"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree", "crates/xfdf/src/tests.rs::fdf_export_bounds_indirect_type_alias_lookups", "crates/xfdf/src/tests.rs::fdf_export_preserves_metadata_with_stale_type_references", "crates/xfdf/src/tests.rs::fdf_export_keeps_indirect_values_nested_in_annotation_properties", "crates/xfdf/src/tests.rs::fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion", "crates/xfdf/src/tests.rs::fdf_export_refuses_excessive_direct_depth_values_and_payload", "crates/xfdf/src/tests.rs::fdf_export_refuses_aliases_before_cloning_large_annotation_values", "crates/xfdf/src/tests.rs::fdf_export_refuses_repeated_saved_annotations_after_cache_eviction", "crates/automation/tests/it/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/it/automation.rs::refused_fdf_export_keeps_the_destination_and_source_through_tools", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes", "crates/xfdf/src/tests_format.rs::free_text_formatting_is_exported_as_xfdf_elements_and_attributes", "crates/xfdf/src/tests_format.rs::line_leaders_and_caption_round_trip", "crates/xfdf/src/tests_format.rs::square_circle_caret_and_redaction_attributes_round_trip", "crates/xfdf/src/tests_format.rs::an_image_stamp_keeps_its_appearance_through_xfdf", "crates/xfdf/src/tests_format.rs::formatting_round_trips"] -notes = "FDF preserves shared/cyclic nested values. Discovery keeps lazy array owners and annotation positions; retained entries are budgeted before cloning, including after saved-PDF cache eviction. Copied-graph limits are 100,000 comments/objects/values, 64 direct levels and a conservative 64 MiB data budget; refusal precedes writing. Source parsing and page discovery retain existing COS/parser limits, with no total source-memory bound. Source /Page and /Pages dictionaries become null at every copy depth; indirect Type names check each reference generation with iterative cycle detection and a 64-reference limit; stale Type references preserve unrelated metadata; a saved/reopened three-page fixture verifies small output and retained unrelated metadata. FDF carries each comment's appearance (/AP with the forms and images it draws) and source-document links stay behind; FDF import copies a comment's streams only when the comment lands on a page. XFDF exports retain tabs and line breaks in comment identities and metadata, and write each comment's formatting as XFDF 3.0 elements and attributes (contents-richtext, defaultstyle, width/style/dashes also from an older /Border, cloudy style, rotation, justification, fringe on every type, line leaders and captions, caret symbol, redaction overlay text) and a stamp's appearance as ; an appearance past the limits, or on a rect with no area, is left out. Readers that don't know an element skip it." +evidence = ["crates/xfdf/src/tests.rs::fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree", "crates/xfdf/src/tests.rs::fdf_export_bounds_indirect_type_alias_lookups", "crates/xfdf/src/tests.rs::fdf_export_preserves_metadata_with_stale_type_references", "crates/xfdf/src/tests.rs::fdf_export_keeps_indirect_values_nested_in_annotation_properties", "crates/xfdf/src/tests.rs::fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion", "crates/xfdf/src/tests.rs::fdf_export_refuses_excessive_direct_depth_values_and_payload", "crates/xfdf/src/tests.rs::fdf_export_refuses_aliases_before_cloning_large_annotation_values", "crates/xfdf/src/tests.rs::fdf_export_refuses_repeated_saved_annotations_after_cache_eviction", "crates/automation/tests/it/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/it/automation.rs::refused_fdf_export_keeps_the_destination_and_source_through_tools", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/it/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes", "crates/xfdf/src/tests_format.rs::free_text_formatting_is_exported_as_xfdf_elements_and_attributes", "crates/xfdf/src/tests_format.rs::line_leaders_and_caption_round_trip", "crates/xfdf/src/tests_format.rs::square_circle_caret_and_redaction_attributes_round_trip", "crates/xfdf/src/tests_format.rs::an_image_stamp_keeps_its_appearance_through_xfdf", "crates/xfdf/src/tests_format.rs::formatting_round_trips", "crates/xfdf/src/tests_format.rs::fdf_carries_only_stamp_appearances"] +notes = "FDF preserves shared/cyclic nested values. Discovery keeps lazy array owners and annotation positions; retained entries are budgeted before cloning, including after saved-PDF cache eviction. Copied-graph limits are 100,000 comments/objects/values, 64 direct levels and a conservative 64 MiB data budget; refusal precedes writing. Source parsing and page discovery retain existing COS/parser limits, with no total source-memory bound. Source /Page and /Pages dictionaries become null at every copy depth; indirect Type names check each reference generation with iterative cycle detection and a 64-reference limit; stale Type references preserve unrelated metadata; a saved/reopened three-page fixture verifies small output and retained unrelated metadata. FDF carries a stamp's appearance (/AP with the forms and images it draws), as XFDF does, and other comments their formatting entries without /AP; source-document links stay behind; FDF import copies a comment's streams only when the comment lands on a page. XFDF exports retain tabs and line breaks in comment identities and metadata, and write each comment's formatting as XFDF 3.0 elements and attributes (contents-richtext, defaultstyle, width/style/dashes also from an older /Border, cloudy style, rotation, justification, fringe on every type, line leaders and captions, caret symbol, redaction overlay text, the intent as both `intent` and `IT`) and a stamp's appearance as ; an appearance past the limits, or on a rect with no area, is left out. Readers that don't know an element skip it." [[feature]] id = "comment.export-selected"