From a90502f2c901ddccf85c5f5207961bbb1c14537b Mon Sep 17 00:00:00 2001
From: mtlcl410
Date: Sun, 8 Nov 2026 17:43:00 +0800
Subject: [PATCH] M5: comments keep their formatting and appearance through
XFDF and FDF (comment.import-fdf-xfdf, comment.export-fdf-xfdf)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
XFDF export and import carry each comment's formatting as XFDF 3.0
(ISO 19444-1) names it: contents-richtext (/RC), defaultstyle (/DS),
width/style/dashes (/BS, or an older /Border), cloudy style and
intensity (/BE), rotation, justification (/Q), fringe (/RD) on every
type, leaderLength/leaderExtend/leader-offset and the caption
attributes of lines, the caret symbol and redaction overlay text.
A stamp's appearance travels as : base 64 of the
DICT/STREAM/ARRAY/NAME/INT/FIXED/BOOL/STRING/NULL XML other tools
write, with stream data in DATA as XFDF 3.0 "Stream encoding" gives it.
Copy and paste carries every comment's appearance. Import keeps an
appearance it can read (never on a redaction mark), records an
unreadable one in Report::skipped and draws the comment instead; it is
bounded at 16 MiB per appearance, 64 MiB per import, 64 levels and
100,000 values.
FDF export keeps /AP with the forms and images it draws (ISO 32000-2
§12.7.8.3.4); FDF import copies those streams into the document, once
when shared, only for comments that land on a page, and no longer
nulls values past 16 levels (a gradient stamp's shading functions sit
deeper).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
---
Cargo.lock | 2 +
ROADMAP.md | 2 +
crates/xfdf/Cargo.toml | 4 +
crates/xfdf/README.md | 26 +-
crates/xfdf/src/appearance.rs | 341 +++++++++++++++
crates/xfdf/src/fdf.rs | 68 ++-
crates/xfdf/src/lib.rs | 12 +-
crates/xfdf/src/tests.rs | 6 +-
crates/xfdf/src/tests_format.rs | 729 ++++++++++++++++++++++++++++++++
crates/xfdf/src/xfdf.rs | 339 +++++++++++++--
parity/acrobat-features.toml | 16 +-
11 files changed, 1478 insertions(+), 67 deletions(-)
create mode 100644 crates/xfdf/src/appearance.rs
create mode 100644 crates/xfdf/src/tests_format.rs
diff --git a/Cargo.lock b/Cargo.lock
index 6710dddeea..8bd476ad54 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4796,10 +4796,12 @@ dependencies = [
name = "pdfcraft-xfdf"
version = "0.18.1"
dependencies = [
+ "base64 0.23.1",
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
+ "proptest",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
diff --git a/ROADMAP.md b/ROADMAP.md
index 1de96a7cda..3270d42bdb 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -230,6 +230,8 @@ 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-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/Cargo.toml b/crates/xfdf/Cargo.toml
index f868c95719..d82e75394e 100644
--- a/crates/xfdf/Cargo.toml
+++ b/crates/xfdf/Cargo.toml
@@ -14,4 +14,8 @@ pdfcraft-annot = { workspace = true }
pdfcraft-forms = { workspace = true }
pdfcraft-model = { workspace = true }
roxmltree = "0.21"
+base64 = "0.23"
thiserror = { workspace = true }
+
+[dev-dependencies]
+proptest = { workspace = true }
diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md
index d286a457c3..b5a6d8d65e 100644
--- a/crates/xfdf/README.md
+++ b/crates/xfdf/README.md
@@ -14,9 +14,25 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink,
default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads
(`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions.
+ Formatting travels both ways as XFDF 3.0 names it: rich text (``, the `/RC`
+ XHTML as markup; plain text in it is the comment's text), default style (``),
+ border width, style and dash pattern (`width`, `style`, `dashes`; also from an older `/Border`
+ array), cloudy borders (`style="cloudy"`, `intensity`), `rotation`, `justification` (free text
+ `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`.
+- **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`
+ or `raw`/`hex`), the shape XFDF files from other tools carry. Copy and paste carries every
+ comment's appearance. Import keeps an appearance it can read (except on a redaction mark, which
+ PdfCraft always draws) and otherwise draws the comment and records the reason in
+ `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. Document
- links and appearances are excluded. Source `/Page` and `/Pages` dictionaries become null
+ 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
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
@@ -28,7 +44,7 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
levels or a conservative 64 MiB serialization budget instead of exporting partial data.
These are copied-graph limits: source parsing and page discovery retain their existing
COS/parser limits, with no total source-memory bound. Unknown indirect streams retain their
- encoded bytes. FDF import semantics are unchanged.
+ encoded bytes. FDF import copies a comment's streams only when the comment lands on a page.
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
tab-delimited text (a row of names, a row of values).
@@ -44,7 +60,7 @@ tables remains heuristic. Both write a cell that a spreadsheet would run as a fo
or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that
export added, so exported data imports back unchanged.
-Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
-appearances PdfCraft can draw, and sets values through the form's own checks (formats,
+Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), keeps the
+appearance the file carries or draws one where PdfCraft can, and sets values through the form's own checks (formats,
validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read
with `roxmltree`.
diff --git a/crates/xfdf/src/appearance.rs b/crates/xfdf/src/appearance.rs
new file mode 100644
index 0000000000..d8cbddb4ad
--- /dev/null
+++ b/crates/xfdf/src/appearance.rs
@@ -0,0 +1,341 @@
+//! A comment's appearance dictionary (`/AP`) in XFDF.
+//!
+//! ISO 19444-1 (XFDF 3.0, "appearance") carries a stamp's `/AP` as a base 64 encoded string. What
+//! the string holds is the dictionary written as XML, one element per PDF object: `DICT`, `STREAM`,
+//! `ARRAY`, `NAME`, `INT`, `FIXED` (a real number), `BOOL`, `STRING` and `NULL`, each with its
+//! dictionary key in `KEY` and a simple value in `VAL`; a stream's data is a `DATA` element whose
+//! `MODE` and `ENCODING` follow XFDF 3.0 "Stream encoding" (`filtered`/`ascii` for short printable
+//! data, `raw`/`hex` otherwise). That is what the XFDF files in the wild hold (for example the
+//! stamps attached to PDFBOX-4437 and PDFBOX-4478). Indirect objects are written in place, so the
+//! XML is self-contained.
+//!
+//! Both directions are bounded: the decoded XML, the stream data, the nesting and the number of
+//! values. Anything unreadable makes the whole appearance unusable; the caller then records it and
+//! draws the comment from its other entries.
+
+use base64::Engine as _;
+
+use super::common::{check_form_xml_depth, esc, parse_form_xml};
+use super::{Dict, Document, ObjRef, Object, PdfString};
+
+/// At most this much decoded XML in one appearance (and at most this much XML is written for one).
+pub(crate) const MAX_APPEARANCE_BYTES: usize = 16 << 20;
+/// At most this much appearance data (decoded XML) in one import, over all of its comments.
+pub(crate) const MAX_IMPORT_APPEARANCE_BYTES: usize = 64 << 20;
+/// How deeply objects may nest inside one appearance.
+const MAX_NESTING: usize = 64;
+/// How many PDF values one appearance may hold.
+const MAX_VALUES: usize = 100_000;
+/// XFDF 3.0 "Stream encoding": data shorter than this that is all printable ASCII is written as it
+/// is (`filtered`/`ascii`); everything else as hexadecimal (`raw`/`hex`).
+const ASCII_DATA_LIMIT: usize = 4096;
+
+/// The base 64 `` text for annotation `d`'s appearance dictionary, or `None` when it
+/// has no normal appearance stream or is past the limits (the comment is then exported without
+/// one).
+pub(crate) fn encode(doc: &Document, d: &Dict) -> Option {
+ if !has_appearance(doc, d) {
+ return None;
+ }
+ let ap = d.get(b"AP").map(|a| doc.resolve(a))?;
+ let mut writer = Writer { doc, out: String::new(), values: MAX_VALUES, path: Vec::new() };
+ writer.dict(Some(b"AP"), ap.as_dict()?, 0)?;
+ let b64 = base64::engine::general_purpose::STANDARD.encode(writer.out.as_bytes());
+ // Line breaks every 76 characters, as base 64 text usually has them; the reader skips them.
+ let mut wrapped = String::with_capacity(b64.len() + b64.len() / 76 + 1);
+ for (i, c) in b64.chars().enumerate() {
+ if i > 0 && i % 76 == 0 {
+ wrapped.push('\n');
+ }
+ wrapped.push(c);
+ }
+ Some(wrapped)
+}
+
+struct Writer<'a> {
+ doc: &'a Document,
+ out: String,
+ values: usize,
+ /// The references being written, so a reference back to one of them ends the cycle.
+ path: Vec,
+}
+
+impl Writer<'_> {
+ fn key_attr(key: Option<&[u8]>) -> String {
+ key.map(|k| format!(" KEY=\"{}\"", esc(&pdfcraft_forms::name_text(k)))).unwrap_or_default()
+ }
+
+ fn spend(&mut self, depth: usize) -> Option<()> {
+ if depth > MAX_NESTING || self.out.len() > MAX_APPEARANCE_BYTES {
+ return None;
+ }
+ self.values = self.values.checked_sub(1)?;
+ Some(())
+ }
+
+ fn dict(&mut self, key: Option<&[u8]>, d: &Dict, depth: usize) -> Option<()> {
+ self.spend(depth)?;
+ self.out.push_str(&format!("\n", Self::key_attr(key)));
+ for (k, v) in d.iter() {
+ self.value(Some(k), v, depth + 1)?;
+ }
+ self.out.push_str("\n");
+ Some(())
+ }
+
+ fn value(&mut self, key: Option<&[u8]>, o: &Object, depth: usize) -> Option<()> {
+ self.spend(depth)?;
+ let k = Self::key_attr(key);
+ match o {
+ Object::Ref(r) => {
+ if self.path.contains(r) {
+ // A cycle: the object is already being written further up.
+ self.out.push_str(&format!("\n"));
+ return Some(());
+ }
+ self.path.push(*r);
+ let target = self.doc.get(*r);
+ let written = self.value(key, &target, depth + 1);
+ self.path.pop();
+ written?;
+ }
+ Object::Null => self.out.push_str(&format!("\n")),
+ Object::Bool(b) => self.out.push_str(&format!("\n")),
+ Object::Int(i) => self.out.push_str(&format!("\n")),
+ // Rust prints the shortest decimal that reads back as the same number, never with an
+ // exponent; a non-finite number (which PDF can't hold) is written as 0.
+ Object::Real(r) => self.out.push_str(&format!("\n", if r.is_finite() { *r } else { 0.0 })),
+ Object::Name(n) => self.out.push_str(&format!("\n", esc(&pdfcraft_forms::name_text(n)))),
+ Object::String(s) => match std::str::from_utf8(&s.bytes) {
+ // Text XML can hold as it is (`esc` would drop other control characters).
+ Ok(t) if t.chars().all(|c| !(c < ' ' && !matches!(c, '\t' | '\n' | '\r')) && c != '\u{FFFE}' && c != '\u{FFFF}') => {
+ self.out.push_str(&format!("\n", esc(t)));
+ }
+ _ => self.out.push_str(&format!("\n", hex_of(&s.bytes, false))),
+ },
+ Object::Array(a) => {
+ self.out.push_str(&format!("\n"));
+ for v in a {
+ self.value(None, v, depth + 1)?;
+ }
+ self.out.push_str("\n");
+ }
+ Object::Dict(d) => self.dict(key, d, depth)?,
+ Object::Stream(s) => {
+ // The data plus its hexadecimal form must fit what is left of the budget.
+ if s.raw.len().saturating_mul(2).saturating_add(self.out.len()) > MAX_APPEARANCE_BYTES {
+ return None;
+ }
+ self.out.push_str(&format!("\n"));
+ // `Length` follows from the data.
+ for (dk, dv) in s.dict.iter().filter(|(dk, _)| dk.as_slice() != b"Length") {
+ self.value(Some(dk), dv, depth + 1)?;
+ }
+ let printable = s.raw.iter().all(|&b| (b > 32 && b < 127) || b == b'\r' || b == b'\n');
+ if s.raw.len() < ASCII_DATA_LIMIT && printable && !s.dict.contains(b"Filter") {
+ // Printable ASCII is valid UTF-8.
+ let text = String::from_utf8_lossy(&s.raw);
+ self.out.push_str(&format!("{}\n", esc_data(&text)));
+ } else {
+ self.out.push_str(&format!("{}\n", hex_of(&s.raw, true)));
+ }
+ self.out.push_str("\n");
+ }
+ }
+ Some(())
+ }
+}
+
+/// XFDF 3.0 "Stream encoding", method 1: the XML control characters as entities; line breaks stay.
+fn esc_data(s: &str) -> String {
+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
+}
+
+/// Bytes as upper-case hexadecimal, with a line feed after every 80 characters when `wrap`.
+fn hex_of(bytes: &[u8], wrap: bool) -> String {
+ const DIGITS: &[u8; 16] = b"0123456789ABCDEF";
+ let mut out = String::with_capacity(bytes.len().saturating_mul(2).saturating_add(bytes.len() / 40));
+ for (i, b) in bytes.iter().enumerate() {
+ if wrap && i > 0 && i % 40 == 0 {
+ out.push('\n');
+ }
+ out.push(char::from(DIGITS[usize::from(b >> 4)]));
+ out.push(char::from(DIGITS[usize::from(b & 15)]));
+ }
+ out
+}
+
+/// Hexadecimal digits back to bytes, ignoring white space; `None` for any other character or an
+/// odd number of digits.
+fn bytes_of_hex(text: &str) -> Option> {
+ let digits: Vec = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
+ if !digits.len().is_multiple_of(2) {
+ return None;
+ }
+ let value = |c: u8| char::from(c).to_digit(16).and_then(|d| u8::try_from(d).ok());
+ digits.as_chunks::<2>().0.iter().map(|[h, l]| Some(value(*h)? << 4 | value(*l)?)).collect()
+}
+
+/// An appearance read from XFDF, before it is put into a document.
+#[derive(Debug, PartialEq)]
+pub(crate) enum Node {
+ Value(Object),
+ Array(Vec),
+ Dict(Vec<(Vec, Node)>),
+ Stream(Vec<(Vec, Node)>, Vec),
+}
+
+/// Read the text of an `` element: base 64, then the XML it holds. `budget` is what
+/// the import may still read (decoded bytes) and is charged. The result is the `/AP` dictionary,
+/// with a normal appearance (`/N`) that is a stream or a dictionary of streams.
+pub(crate) fn decode(text: &str, budget: &mut usize) -> Result {
+ let limit = MAX_APPEARANCE_BYTES.min(*budget);
+ // Base 64 is 4 characters for 3 bytes: refuse a text that decodes past the limit before
+ // decoding it.
+ let mut compact = String::new();
+ for c in text.chars().filter(|c| !c.is_whitespace()) {
+ if compact.len() / 4 * 3 > limit {
+ return Err(format!("the appearance is larger than {} MiB", limit >> 20));
+ }
+ compact.push(c);
+ }
+ let engine = base64::engine::GeneralPurpose::new(
+ &base64::alphabet::STANDARD,
+ base64::engine::GeneralPurposeConfig::new().with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
+ );
+ let bytes = engine.decode(compact.as_bytes()).map_err(|e| format!("the appearance is not base 64: {e}"))?;
+ if bytes.len() > limit {
+ return Err(format!("the appearance is larger than {} MiB", limit >> 20));
+ }
+ *budget = budget.saturating_sub(bytes.len());
+ let xml = std::str::from_utf8(&bytes).map_err(|_| "the appearance is not UTF-8 text".to_string())?;
+ let xml = xml.trim_start_matches('\u{feff}');
+ check_form_xml_depth(xml).map_err(|e| e.to_string())?;
+ let parsed = parse_form_xml(xml).map_err(|e| format!("the appearance is not XML: {e}"))?;
+ let root = parsed.root_element();
+ if !root.tag_name().name().eq_ignore_ascii_case("DICT") {
+ return Err("the appearance is not a dictionary".into());
+ }
+ let mut values = MAX_VALUES;
+ let node = read(root, 0, &mut values)?;
+ let usable = match &node {
+ Node::Dict(entries) => entries.iter().any(|(k, v)| {
+ k.as_slice() == b"N"
+ && match v {
+ Node::Stream(..) => true,
+ Node::Dict(states) => !states.is_empty() && states.iter().all(|(_, s)| matches!(s, Node::Stream(..))),
+ _ => false,
+ }
+ }),
+ _ => false,
+ };
+ if !usable {
+ return Err("the appearance has no normal appearance stream".into());
+ }
+ Ok(node)
+}
+
+fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
+ node.attributes().find(|a| a.name().eq_ignore_ascii_case(name)).map(|a| a.value())
+}
+
+fn read(node: roxmltree::Node, depth: usize, values: &mut usize) -> Result {
+ if depth > MAX_NESTING {
+ return Err(format!("the appearance nests objects more than {MAX_NESTING} deep"));
+ }
+ *values = values.checked_sub(1).ok_or_else(|| format!("the appearance holds more than {MAX_VALUES} values"))?;
+ let tag = node.tag_name().name().to_ascii_uppercase();
+ let val = || attr(node, "VAL").ok_or_else(|| format!("{tag} has no VAL"));
+ let entries = |values: &mut usize| -> Result, Node)>, String> {
+ let mut out = Vec::new();
+ for child in node.children().filter(|c| c.is_element() && !c.tag_name().name().eq_ignore_ascii_case("DATA")) {
+ let key = attr(child, "KEY").ok_or_else(|| format!("an entry of {tag} has no KEY"))?;
+ out.push((pdfcraft_forms::name_bytes(key), read(child, depth + 1, values)?));
+ }
+ Ok(out)
+ };
+ Ok(match tag.as_str() {
+ "DICT" => Node::Dict(entries(values)?),
+ "ARRAY" => {
+ let mut items = Vec::new();
+ for child in node.children().filter(|c| c.is_element()) {
+ items.push(read(child, depth + 1, values)?);
+ }
+ Node::Array(items)
+ }
+ "STREAM" => {
+ let mut dict = entries(values)?;
+ let data = node.children().find(|c| c.is_element() && c.tag_name().name().eq_ignore_ascii_case("DATA")).ok_or("a stream has no DATA")?;
+ let text: String = data.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
+ let bytes = match attr(data, "ENCODING").map(str::to_ascii_lowercase).as_deref() {
+ Some("hex") => bytes_of_hex(&text).ok_or("stream data is not hexadecimal")?,
+ Some("ascii") | None => text.into_bytes(),
+ Some(other) => return Err(format!("unknown stream encoding {other}")),
+ };
+ // `filtered` data is already decoded: the filters no longer apply to it.
+ if attr(data, "MODE").is_some_and(|m| m.eq_ignore_ascii_case("filtered")) {
+ dict.retain(|(k, _)| !matches!(k.as_slice(), b"Filter" | b"DecodeParms" | b"FFilter" | b"FDecodeParms"));
+ }
+ dict.retain(|(k, _)| k.as_slice() != b"Length");
+ Node::Stream(dict, bytes)
+ }
+ "NAME" => Node::Value(Object::Name(pdfcraft_forms::name_bytes(val()?))),
+ "INT" => Node::Value(Object::Int(val()?.trim().parse::().map_err(|_| "INT is not an integer")?)),
+ "FIXED" => {
+ let v = val()?.trim().parse::().ok().filter(|v| v.is_finite()).ok_or("FIXED is not a number")?;
+ Node::Value(Object::Real(v))
+ }
+ "BOOL" => Node::Value(Object::Bool(match val()?.trim() {
+ "true" => true,
+ "false" => false,
+ _ => return Err("BOOL is neither true nor false".into()),
+ })),
+ "STRING" => {
+ let v = val()?;
+ let bytes = if attr(node, "ENCODING").is_some_and(|e| e.eq_ignore_ascii_case("hex")) {
+ bytes_of_hex(v).ok_or("STRING is not hexadecimal")?
+ } else {
+ v.as_bytes().to_vec()
+ };
+ Node::Value(Object::String(PdfString::literal(bytes)))
+ }
+ "NULL" => Node::Value(Object::Null),
+ other => return Err(format!("unknown appearance element {other}")),
+ })
+}
+
+/// Put a read appearance into `doc`: each stream becomes an object of its own. Returns the `/AP`
+/// value. Null dictionary entries are left out (ISO 32000-2 §7.3.7: they mean the entry is absent).
+pub(crate) fn materialize(doc: &mut Document, node: Node) -> Object {
+ let dict = |doc: &mut Document, entries: Vec<(Vec, Node)>| {
+ let mut d = Dict::new();
+ for (k, v) in entries {
+ let v = materialize(doc, v);
+ if !matches!(v, Object::Null) {
+ d.set(k, v);
+ }
+ }
+ d
+ };
+ match node {
+ Node::Value(o) => o,
+ Node::Array(items) => Object::Array(items.into_iter().map(|n| materialize(doc, n)).collect()),
+ Node::Dict(entries) => Object::Dict(dict(doc, entries)),
+ Node::Stream(entries, raw) => {
+ let d = dict(doc, entries);
+ Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream { dict: d, raw: raw.into() })))
+ }
+ }
+}
+
+/// Whether an annotation dictionary carries an appearance the document can draw: `/AP` with a
+/// normal appearance that is a stream, or a dictionary of streams (one per state).
+pub(crate) fn has_appearance(doc: &Document, d: &Dict) -> bool {
+ let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)) else { return false };
+ let Some(normal) = ap.as_dict().and_then(|ap| ap.get(b"N")).map(|n| doc.resolve(n)) else { return false };
+ match normal.as_ref() {
+ Object::Stream(_) => true,
+ Object::Dict(states) => !states.is_empty() && states.iter().all(|(_, s)| matches!(doc.resolve(s).as_ref(), Object::Stream(_))),
+ _ => false,
+ }
+}
diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs
index ceb4ad99eb..42d0d28ecc 100644
--- a/crates/xfdf/src/fdf.rs
+++ b/crates/xfdf/src/fdf.rs
@@ -86,13 +86,14 @@ impl<'a> FdfCopy<'a> {
Ok(())
}
- /// Skip source-document links and appearances without cloning their values. The
- /// dictionary and every retained entry are charged before any owned copy is made.
+ /// 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.
+ /// 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 mut out = Dict::new();
for (key, value) in dict.iter() {
- if matches!(key.as_slice(), b"P" | b"AP" | 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") {
continue;
}
self.dict_entry(&mut out, key, value, 1)?;
@@ -202,7 +203,7 @@ impl<'a> FdfCopy<'a> {
}
}
-/// FDF with field values and/or comments (appearances are not carried). Each comment is an
+/// FDF with field values and/or comments, each 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.
@@ -356,11 +357,27 @@ impl ImportBudget {
}
}
+/// Where [`deep`] copies to: the document, and the streams already copied into it (by their
+/// reference in the FDF), so a stream two comments share is copied once.
+struct DeepTarget<'a> {
+ doc: &'a mut Document,
+ streams: HashMap,
+}
+
/// Copy an object out of another document, resolving references. A reference back to an object
-/// being copied becomes null.
-fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result
two
"#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ // What it writes is XML the reader accepts.
+ assert!(roxmltree::Document::parse(&xml).is_ok());
+}
+
+#[test]
+fn free_text_formatting_comes_back_from_xfdf() {
+ let mut src = pages(1);
+ put(&mut src, formatted_freetext());
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "ft");
+ assert_eq!(text(&dst, &d, b"RC").as_deref(), Some(RICH));
+ assert_eq!(text(&dst, &d, b"DS").as_deref(), Some("font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF"));
+ assert_eq!(text(&dst, &d, b"Contents").as_deref(), Some("Bold & plain\rtwo"));
+ assert_eq!(d.int(b"Q"), Some(2));
+ assert_eq!(d.int(b"Rotate"), Some(90));
+ assert_eq!(f64s(&dst, d.get(b"RD")), vec![1.5, 2.0, 3.0, 4.0]);
+ let bs = sub_dict(&dst, &d, b"BS");
+ assert_eq!(bs.name(b"S"), Some(b"D".as_slice()), "a dashed border comes back dashed, not solid");
+ assert_eq!(f64s(&dst, bs.get(b"D")), vec![3.0, 2.0]);
+ assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(2.0));
+}
+
+#[test]
+fn line_leaders_and_caption_round_trip() {
+ let mut src = pages(1);
+ put(&mut src, formatted_line());
+ let (xml, dst) = round_trip_xfdf(&src);
+ for want in [
+ r#"leaderLength="-12""#,
+ r#"leaderExtend="3""#,
+ r#"leader-offset="1.5""#,
+ r#"caption="yes""#,
+ r#"caption-style="Top""#,
+ r#"caption-offset-h="4" caption-offset-v="-2""#,
+ r#"dashes="4,3,2,3""#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ let d = named(&dst, "ln");
+ assert_eq!(d.get(b"LL").and_then(Object::as_f64), Some(-12.0));
+ assert_eq!(d.get(b"LLE").and_then(Object::as_f64), Some(3.0));
+ assert_eq!(d.get(b"LLO").and_then(Object::as_f64), Some(1.5));
+ assert_eq!(d.get(b"Cap"), Some(&Object::Bool(true)));
+ assert_eq!(d.name(b"CP"), Some(b"Top".as_slice()));
+ assert_eq!(f64s(&dst, d.get(b"CO")), vec![4.0, -2.0]);
+ assert_eq!(f64s(&dst, sub_dict(&dst, &d, b"BS").get(b"D")), vec![4.0, 3.0, 2.0, 3.0]);
+}
+
+#[test]
+fn square_circle_caret_and_redaction_attributes_round_trip() {
+ let mut src = pages(1);
+ let mut sq = annot("Square", "sq", [10.0, 10.0, 110.0, 110.0]);
+ sq.set(b"RD".to_vec(), nums(&[5.0, 5.0, 5.0, 5.0]));
+ let mut be = Dict::new();
+ be.set(b"S".to_vec(), Object::name("C"));
+ be.set(b"I".to_vec(), Object::Real(2.0));
+ sq.set(b"BE".to_vec(), Object::Dict(be));
+ put(&mut src, sq);
+ let mut ci = annot("Circle", "ci", [120.0, 10.0, 220.0, 110.0]);
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(3.0));
+ bs.set(b"S".to_vec(), Object::name("B"));
+ ci.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, ci);
+ let mut ca = annot("Caret", "ca", [230.0, 10.0, 250.0, 30.0]);
+ ca.set(b"Sy".to_vec(), Object::name("P"));
+ ca.set(b"RD".to_vec(), nums(&[1.0, 1.0, 1.0, 1.0]));
+ put(&mut src, ca);
+ let mut rd = annot("Redact", "rd", [300.0, 10.0, 400.0, 30.0]);
+ rd.set(b"OverlayText".to_vec(), PdfString::text("WITHHELD"));
+ rd.set(b"Repeat".to_vec(), Object::Bool(true));
+ rd.set(b"Q".to_vec(), Object::Int(1));
+ put(&mut src, rd);
+ // A solid border that still holds a dash array keeps it, and stays solid.
+ let mut solid = annot("Square", "solid", [120.0, 200.0, 220.0, 300.0]);
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(1.0));
+ bs.set(b"S".to_vec(), Object::name("S"));
+ bs.set(b"D".to_vec(), nums(&[2.0]));
+ solid.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, solid);
+ // An older /Border array: width 2, dashes 4 on 1.
+ let mut old = annot("Square", "old", [10.0, 200.0, 110.0, 300.0]);
+ old.set(b"Border".to_vec(), Object::Array(vec![0.into(), 0.into(), 2.into(), nums(&[4.0, 1.0])]));
+ put(&mut src, old);
+ let (xml, dst) = round_trip_xfdf(&src);
+ for want in [
+ r#"style="cloudy" intensity="2""#,
+ r#"fringe="5,5,5,5""#,
+ r#"style="bevelled""#,
+ r#"symbol="paragraph""#,
+ r#"overlay-text="WITHHELD""#,
+ r#"overlay-text-repeat="true""#,
+ r#"justification="1""#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ let sq = named(&dst, "sq");
+ assert_eq!(f64s(&dst, sq.get(b"RD")), vec![5.0; 4]);
+ assert_eq!(sub_dict(&dst, &sq, b"BE").get(b"I").and_then(Object::as_f64), Some(2.0));
+ assert_eq!(sub_dict(&dst, &named(&dst, "ci"), b"BS").name(b"S"), Some(b"B".as_slice()));
+ let ca = named(&dst, "ca");
+ assert_eq!(ca.name(b"Sy"), Some(b"P".as_slice()));
+ assert_eq!(f64s(&dst, ca.get(b"RD")), vec![1.0; 4]);
+ let rd = named(&dst, "rd");
+ assert_eq!(text(&dst, &rd, b"OverlayText").as_deref(), Some("WITHHELD"));
+ assert_eq!(rd.get(b"Repeat"), Some(&Object::Bool(true)));
+ assert_eq!(rd.int(b"Q"), Some(1));
+ let solid = sub_dict(&dst, &named(&dst, "solid"), b"BS");
+ assert_eq!((solid.name(b"S"), f64s(&dst, solid.get(b"D"))), (Some(b"S".as_slice()), vec![2.0]));
+ let old = sub_dict(&dst, &named(&dst, "old"), b"BS");
+ assert_eq!((old.get(b"W").and_then(Object::as_f64), old.name(b"S")), (Some(2.0), Some(b"D".as_slice())));
+ assert_eq!(f64s(&dst, old.get(b"D")), vec![4.0, 1.0]);
+}
+
+/// The XFDF the audit imported: what another tool writes for formatted comments.
+#[test]
+fn formatting_written_by_another_tool_is_imported() {
+ let xml = r#"
+
+
+