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 { +/// being copied becomes null. A stream (an appearance and the images and forms it draws) becomes +/// an object of `dst`, its data charged to the budget. +fn deep( + src: &Document, + dst: &mut DeepTarget, + o: &Object, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result { budget.spend(1, 0)?; - if depth > 16 { + // As deep as export writes (an appearance's shading functions sit a dozen levels down). + if depth > MAX_DEPTH { return Ok(Object::Null); } Ok(match o { @@ -368,15 +385,32 @@ fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget if path.contains(r) { return Ok(Object::Null); } + if let Some(copied) = dst.streams.get(r) { + return Ok(Object::Ref(*copied)); + } + let target = src.get(*r); + if let Object::Stream(stream) = target.as_ref() { + budget.spend(0, stream.raw.len())?; + path.push(*r); + let dict = deep(src, dst, &Object::Dict(stream.dict.clone()), depth + 1, path, budget); + path.pop(); + let Object::Dict(mut dict) = dict? else { return Ok(Object::Null) }; + dict.remove(b"Length"); + let copied = dst.doc.add(Object::Stream(pdfcraft_cos::Stream { dict, raw: stream.raw.clone() })); + dst.streams.insert(*r, copied); + return Ok(Object::Ref(copied)); + } path.push(*r); - let copy = deep(src, &src.get(*r), depth + 1, path, budget); + let copy = deep(src, dst, &target, depth + 1, path, budget); path.pop(); copy? } - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, dst, x, depth + 1, path, budget)).collect::>()?), Object::Dict(d) => { - Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, dst, v, depth + 1, path, budget)?))).collect::>()?) } + // A stream is always an indirect object (ISO 32000-2 §7.3.8); one held directly is not + // read. Object::Stream(_) => Object::Null, Object::String(s) => { budget.spend(0, s.bytes.len())?; @@ -469,8 +503,17 @@ pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); let mut placed: HashMap = HashMap::new(); let mut later: HashMap, Option)> = HashMap::new(); + let mut copied_streams: HashMap = HashMap::new(); for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue }; + // Only a comment of a known type on a page of this document is copied in: its streams + // become objects of the document. + let on_page = src.get(b"Page").and_then(|p| fdf.resolve(p).as_int()).and_then(|p| usize::try_from(p).ok()).is_some_and(|p| p < pages.len()); + let known = + src.get(b"Subtype").map(|s| fdf.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s))); + if !on_page || !known { + continue; + } // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); @@ -480,7 +523,10 @@ pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result = a.as_ref().into_iter().collect(); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; + let mut target = DeepTarget { doc: &mut *doc, streams: std::mem::take(&mut copied_streams) }; + let copy = deep(&fdf, &mut target, &Object::Dict(src), 0, &mut path, &mut budget); + copied_streams = target.streams; + let Object::Dict(mut d) = copy? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { continue; }; diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index c42d414918..f29789ac0b 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -2,8 +2,10 @@ //! //! - **XFDF** (ISO 19444-1, Acrobat's "Export all to data file" / "Import comments", and form //! data): comments with their geometry, colours, flags, authors, dates, replies, pop-ups, -//! ink, quadrilaterals, line ends and callouts; field values, nested by name. -//! - **FDF** (ISO 32000-2 §12.7.8): fields and comments in PDF syntax. +//! ink, quadrilaterals, line ends, callouts, formatting (rich text, default style, border +//! style and dashes, rotation, justification, fringe, line leaders) and a stamp's appearance; +//! field values, nested by name. +//! - **FDF** (ISO 32000-2 §12.7.8): fields and comments in PDF syntax, with their appearances. //! - Form data as **XML**, **CSV** and **tab-delimited text** (Acrobat's Export data formats). //! //! Import merges: a comment whose name (`/NM`) already exists on its page replaces it; field @@ -18,6 +20,7 @@ use std::io::Write as _; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; use pdfcraft_forms::{FieldKind, FieldValue}; +mod appearance; mod common; mod fdf; mod xfdf; @@ -36,6 +39,8 @@ use xfdf::{apply_values, import_xfdf}; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_format; #[derive(Debug, thiserror::Error, PartialEq)] pub enum DataError { @@ -85,6 +90,9 @@ pub struct Report { pub fields: usize, /// Field values the form refused (format, validation, unknown options). pub rejected: Vec, + /// What the import left out of a comment it did import, each as "name: why" (an appearance + /// it could not read: the comment is drawn from its other entries instead). + pub skipped: Vec, } /// Form data as XML, CSV or tab-delimited text (Acrobat's Export data formats). diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 96e9179faa..f645d7bbb6 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -438,8 +438,10 @@ fn fdf_keeps_replies_and_review_status_on_their_note() { // Every cross-reference entry points at its object (other readers trust the table). let tail = std::str::from_utf8(&fdf[fdf.windows(5).position(|w| w == b"xref\n").unwrap()..]).unwrap(); let xref: usize = tail.lines().skip_while(|l| *l != "startxref").nth(1).unwrap().parse().unwrap(); - assert!(fdf[xref..].starts_with(b"xref\n0 6\n"), "{tail}"); - for (num, line) in tail.lines().skip(3).take(5).enumerate() { + // The root, the four comments and their appearance streams. + let count: usize = tail.lines().nth(1).and_then(|l| l.strip_prefix("0 ")).unwrap().parse().unwrap(); + assert!(fdf[xref..].starts_with(b"xref\n") && count >= 6, "{tail}"); + for (num, line) in tail.lines().skip(3).take(count - 1).enumerate() { let at: usize = line[..10].parse().unwrap(); assert!(fdf[at..].starts_with(format!("{} 0 obj\n", num + 1).as_bytes()), "object {}: {line}", num + 1); } diff --git a/crates/xfdf/src/tests_format.rs b/crates/xfdf/src/tests_format.rs new file mode 100644 index 0000000000..83e91983e2 --- /dev/null +++ b/crates/xfdf/src/tests_format.rs @@ -0,0 +1,729 @@ +//! Comment formatting and appearances through XFDF (ISO 19444-1, XFDF 3.0) and FDF +//! (ISO 32000-2 §12.7.8): each property both ways, appearance streams with their resources, and +//! files that are malformed or too large. + +use std::sync::Arc; + +use base64::Engine as _; +use pdfcraft_cos::{SaveOptions, Stream, write_full}; +use proptest::prelude::*; + +use super::*; + +/// A document with `n` empty pages. +fn pages(n: usize) -> Document { + let mut doc = Document::new_empty(); + let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut kids = Vec::new(); + for _ in 0..n { + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(root)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + kids.push(Object::Ref(doc.add(p))); + } + doc.update_dict(root, |d| { + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + doc +} + +fn page_ref(doc: &Document, i: usize) -> ObjRef { + pdfcraft_model::pages(doc)[i].obj +} + +/// Add an annotation dictionary to page 0 as an object of its own. +fn put(doc: &mut Document, mut d: Dict) -> ObjRef { + let page = page_ref(doc, 0); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"P".to_vec(), Object::Ref(page)); + let r = doc.add(Object::Dict(d)); + doc.update_dict(page, |p| match p.get_mut(b"Annots") { + Some(Object::Array(a)) => a.push(Object::Ref(r)), + _ => p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(r)])), + }) + .unwrap(); + r +} + +fn nums(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +fn annot(sub: &str, nm: &str, rect: [f64; 4]) -> Dict { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name(sub)); + d.set(b"NM".to_vec(), PdfString::text(nm)); + d.set(b"Rect".to_vec(), nums(&rect)); + d.set(b"F".to_vec(), Object::Int(4)); + d +} + +/// The imported annotation named `nm`. +fn named(doc: &Document, nm: &str) -> Dict { + let r = xfdf::find_by_name(doc, nm).unwrap_or_else(|| panic!("no comment {nm}")); + doc.get(r).as_dict().unwrap().clone() +} + +fn f64s(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() +} + +fn text(doc: &Document, d: &Dict, k: &[u8]) -> Option { + d.get(k).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())) +} + +fn sub_dict(doc: &Document, d: &Dict, k: &[u8]) -> Dict { + d.get(k).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default() +} + +const RICH: &str = r#"

Bold & plain

two

"#; + +/// A free text comment with every formatting entry XFDF 3.0 gives an element or attribute for. +fn formatted_freetext() -> Dict { + let mut d = annot("FreeText", "ft", [100.0, 100.0, 300.0, 200.0]); + d.set(b"Contents".to_vec(), PdfString::text("Bold & plain\rtwo")); + d.set(b"RC".to_vec(), PdfString::text(RICH)); + d.set(b"DA".to_vec(), PdfString::literal(b"0 0 1 rg /Helv 14 Tf".to_vec())); + d.set(b"DS".to_vec(), PdfString::text("font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF")); + d.set(b"Q".to_vec(), Object::Int(2)); + d.set(b"Rotate".to_vec(), Object::Int(90)); + d.set(b"RD".to_vec(), nums(&[1.5, 2.0, 3.0, 4.0])); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(2.0)); + bs.set(b"S".to_vec(), Object::name("D")); + bs.set(b"D".to_vec(), nums(&[3.0, 2.0])); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +fn formatted_line() -> Dict { + let mut d = annot("Line", "ln", [50.0, 50.0, 250.0, 120.0]); + d.set(b"L".to_vec(), nums(&[60.0, 60.0, 240.0, 60.0])); + d.set(b"LL".to_vec(), Object::Real(-12.0)); + d.set(b"LLE".to_vec(), Object::Real(3.0)); + d.set(b"LLO".to_vec(), Object::Real(1.5)); + d.set(b"Cap".to_vec(), Object::Bool(true)); + d.set(b"CP".to_vec(), Object::name("Top")); + d.set(b"CO".to_vec(), nums(&[4.0, -2.0])); + d.set(b"IT".to_vec(), Object::name("LineDimension")); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(1.0)); + bs.set(b"S".to_vec(), Object::name("D")); + bs.set(b"D".to_vec(), nums(&[4.0, 3.0, 2.0, 3.0])); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +fn round_trip_xfdf(src: &Document) -> (String, Document) { + let xml = export_xfdf(src, true, false, "src.pdf"); + let mut dst = pages(2); + import(&mut dst, xml.as_bytes()).unwrap(); + (xml, dst) +} + +#[test] +fn free_text_formatting_is_exported_as_xfdf_elements_and_attributes() { + let mut src = pages(1); + put(&mut src, formatted_freetext()); + let xml = export_xfdf(&src, true, false, "src.pdf"); + for want in [ + r#"justification="right""#, + r#"rotation="90""#, + r#"width="2""#, + r#"style="dash""#, + r#"dashes="3,2""#, + r#"fringe="1.5,2,3,4""#, + "font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF", + // The rich text's element, as markup and without its XML declaration. + r#"Bold & plain

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#" + + +

Hello there

+font: 12pt Helvetica0 g /Helv 12 Tf
+ +
"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + let a = named(&dst, "a"); + assert_eq!(a.int(b"Q"), Some(2)); + assert_eq!(a.int(b"Rotate"), Some(90)); + assert_eq!(f64s(&dst, a.get(b"RD")), vec![2.0; 4]); + assert_eq!(text(&dst, &a, b"DS").as_deref(), Some("font: 12pt Helvetica")); + assert_eq!( + text(&dst, &a, b"RC").as_deref(), + Some(r#"

Hello there

"#) + ); + // Without , the comment's text is the rich text's. + assert_eq!(text(&dst, &a, b"Contents").as_deref(), Some("Hello there")); + let bs = sub_dict(&dst, &a, b"BS"); + assert_eq!(bs.name(b"S"), Some(b"D".as_slice())); + assert_eq!(f64s(&dst, bs.get(b"D")), vec![3.0, 2.0]); + let b = named(&dst, "b"); + assert_eq!(b.get(b"LL").and_then(Object::as_f64), Some(10.0)); + assert_eq!(b.get(b"LLE").and_then(Object::as_f64), Some(2.0)); + assert_eq!(b.get(b"LLO").and_then(Object::as_f64), Some(1.0)); +} + +#[test] +fn plain_rich_text_is_the_comments_text_and_paragraphs_become_lines() { + let xml = r#" +just text + + +

one

+

two

+ +
+
"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + let p = named(&dst, "p"); + assert_eq!(text(&dst, &p, b"Contents").as_deref(), Some("just text")); + assert!(!p.contains(b"RC")); + assert_eq!(text(&dst, &named(&dst, "q"), b"Contents").as_deref(), Some("one\rtwo")); +} + +#[test] +fn rich_text_that_is_not_xml_survives_as_text() { + let mut src = pages(1); + let mut d = annot("Text", "bad", [10.0, 10.0, 30.0, 30.0]); + d.set(b"Contents".to_vec(), PdfString::text("x")); + d.set(b"RC".to_vec(), PdfString::text("

unclosed & raw")); + put(&mut src, d); + let (xml, dst) = round_trip_xfdf(&src); + assert!(xml.contains("<p>unclosed & raw"), "{xml}"); + let d = named(&dst, "bad"); + assert_eq!(text(&dst, &d, b"RC").as_deref(), Some("

unclosed & raw")); + assert_eq!(text(&dst, &d, b"Contents").as_deref(), Some("x")); +} + +/// An image stamp: its appearance draws an image XObject from its resources. +fn image_stamp(doc: &mut Document, nm: &str, sub: &str) -> (Vec, Vec) { + let pixels: Vec = (0..=255u8).cycle().take(3 * 16 * 16).collect(); + let mut im = Dict::new(); + im.set(b"Type".to_vec(), Object::name("XObject")); + im.set(b"Subtype".to_vec(), Object::name("Image")); + im.set(b"Width".to_vec(), Object::Int(16)); + im.set(b"Height".to_vec(), Object::Int(16)); + im.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + im.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let im = doc.add(Object::Stream(Stream { dict: im, raw: pixels.clone().into() })); + let content = b"q 200 0 0 100 0 0 cm /Im0 Do Q".to_vec(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(im)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), nums(&[0.0, 0.0, 200.0, 100.0])); + form.set(b"Resources".to_vec(), Object::Dict(res)); + form.set(b"Name".to_vec(), PdfString::text("logo")); + let n = doc.add(Object::Stream(Stream { dict: form, raw: content.clone().into() })); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(n)); + let mut d = annot(sub, nm, [100.0, 500.0, 300.0, 600.0]); + d.set(b"Name".to_vec(), Object::name("Draft")); + d.set(b"AP".to_vec(), Object::Dict(ap)); + put(doc, d); + (content, pixels) +} + +/// The normal appearance of `d`, and its `/Im0` image's data. +fn appearance_of(doc: &Document, d: &Dict) -> (Vec, Vec) { + let n = doc.resolve(sub_dict(doc, d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no appearance stream") }; + let im = sub_dict(doc, &sub_dict(doc, &n.dict, b"Resources"), b"XObject").get(b"Im0").map(|o| doc.resolve(o)); + let pixels = match im.as_deref() { + Some(Object::Stream(s)) => s.decoded().unwrap(), + _ => Vec::new(), + }; + (n.decoded().unwrap(), pixels) +} + +#[test] +fn an_image_stamp_keeps_its_appearance_through_xfdf() { + let mut src = pages(1); + let (content, pixels) = image_stamp(&mut src, "logo", "Stamp"); + let (xml, dst) = round_trip_xfdf(&src); + assert!(xml.contains(""), "{xml}"); + let d = named(&dst, "logo"); + assert_eq!(appearance_of(&dst, &d), (content, pixels), "the stamp comes back with its own picture, not a drawn 'DRAFT'"); + // And it survives a save. + let saved = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap(); + let d = named(&saved, "logo"); + assert_eq!(appearance_of(&saved, &d).1.len(), 3 * 16 * 16); +} + +#[test] +fn file_export_carries_only_stamp_appearances_and_copies_carry_every_one() { + let mut src = pages(1); + let (content, _) = image_stamp(&mut src, "sq", "Square"); + let xml = export_xfdf(&src, true, false, "src.pdf"); + assert!(!xml.contains(""), "XFDF 3.0 gives only stamps an appearance element"); + let copy = export_xfdf_comments(&src, &[(0, 0)], "src.pdf"); + assert!(copy.contains("")); + let mut dst = pages(1); + import_comments(&mut dst, ©, Some(0), Some("paste"), Some([10.0, -10.0])).unwrap(); + let d = named(&dst, "paste-1"); + assert_eq!(appearance_of(&dst, &d).0, content, "a pasted copy looks the same"); + assert_eq!(f64s(&dst, d.get(b"Rect")), vec![110.0, 490.0, 310.0, 590.0]); +} + +#[test] +fn a_stamp_without_area_exports_no_appearance() { + let mut src = pages(1); + image_stamp(&mut src, "flat", "Stamp"); + let r = xfdf::find_by_name(&src, "flat").unwrap(); + src.update_dict(r, |d| d.set(b"Rect".to_vec(), nums(&[100.0, 500.0, 100.0, 600.0]))).unwrap(); + assert!(!export_xfdf(&src, true, false, "src.pdf").contains("")); +} + +/// The appearance encoding other tools write (the stamps attached to PDFBOX-4437 and +/// PDFBOX-4478 hold this shape): data written `filtered`/`ascii` under a dictionary that still +/// names its filter, and `raw`/`hex`. +#[test] +fn an_appearance_written_by_another_tool_is_imported() { + let ap = r#" + + + + + + + + + + + + + + + + + + +3020302031302031302072650A66 + + + + + + +q +/Fm0 Do +0 0 1 rg 0 0 100 50 re f +Q + + +"#; + let b64 = base64::engine::general_purpose::STANDARD.encode(ap); + let xml = format!( + r#"{b64}"# + ); + let mut dst = pages(1); + let report = import(&mut dst, xml.as_bytes()).unwrap(); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + let d = named(&dst, "s"); + let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no appearance stream") }; + assert!(!n.dict.contains(b"Filter"), "filtered data is already decoded"); + assert_eq!(n.decoded().unwrap(), b"q\n/Fm0 Do\n0 0 1 rg 0 0 100 50 re f\nQ\n"); + let fm = sub_dict(&dst, &sub_dict(&dst, &n.dict, b"Resources"), b"XObject").get(b"Fm0").map(|o| dst.resolve(o)).unwrap(); + let Object::Stream(fm) = fm.as_ref() else { panic!("no form") }; + assert_eq!(fm.decoded().unwrap(), b"0 0 10 10 re\nf"); + assert_eq!(fm.dict.get(b"Interpolate"), Some(&Object::Bool(true))); +} + +/// XFDF with one comment of type `sub` whose `` text is `inner`. +fn appearance_xfdf(inner: &str, sub: &str) -> String { + format!( + r##"<{sub} page="0" rect="10,10,110,60" name="x" color="#FF0000">{inner}"## + ) +} + +fn b64(s: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(s) +} + +#[test] +fn unreadable_appearances_are_skipped_recorded_and_drawn_again() { + let deep = format!( + r#"{}{}00"#, + r#""#.repeat(70), + "".repeat(70) + ); + for (why, inner) in [ + ("not base 64", "@@@not base64@@@".to_string()), + ("not XML", b64("")), + ("no normal appearance", b64(r#""#)), + ("a stream without data", b64(r#""#)), + ("odd hexadecimal", b64(r#"ABC"#)), + ("an unknown element", b64(r#"q Q"#)), + ("an unknown encoding", b64(r#"q Q"#)), + ("a bad number", b64(r#"q Q"#)), + ("a DTD", b64(r#"]>&a;"#)), + ("too deep", b64(&deep)), + ] { + let mut dst = pages(1); + let report = import(&mut dst, appearance_xfdf(&inner, "square").as_bytes()).unwrap_or_else(|e| panic!("{why}: {e}")); + assert_eq!(report.comments, 1, "{why}: the comment itself is imported"); + assert_eq!(report.skipped.len(), 1, "{why}: {:?}", report.skipped); + assert!(report.skipped[0].starts_with("x: "), "{why}: {:?}", report.skipped); + // PdfCraft draws the square from its own entries instead. + let d = named(&dst, "x"); + let n = sub_dict(&dst, &d, b"AP").get(b"N").map(|o| dst.resolve(o)); + assert!(matches!(n.as_deref(), Some(Object::Stream(_))), "{why}: drawn again"); + } +} + +#[test] +fn an_oversized_appearance_is_refused_before_it_is_decoded() { + let mut budget = 1000; + let inner = b64(&format!(r#"{}"#, "q ".repeat(2000))); + assert!(appearance::decode(&inner, &mut budget).is_err()); + assert_eq!(budget, 1000, "nothing was charged for a refused appearance"); + let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES; + let big = "A".repeat(appearance::MAX_APPEARANCE_BYTES / 3 * 4 + 8); + let err = appearance::decode(&big, &mut budget).unwrap_err(); + assert!(err.contains("larger than"), "{err}"); +} + +#[test] +fn many_appearances_share_one_import_budget() { + let mut budget = 100; + let one = b64(r#"0 0 m 1 1 l S"#); + let mut read = 0; + while appearance::decode(&one, &mut budget).is_ok() { + read += 1; + assert!(read < 10); + } + assert!(read >= 1 && budget < 100); +} + +#[test] +fn an_imported_redaction_mark_is_always_drawn_by_pdfcraft() { + let inner = b64( + r#"%nothing"#, + ); + let mut dst = pages(1); + import(&mut dst, appearance_xfdf(&inner, "redact").as_bytes()).unwrap(); + let d = named(&dst, "x"); + let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no appearance") }; + assert_ne!(n.decoded().unwrap(), b"%nothing", "the file's appearance could hide what the mark removes"); +} + +#[test] +fn appearance_cycles_and_strings_survive_export() { + let mut src = pages(1); + image_stamp(&mut src, "c", "Stamp"); + let d = named(&src, "c"); + let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap(); + // The form's resources name the form itself, and hold a binary string. + let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") }; + let mut xo = Dict::new(); + xo.set(b"Self".to_vec(), Object::Ref(n)); + let mut res = sub_dict(&src, &form.dict, b"Resources"); + res.set(b"Pattern".to_vec(), Object::Dict(xo)); + res.set(b"Bin".to_vec(), Object::String(PdfString::literal(vec![0u8, 1, 0xFE, 0xFF]))); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + src.set(n, Object::Stream(form)); + let (xml, dst) = round_trip_xfdf(&src); + assert!(xml.contains("")); + let d = named(&dst, "c"); + let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no form") }; + let res = sub_dict(&dst, &n.dict, b"Resources"); + assert_eq!(res.get(b"Bin"), Some(&Object::String(PdfString::literal(vec![0u8, 1, 0xFE, 0xFF])))); + // The reference back to the form ends there (as null, so the entry is left out). + assert!(sub_dict(&dst, &res, b"Pattern").is_empty()); +} + +#[test] +fn fdf_carries_appearances_with_their_resources() { + let mut src = pages(1); + let (content, pixels) = image_stamp(&mut src, "logo", "Stamp"); + put(&mut src, formatted_freetext()); + let fdf = export_fdf(&src, true, false, "src.pdf").unwrap(); + assert!(fdf.windows(4).any(|w| w == b"/AP "), "the FDF holds the appearance"); + let mut dst = pages(1); + import(&mut dst, &fdf).unwrap(); + let d = named(&dst, "logo"); + assert_eq!(appearance_of(&dst, &d), (content, pixels)); + // Formatting already travelled in FDF and still does. + let ft = named(&dst, "ft"); + assert_eq!((ft.int(b"Q"), ft.int(b"Rotate")), (Some(2), Some(90))); + assert_eq!(text(&dst, &ft, b"RC").as_deref(), Some(RICH)); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + assert!(Document::open(Arc::new(saved)).is_ok()); +} + +/// A shading's function sits a dozen levels below the comment (appearance, form, resources, +/// pattern, shading, function, functions, …); an FDF import nulled everything past 16 levels, so +/// a gradient stamp lost its colours. +#[test] +fn deeply_nested_appearance_resources_survive_fdf_and_xfdf() { + let mut src = pages(1); + image_stamp(&mut src, "deep", "Stamp"); + let d = named(&src, "deep"); + let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap(); + let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") }; + let mut inner = Object::Array(vec![Object::Real(0.25), Object::Real(0.5), Object::Real(0.75)]); + for _ in 0..20 { + let mut level = Dict::new(); + level.set(b"Next".to_vec(), inner); + inner = Object::Dict(level); + } + let mut res = sub_dict(&src, &form.dict, b"Resources"); + res.set(b"Pattern".to_vec(), inner); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + src.set(n, Object::Stream(form)); + let innermost = |doc: &Document| { + let d = named(doc, "deep"); + let n = doc.resolve(sub_dict(doc, &d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no form") }; + let mut at = sub_dict(doc, &n.dict, b"Resources").get(b"Pattern").cloned().unwrap(); + for _ in 0..20 { + at = at.as_dict().and_then(|d| d.get(b"Next")).cloned().unwrap_or(Object::Null); + } + f64s(doc, Some(&at)) + }; + let mut dst = pages(1); + import(&mut dst, &export_fdf(&src, true, false, "src.pdf").unwrap()).unwrap(); + assert_eq!(innermost(&dst), vec![0.25, 0.5, 0.75], "FDF"); + let (_, dst) = round_trip_xfdf(&src); + assert_eq!(innermost(&dst), vec![0.25, 0.5, 0.75], "XFDF"); +} + +#[test] +fn fdf_comments_off_the_document_copy_no_streams_in() { + let mut src = pages(3); + // A stamp on the third page, imported into a one-page document: nothing to place it on. + image_stamp(&mut src, "far", "Stamp"); + let r = xfdf::find_by_name(&src, "far").unwrap(); + let p2 = page_ref(&src, 2); + let p0 = page_ref(&src, 0); + src.update_dict(p0, |p| { + p.remove(b"Annots"); + }) + .unwrap(); + src.update_dict(p2, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(r)]))).unwrap(); + let fdf = export_fdf(&src, true, false, "src.pdf").unwrap(); + let mut dst = pages(1); + let before = dst.add(Object::Null); + assert!(import(&mut dst, &fdf).is_err(), "nothing in the file is for this document"); + let after = dst.add(Object::Null); + assert_eq!(after.num, before.num + 1, "the stamp's appearance and image were not copied in"); +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + /// Whatever formatting a free text and a line comment carry comes back the same. + #[test] + fn formatting_round_trips( + q in 0i64..3, + rotate in prop::sample::select(vec![0i64, 90, 180, 270]), + width in 0.0f64..20.0, + dashes in prop::collection::vec(0.5f64..30.0, 1..6), + fringe in prop::collection::vec(0.0f64..50.0, 4), + ll in -100.0f64..100.0, + lle in 0.0f64..50.0, + llo in 0.0f64..50.0, + ds in "[a-zA-Z0-9:;.# ]{0,40}", + ) { + // XFDF writes numbers to four decimals. + let r = |v: f64| (v * 10_000.0).round() / 10_000.0; + let mut src = pages(1); + let mut ft = formatted_freetext(); + ft.set(b"Q".to_vec(), Object::Int(q)); + ft.set(b"Rotate".to_vec(), Object::Int(rotate)); + ft.set(b"RD".to_vec(), nums(&fringe)); + ft.set(b"DS".to_vec(), PdfString::text(&ds)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(width)); + bs.set(b"S".to_vec(), Object::name("D")); + bs.set(b"D".to_vec(), nums(&dashes)); + ft.set(b"BS".to_vec(), Object::Dict(bs)); + put(&mut src, ft); + let mut ln = formatted_line(); + ln.set(b"LL".to_vec(), Object::Real(ll)); + ln.set(b"LLE".to_vec(), Object::Real(lle)); + ln.set(b"LLO".to_vec(), Object::Real(llo)); + put(&mut src, ln); + let (_, dst) = round_trip_xfdf(&src); + let ft = named(&dst, "ft"); + prop_assert_eq!(ft.int(b"Q"), Some(q)); + prop_assert_eq!(ft.int(b"Rotate"), Some(rotate)); + prop_assert_eq!(f64s(&dst, ft.get(b"RD")), fringe.iter().map(|v| r(*v)).collect::>()); + prop_assert_eq!(text(&dst, &ft, b"DS"), Some(ds.clone())); + let bs = sub_dict(&dst, &ft, b"BS"); + prop_assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(r(width))); + prop_assert_eq!(f64s(&dst, bs.get(b"D")), dashes.iter().map(|v| r(*v)).collect::>()); + let ln = named(&dst, "ln"); + prop_assert_eq!(ln.get(b"LL").and_then(Object::as_f64), Some(r(ll))); + prop_assert_eq!(ln.get(b"LLE").and_then(Object::as_f64), Some(r(lle))); + prop_assert_eq!(ln.get(b"LLO").and_then(Object::as_f64), Some(r(llo))); + } + + /// Any appearance dictionary of numbers, names, strings and nested data comes back the same. + #[test] + fn appearance_values_round_trip( + ints in prop::collection::vec(any::(), 0..8), + reals in prop::collection::vec(-1.0e6f64..1.0e6, 0..8), + names in prop::collection::vec("[A-Za-z#/ ]{1,12}", 0..5), + strings in prop::collection::vec(prop::collection::vec(any::(), 0..20), 0..5), + data in prop::collection::vec(any::(), 0..300), + ) { + let mut src = pages(1); + let mut extra = Dict::new(); + extra.set(b"Ints".to_vec(), Object::Array(ints.iter().map(|i| Object::Int(*i)).collect())); + extra.set(b"Reals".to_vec(), Object::Array(reals.iter().map(|v| Object::Real(*v)).collect())); + extra.set(b"Names".to_vec(), Object::Array(names.iter().map(|n| Object::Name(n.as_bytes().to_vec())).collect())); + extra.set(b"Strings".to_vec(), Object::Array(strings.iter().map(|s| Object::String(PdfString::literal(s.clone()))).collect())); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), nums(&[0.0, 0.0, 10.0, 10.0])); + form.set(b"Extra".to_vec(), Object::Dict(extra.clone())); + let n = src.add(Object::Stream(Stream { dict: form, raw: data.clone().into() })); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(n)); + let mut d = annot("Stamp", "p", [0.0, 0.0, 10.0, 10.0]); + d.set(b"AP".to_vec(), Object::Dict(ap)); + put(&mut src, d); + let (_, dst) = round_trip_xfdf(&src); + let d = named(&dst, "p"); + let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no appearance") }; + prop_assert_eq!(n.raw.as_ref(), data.as_slice()); + prop_assert_eq!(sub_dict(&dst, &n.dict, b"Extra"), extra); + } +} diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index e2137a98f0..1988786a37 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -1,6 +1,6 @@ use super::common::{ FLAGS, MAX_DEPTH, SUBTYPES, arr, check_form_xml_depth, csv, esc, hex, n, nums_of, parse_form_xml, parse_hex, quad_has_area, rect_strict, text_of, - walk_xfdf, + walk_xfdf, xml_nesting_depth, }; use super::*; @@ -99,6 +99,227 @@ fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { } } +/// Rich text deeper than this is written as text rather than as markup, so the XFDF it goes into +/// stays well inside the nesting the reader accepts (`MAX_XML_DEPTH`). +const MAX_RICH_DEPTH: usize = 512; +/// At most this many numbers in a `dashes` list. +const MAX_DASHES: usize = 64; + +/// Rich text (`/RC`) as `` holds it: its root element alone (no XML +/// declaration), when the text is well-formed XML; `None` otherwise. +fn rich_text_element(rc: &str) -> Option<&str> { + let t = rc.trim_start_matches('\u{feff}'); + if xml_nesting_depth(t) > MAX_RICH_DEPTH { + return None; + } + let parsed = parse_form_xml(t).ok()?; + t.get(parsed.root_element().range()) +} + +/// The formatting attributes of a comment: XFDF 3.0 "Border style attributes" (`width`, `style`, +/// `dashes`), "Border effect attributes" (`style="cloudy"`, `intensity`), and the free text +/// (`rotation`, `justification`), stamp (`rotation`), circle/square/caret (`fringe`), line +/// (`leaderLength`, `leaderExtend`, `leader-offset`, `caption`, `caption-style`, +/// `caption-offset-h`/`-v`), caret (`symbol`) and redaction (`overlay-text`, +/// `overlay-text-repeat`, `justification`) attributes. +fn format_attrs(doc: &Document, d: &Dict, sub: &[u8], attrs: &mut String) { + let num = |k: &[u8]| d.get(k).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite()); + let name = |k: &[u8]| d.get(k).and_then(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec)); + let list = |v: &[f64]| v.iter().map(|x| n(*x)).collect::>().join(","); + // The border: /BS, or (without one) the older /Border array [h v width dashes]. + let (width, style, dashes) = match d.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()) { + Some(bs) => ( + bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()), + bs.get(b"S").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)), + nums_of(doc, bs.get(b"D")), + ), + None => { + let border = d.get(b"Border").map(|b| doc.resolve(b)).and_then(|b| b.as_array().cloned()).unwrap_or_default(); + let dash = border.get(3).map(|x| nums_of(doc, Some(x))).unwrap_or_default(); + (border.get(2).and_then(|w| doc.resolve(w).as_f64()), (!dash.is_empty()).then(|| b"D".to_vec()), dash) + } + }; + if let Some(w) = width.filter(|w| w.is_finite()) { + let _ = write!(attrs, " width=\"{}\"", n(w)); + } + // One `style` attribute names both: a cloudy border effect (/BE /S /C) takes it. + let cloudy = d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice())); + if let Some(be) = cloudy { + attrs.push_str(" style=\"cloudy\""); + if let Some(i) = be.get(b"I").and_then(|i| doc.resolve(i).as_f64()).filter(|i| i.is_finite()) { + let _ = write!(attrs, " intensity=\"{}\"", n(i)); + } + } else if let Some(s) = match style.as_deref() { + Some(b"D") => Some("dash"), + Some(b"B") => Some("bevelled"), + Some(b"I") => Some("inset"), + Some(b"U") => Some("underline"), + _ => None, + } { + let _ = write!(attrs, " style=\"{s}\""); + } + // The dash array (/D) whatever the style, as `dashes` maps it; only `style="dash"` draws it. + if !dashes.is_empty() && dashes.iter().all(|v| v.is_finite()) { + let _ = write!(attrs, " dashes=\"{}\"", list(&dashes)); + } + if let Some(r) = d.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()) { + let _ = write!(attrs, " rotation=\"{r}\""); + } + if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { + // Free text names its justification; a redaction mark gives the number (XFDF 3.0). + match (sub, q) { + (b"FreeText", 0) => attrs.push_str(" justification=\"left\""), + (b"FreeText", 1) => attrs.push_str(" justification=\"centered\""), + (b"FreeText", 2) => attrs.push_str(" justification=\"right\""), + (b"FreeText", _) => {} + (_, q) => { + let _ = write!(attrs, " justification=\"{q}\""); + } + } + } + let rd = nums_of(doc, d.get(b"RD")); + if rd.len() == 4 && rd.iter().all(|v| v.is_finite()) { + let _ = write!(attrs, " fringe=\"{}\"", list(&rd)); + } + if let Some(ll) = num(b"LL") { + let _ = write!(attrs, " leaderLength=\"{}\"", n(ll)); + } + if let Some(lle) = num(b"LLE") { + let _ = write!(attrs, " leaderExtend=\"{}\"", n(lle)); + } + if let Some(llo) = num(b"LLO") { + let _ = write!(attrs, " leader-offset=\"{}\"", n(llo)); + } + if let Some(Object::Bool(cap)) = d.get(b"Cap").map(|o| doc.resolve(o)).as_deref() { + let _ = write!(attrs, " caption=\"{}\"", if *cap { "yes" } else { "no" }); + } + if let Some(cp) = name(b"CP") { + let _ = write!(attrs, " caption-style=\"{}\"", esc(&String::from_utf8_lossy(&cp))); + } + if let [h, v] = nums_of(doc, d.get(b"CO")).as_slice() + && h.is_finite() + && v.is_finite() + { + let _ = write!(attrs, " caption-offset-h=\"{}\" caption-offset-v=\"{}\"", n(*h), n(*v)); + } + match name(b"Sy").as_deref() { + Some(b"P") => attrs.push_str(" symbol=\"paragraph\""), + Some(b"None") => attrs.push_str(" symbol=\"none\""), + _ => {} + } + if let Some(t) = text_of(doc, d, b"OverlayText") { + let _ = write!(attrs, " overlay-text=\"{}\"", esc(&t)); + } + if let Some(Object::Bool(repeat)) = d.get(b"Repeat").map(|o| doc.resolve(o)).as_deref() { + let _ = write!(attrs, " overlay-text-repeat=\"{repeat}\""); + } +} + +/// The formatting attributes of an XFDF comment element back as dictionary entries (see +/// [`format_attrs`]). Values that are not numbers, or out of range, are left out. +fn read_format(node: roxmltree::Node, d: &mut Dict) { + let num = |a: &str| node.attribute(a).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let style = node.attribute("style").map(str::trim); + let dashes = node + .attribute("dashes") + .map(csv) + .filter(|v| !v.is_empty() && v.len() <= MAX_DASHES && v.iter().all(|x| *x >= 0.0) && v.iter().any(|x| *x > 0.0)); + let bs_style = match style { + Some("dash") => Some("D"), + Some("bevelled" | "beveled") => Some("B"), + Some("inset") => Some("I"), + Some("underline") => Some("U"), + Some("solid") => Some("S"), + _ => None, + }; + let width = num("width"); + if width.is_some() || bs_style.is_some() || dashes.is_some() { + let mut bs = Dict::new(); + // XFDF 3.0: the width is 1 and the style solid unless given. + bs.set(b"W".to_vec(), Object::Real(width.unwrap_or(1.0).max(0.0))); + bs.set(b"S".to_vec(), Object::name(bs_style.unwrap_or("S"))); + if let Some(dv) = &dashes { + bs.set(b"D".to_vec(), arr(dv)); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + } + if style == Some("cloudy") { + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + let intensity = num("intensity").unwrap_or(1.0); + be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0))); + d.set(b"BE".to_vec(), Object::Dict(be)); + } + if let Some(r) = node.attribute("rotation").and_then(|r| r.trim().parse::().ok()) { + d.set(b"Rotate".to_vec(), Object::Int(i64::from(r))); + } + let q = match node.attribute("justification").map(str::trim) { + Some("left" | "0") => Some(0), + Some("centered" | "center" | "1") => Some(1), + Some("right" | "2") => Some(2), + _ => None, + }; + if let Some(q) = q { + d.set(b"Q".to_vec(), Object::Int(q)); + } + if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { + d.set(b"RD".to_vec(), arr(&rd)); + } + if let Some(ll) = num("leaderLength") { + d.set(b"LL".to_vec(), Object::Real(ll)); + } + if let Some(lle) = num("leaderExtend").filter(|v| *v >= 0.0) { + d.set(b"LLE".to_vec(), Object::Real(lle)); + } + if let Some(llo) = num("leader-offset").filter(|v| *v >= 0.0) { + d.set(b"LLO".to_vec(), Object::Real(llo)); + } + match node.attribute("caption").map(str::trim) { + Some("yes" | "true") => d.set(b"Cap".to_vec(), Object::Bool(true)), + Some("no" | "false") => d.set(b"Cap".to_vec(), Object::Bool(false)), + _ => {} + } + if let Some(cp) = node.attribute("caption-style").map(str::trim).filter(|c| matches!(*c, "Inline" | "Top")) { + d.set(b"CP".to_vec(), Object::name(cp)); + } + let (h, v) = (num("caption-offset-h"), num("caption-offset-v")); + if h.is_some() || v.is_some() { + d.set(b"CO".to_vec(), arr(&[h.unwrap_or(0.0), v.unwrap_or(0.0)])); + } + match node.attribute("symbol").map(str::trim) { + Some("paragraph" | "P") => d.set(b"Sy".to_vec(), Object::name("P")), + Some("none" | "None") => d.set(b"Sy".to_vec(), Object::name("None")), + _ => {} + } + if let Some(t) = node.attribute("overlay-text") { + d.set(b"OverlayText".to_vec(), PdfString::text(t)); + } + match node.attribute("overlay-text-repeat").map(str::trim) { + Some("true" | "yes") => d.set(b"Repeat".to_vec(), Object::Bool(true)), + Some("false" | "no") => d.set(b"Repeat".to_vec(), Object::Bool(false)), + _ => {} + } +} + +/// A `` element's plain text, a line per paragraph: what `/Contents` gets +/// when the element comes without ``. +fn rich_plain(node: roxmltree::Node) -> String { + let mut out = String::new(); + for n in node.descendants() { + if n.is_element() && matches!(n.tag_name().name(), "p" | "div" | "li" | "br") { + if !out.is_empty() && !out.ends_with('\r') { + out.push('\r'); + } + } else if let Some(t) = n.text().filter(|_| n.is_text()) { + // Indentation between elements is not text. + if !(t.trim().is_empty() && t.contains('\n')) { + out.push_str(&t.replace("\r\n", "\r").replace('\n', "\r")); + } + } + } + out.trim_end_matches('\r').to_string() +} + /// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one /// (other tools leave /NM out), a name new to the document for this export, so a reply copied /// with it still finds it. @@ -135,15 +356,33 @@ fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option names } -/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices -/// and pop-up (on page `pi`). -fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { +/// The elements inside a comment's XFDF element: its text (plain and rich), default appearance +/// and style, ink, vertices, appearance (with `ap`) and pop-up (on page `pi`). +fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize, ap: bool) { if let Some(c) = text_of(doc, d, b"Contents") { let _ = write!(out, "{}", esc(&c)); } + if let Some(rc) = text_of(doc, d, b"RC") { + match rich_text_element(&rc) { + // Rich text is an XML (XHTML) document (ISO 32000-2 Table 172, RC): its element goes in as markup. + Some(markup) => { + let _ = write!(out, "{markup}"); + } + // Text that is not well-formed XML goes in as text, so nothing is lost. + None => { + let _ = write!(out, "{}", esc(&rc)); + } + } + } if let Some(da) = text_of(doc, d, b"DA") { let _ = write!(out, "{}", esc(&da)); } + if let Some(ds) = text_of(doc, d, b"DS") { + let _ = write!(out, "{}", esc(&ds)); + } + if ap && let Some(b64) = super::appearance::encode(doc, d) { + let _ = write!(out, "{b64}"); + } let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); if !ink.is_empty() { out.push_str(""); @@ -233,9 +472,7 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) { let _ = write!(attrs, " opacity=\"{}\"", n(o)); } - if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) { - let _ = write!(attrs, " width=\"{}\"", n(w)); - } + format_attrs(doc, &d, sub, &mut attrs); let quads = nums_of(doc, d.get(b"QuadPoints")); if !quads.is_empty() { let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(",")); @@ -254,10 +491,6 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u let cl = nums_of(doc, d.get(b"CL")); if cl.len() == 4 || cl.len() == 6 { let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); - let rd = nums_of(doc, d.get(b"RD")); - if rd.len() == 4 { - let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); - } if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { let _ = write!(attrs, " head=\"{}\"", esc(&le)); } @@ -266,15 +499,6 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u 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)); } - // A cloudy border (/BE /S /C) and how far its arcs bulge. - if let Some(be) = - d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice())) - { - attrs.push_str(" style=\"cloudy\""); - if let Some(i) = be.get(b"I").and_then(Object::as_f64) { - let _ = write!(attrs, " intensity=\"{}\"", n(i)); - } - } 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)); } @@ -282,7 +506,10 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys)); } let _ = write!(out, "<{tag}{attrs}>"); - xfdf_annot_children(doc, out, &d, pi); + // XFDF 3.0 carries a stamp's appearance, and none for a rect with no area; a copy and + // paste (`own`) carries every comment's, so the copy looks the same. + let has_area = rect.len() == 4 && rect[0] != rect[2] && rect[1] != rect[3]; + xfdf_annot_children(doc, out, &d, pi, has_area && (own || sub == b"Stamp")); let _ = writeln!(out, ""); } } @@ -659,12 +886,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) { d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0))); } - if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - } + read_format(node, &mut d); if let Some(q) = node.attribute("coords") { d.set(b"QuadPoints".to_vec(), arr(&csv(q))); } @@ -683,18 +905,8 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { d.set(b"IT".to_vec(), Object::name(it)); } - if node.attribute("style") == Some("cloudy") { - let mut be = Dict::new(); - be.set(b"S".to_vec(), Object::name("C")); - let intensity = node.attribute("intensity").and_then(|i| i.parse::().ok()).filter(|i| i.is_finite()).unwrap_or(1.0); - be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0))); - d.set(b"BE".to_vec(), Object::Dict(be)); - } 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)); - if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { - d.set(b"RD".to_vec(), arr(&rd)); - } d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); // A callout's /LE is one name, not the head/tail pair of a line. match node.attribute("head") { @@ -712,6 +924,9 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt "defaultappearance" => { d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec())); } + "defaultstyle" => { + d.set(b"DS".to_vec(), PdfString::text(&child.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::())); + } "inklist" => { let list: Vec = child .children() @@ -731,10 +946,38 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt _ => {} } } + if let Some(rich) = node.children().find(|c| c.has_tag_name("contents-richtext")) { + read_rich_text(rich, node.children().any(|c| c.has_tag_name("contents")), &mut d); + } read_own_keys(node, &mut d); Some(d) } +/// `` back as `/RC` (XFDF 3.0 "The contents and contents-richtext elements in +/// annotations"): its markup, as written in the file, after an XML declaration. Plain text in it is the comment's text +/// (`/Contents`) when the element comes alone; beside `` it is kept as `/RC` (rich text +/// PdfCraft exported as text because it was not well-formed XML). Markup without `` +/// gives `/Contents` its plain text too, so the comment's text shows where rich text is not read. +fn read_rich_text(rich: roxmltree::Node, has_contents: bool, d: &mut Dict) { + let elements: Vec = rich.children().filter(|c| c.is_element()).collect(); + let (Some(first), Some(last)) = (elements.first(), elements.last()) else { + let text: String = rich.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if has_contents { + d.set(b"RC".to_vec(), PdfString::text(&text)); + } else { + d.set(b"Contents".to_vec(), PdfString::text(&text.replace("\r\n", "\r").replace('\n', "\r"))); + } + return; + }; + let source = rich.document().input_text(); + let Some(markup) = source.get(first.range().start..last.range().end) else { return }; + // Rich text is an XML document of its own; export left its declaration out. + d.set(b"RC".to_vec(), PdfString::text(&format!("{markup}"))); + if !has_contents { + d.set(b"Contents".to_vec(), PdfString::text(&rich_plain(rich))); + } +} + /// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its /// reference. /// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a @@ -784,6 +1027,10 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result } } } + // An appearance the file carries is how the comment looked where it came from, and is kept + // (XFDF 3.0 "stamp": it takes precedence over the icon). A redaction mark is always drawn by + // PdfCraft, so what it marks for removal is shown. + let keep_appearance = d.name(b"Subtype") != Some(b"Redact".as_slice()) && super::appearance::has_appearance(doc, &d); let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())); let annots: Vec = doc .get(page_ref) @@ -827,8 +1074,10 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result } None => doc.add(Object::Dict(d)), }; - // Our own appearance where we can draw one; others keep none (viewers draw a default). - let _ = pdfcraft_annot::set_appearance(doc, r); + // Otherwise our own appearance where we can draw one; others keep none (viewers draw a default). + if !keep_appearance { + let _ = pdfcraft_annot::set_appearance(doc, r); + } kept.push(Object::Ref(r)); doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; Ok(r) @@ -885,6 +1134,7 @@ fn import_xfdf_with( return Err(DataError::UnknownFormat); } let mut report = Report::default(); + let mut appearance_budget = super::appearance::MAX_IMPORT_APPEARANCE_BYTES; let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); if let Some(p) = target_page && p >= pages.len() @@ -912,6 +1162,17 @@ fn import_xfdf_with( let own_page = node.attribute("page").and_then(|p| p.parse::().ok()); let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue }; let Some(mut d) = annot_from_xml(node, subtype, page) else { continue }; + // The appearance the file carries (XFDF 3.0 "appearance"), when it can be read. + if let Some(ap) = node.children().find(|c| c.has_tag_name("appearance")) { + let text: String = ap.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + match super::appearance::decode(&text, &mut appearance_budget) { + Ok(tree) => { + let ap = super::appearance::materialize(doc, tree); + d.set(b"AP".to_vec(), ap); + } + Err(why) => report.skipped.push(format!("{}: {why}", node.attribute("name").unwrap_or(&tag))), + } + } let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1)); if let Some(name) = &assigned { // A copy under a name of its own (one even where the original had none). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e3a3ff72c..0db748af2b 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"] -notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces" +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." [[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"] -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. Appearances and source-document links are not carried; FDF import semantics are unchanged. XFDF exports retain tabs and line breaks in comment identities and metadata." +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." [[feature]] id = "comment.export-selected" @@ -4358,8 +4358,8 @@ milestone = "M5" status = "partial" commands = ["comment.copy"] tools = ["comments_copy"] -evidence = ["crates/xfdf/src/tests.rs::exporting_selected_comments_takes_them_and_their_replies", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests.rs::copies_keep_their_intent_cloud_and_pdfcraft_keys"] -notes = "The selected comments, each with its replies, as XFDF: Copy puts it on the clipboard (desktop) and comments_copy returns it headlessly. Each keeps what it is meant as (`intent`: typewriter text, a cloud, an arrow), a cloudy border (`style`, `intensity`; both also in File ▸ Export comments) and PdfCraft's own keys (`pdfcraft:keys`, in a namespace of its own: a Fill & Sign item, a turned comment's box and angle), so a paste is the same comment. Not yet: a Save as… to a data file from the interface." +evidence = ["crates/xfdf/src/tests.rs::exporting_selected_comments_takes_them_and_their_replies", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests.rs::copies_keep_their_intent_cloud_and_pdfcraft_keys", "crates/xfdf/src/tests_format.rs::file_export_carries_only_stamp_appearances_and_copies_carry_every_one"] +notes = "The selected comments, each with its replies, as XFDF: Copy puts it on the clipboard (desktop) and comments_copy returns it headlessly. Each keeps what it is meant as (`intent`: typewriter text, a cloud, an arrow), a cloudy border (`style`, `intensity`; both also in File ▸ Export comments) and PdfCraft's own keys (`pdfcraft:keys`, in a namespace of its own: a Fill & Sign item, a turned comment's box and angle), its formatting (rich text, default style, border style and dashes, rotation, justification, fringe, line leaders) and its appearance (``, for every comment type, not only stamps), so a paste is the same comment. Not yet: a Save as… to a data file from the interface." [[feature]] id = "comment.multi-select" @@ -4382,8 +4382,8 @@ milestone = "M5" status = "shipped" commands = ["comment.copy", "comment.paste"] tools = ["comments_copy", "comments_paste"] -evidence = ["crates/ui-egui/tests/it/comments.rs::copied_comments_paste_into_another_document", "crates/ui-egui/tests/it/comments.rs::copied_comments_take_the_click_from_the_tool_that_is_on", "crates/engine/src/tests.rs::pasting_copied_comments_adds_copies_that_undo_as_one_step", "crates/xfdf/src/tests.rs::importing_comments_as_copies_adds_them_on_the_page_with_fresh_names", "crates/xfdf/src/tests.rs::copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools"] -notes = "⌘C with comments selected (and no text selected) copies them as XFDF, each with its replies, to the app's clipboard (every tab and window) and as text to the system clipboard. ⌘V or Edit ▸ Paste comments hangs the copies on the pointer as one bundle (their box, with a count, and each comment's outline in its colour: Fill & Sign ticks, crosses, dots and lines as themselves, text boxes with their text); a click places them, centred there, on the page clicked (whatever tool is on: Fill & Sign's Text tool opens no box, a stamp tool places no stamp), so a copy never lands on its original and a bundle from an A0 page goes where it fits on an A4 one (placed as it is when larger than the page, with a notice); Escape or a right-click lets the bundle go. The page's context menu places the bundle at the point the menu was opened at. The copies are new comments under names of their own (nothing is replaced; pasting twice gives two sets) and are selected afterwards. XFDF with comments on the system clipboard pastes too, so comments travel between PdfCraft processes. The Comments panel's card menu copies that card's comment. Headless: comments_copy / comments_paste (`at` places the bundle's centre), undoable as one step. Not yet: appearance streams (the copies are drawn again), file attachments' data (the attachment comment is copied without its file), pasting in the web build's other tabs through the system clipboard." +evidence = ["crates/ui-egui/tests/it/comments.rs::copied_comments_paste_into_another_document", "crates/ui-egui/tests/it/comments.rs::copied_comments_take_the_click_from_the_tool_that_is_on", "crates/engine/src/tests.rs::pasting_copied_comments_adds_copies_that_undo_as_one_step", "crates/xfdf/src/tests.rs::importing_comments_as_copies_adds_them_on_the_page_with_fresh_names", "crates/xfdf/src/tests.rs::copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests_format.rs::file_export_carries_only_stamp_appearances_and_copies_carry_every_one"] +notes = "⌘C with comments selected (and no text selected) copies them as XFDF, each with its replies, to the app's clipboard (every tab and window) and as text to the system clipboard. ⌘V or Edit ▸ Paste comments hangs the copies on the pointer as one bundle (their box, with a count, and each comment's outline in its colour: Fill & Sign ticks, crosses, dots and lines as themselves, text boxes with their text); a click places them, centred there, on the page clicked (whatever tool is on: Fill & Sign's Text tool opens no box, a stamp tool places no stamp), so a copy never lands on its original and a bundle from an A0 page goes where it fits on an A4 one (placed as it is when larger than the page, with a notice); Escape or a right-click lets the bundle go. The page's context menu places the bundle at the point the menu was opened at. The copies are new comments under names of their own (nothing is replaced; pasting twice gives two sets) and are selected afterwards. XFDF with comments on the system clipboard pastes too, so comments travel between PdfCraft processes. The Comments panel's card menu copies that card's comment. Headless: comments_copy / comments_paste (`at` places the bundle's centre), undoable as one step. Each copy keeps its formatting and its appearance stream, so it looks as the original did. Not yet: file attachments' data (the attachment comment is copied without its file), pasting in the web build's other tabs through the system clipboard." [[feature]] id = "comment.import-from-pdf"