diff --git a/Cargo.lock b/Cargo.lock index 341bbdcdf0..ee3344120a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4836,10 +4836,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 1359e5e02c..4cda608928 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. +- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. + - **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **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-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 265104c657..c5444c35e0 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -165,6 +165,47 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } } +/// What a comment import left out of comments it did import (an appearance it could not read), +/// in the repair log as one line: said, not slipped in. The first five are named, each in at +/// most [`SKIPPED_CHARS`] characters with control characters escaped (a name from the file +/// holding a line break would otherwise read as a repair of its own). +fn note_skipped(doc: &mut pdfcraft_cos::Document, report: &pdfcraft_xfdf::Report) { + if report.skipped.is_empty() { + return; + } + let shown: Vec = report.skipped.iter().take(5).map(|s| skipped_text(s)).collect(); + let more = report.skipped.len().saturating_sub(shown.len()); + let more = if more > 0 { format!("; and {more} more") } else { String::new() }; + doc.note_repair(format!( + "imported {} comment{} without the appearance the file gave {} (PdfCraft draws it where it can; it may look different or not show): {}{more}", + report.skipped.len(), + if report.skipped.len() == 1 { "" } else { "s" }, + if report.skipped.len() == 1 { "it" } else { "them" }, + shown.join("; ") + )); +} + +/// At most this many characters of one skipped comment's description in the repair log. +const SKIPPED_CHARS: usize = 200; + +/// `s` for one line of the repair log: control characters and line separators escaped, cut to +/// [`SKIPPED_CHARS`] characters. +fn skipped_text(s: &str) -> String { + let mut out = String::new(); + for (n, c) in s.chars().enumerate() { + if n == SKIPPED_CHARS { + out.push('…'); + break; + } + if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { + out.extend(c.escape_default()); + } else { + out.push(c); + } + } + out +} + /// Dates and unique ids stamped onto what an edit creates. pub(crate) struct EditCtx { /// Field scripts run (`None`: JavaScript is off) and what they produced. @@ -514,6 +555,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E if report.comments == 0 { return Err(EditError::Invalid("the clipboard holds no comments".into())); } + note_skipped(doc, &report); } Edit::AddCustomStamp { page, rect, name, file, author } => { let src = mark_source(doc, file)?; @@ -874,7 +916,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // the data importer's error. let mut data = doc.clone(); match pdfcraft_xfdf::import(&mut data, bytes) { - Ok(_) => *doc = data, + Ok(report) => { + note_skipped(&mut data, &report); + *doc = data; + } Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) { Ok(src) => import_pdf_comments(doc, &src)?, Err(SourceProblem::Unreadable(_)) => return Err(e.into()), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e75041fbbb..730e551523 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11838,3 +11838,49 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// A comment imported or pasted without the appearance its file carried (unreadable there) is +/// said in the repair log, which the interface shows and the tools return. +#[test] +fn a_comment_imported_without_its_appearance_is_said_in_the_repair_log() { + let xfdf = r#"@@@not base 64@@@"#; + let said = |s: &Session, id: DocId| { + s.get(id) + .unwrap() + .repair_log() + .iter() + .filter(|l| l.contains("imported 1 comment without the appearance") && l.contains("st: the appearance is not base 64")) + .count() + }; + let (mut s, id) = session_with(1); + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(said(&s, id), 1, "{:?}", s.get(id).unwrap().repair_log()); + let (mut s, id) = session_with(1); + s.apply(id, Edit::PasteComments { xfdf: xfdf.into(), page: 0, offset: None }).unwrap(); + assert_eq!(said(&s, id), 1, "{:?}", s.get(id).unwrap().repair_log()); +} + +/// Six comments without their appearances: one line naming five and counting the sixth, a name +/// holding a line break stays on that line, and a long name is cut short. +#[test] +fn many_comments_imported_without_their_appearances_are_summed_up_in_one_line() { + let long = "L".repeat(1000); + let names = ["a imported 9 comments", "b", "c", "d", long.as_str(), "f"]; + let stamps: String = names + .iter() + .map(|nm| format!(r#"@@@"#)) + .collect(); + let xfdf = format!(r#"{stamps}"#); + let (mut s, id) = session_with(1); + let before = s.get(id).unwrap().repair_log().len(); + s.apply(id, Edit::PasteComments { xfdf, page: 0, offset: None }).unwrap(); + let log = s.get(id).unwrap().repair_log(); + let added = log.get(before..).unwrap_or_default(); + assert_eq!(added.len(), 1, "{added:?}"); + let line = &added[0]; + assert!(line.starts_with("imported 6 comments without the appearance"), "{line}"); + assert!(line.ends_with("; and 1 more"), "{line}"); + assert!(!line.contains('\n') && line.contains(r"a\nimported 9 comments: "), "{line}"); + assert!(line.contains(&format!("{}…", "L".repeat(200))) && !line.contains(&"L".repeat(201)), "{line}"); + assert_eq!(line.matches(": the appearance").count(), 4, "five named, the long one cut before its reason: {line}"); +} 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..0c823c2ee0 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -14,9 +14,30 @@ 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`. + The intent (`/IT`) is written as both `intent` and `IT`, and either is read. +- **Appearances** (``, `appearance.rs`): a stamp's `/AP`, as XFDF 3.0 defines it for + stamps, base 64 of the dictionary written as `DICT`/`STREAM`/`ARRAY`/`NAME`/`INT`/`FIXED`/`BOOL`/ + `STRING`/`NULL` elements with stream data in `DATA` (XFDF 3.0 "Stream encoding": `filtered`/`ascii` + or `raw`/`hex`), the shape XFDF files from other tools carry. Copy and paste carries every + comment's appearance but a redaction mark's, which PdfCraft always draws and import never + reads. Import keeps an appearance it can read and otherwise draws the comment and records the reason in + `Report::skipped` (the engine puts it in the repair log); 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, and stops at the 64 MiB an import reads; where `/AS` selects one of several + states, the selected stream is written (and import, with no `/AS` in XFDF, refuses a normal + appearance of several states). Import and FDF import are all or nothing: on an error the + document is left as it was. - **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. A stamp's appearance + (`/AP`, with the forms and images it draws) is copied, as in XFDF, and an `/AP` in the file comes + back on import as objects of the document (a stream shared by several comments once); document links are excluded. Source `/Page` and `/Pages` dictionaries become null at every copy depth, so nested destination actions cannot carry the source page tree or content. Type names may be indirect; lookup checks each reference generation, is iterative, stops at cycles and refuses chains beyond @@ -28,7 +49,9 @@ 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, and + refuses the file (nothing imported) when its comments expand past 1,000,000 values or 64 MiB, or + nest more than 256 levels, rather than import part of an appearance. - **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 +67,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..3690a19f42 --- /dev/null +++ b/crates/xfdf/src/appearance.rs @@ -0,0 +1,377 @@ +//! 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). +/// +/// XFDF has no appearance state (`/AS`), so where `/N`, `/R` or `/D` holds one stream per state +/// only the selected state's stream is written (ISO 32000-2 §12.5.5: `/AS` picks it); a normal +/// appearance with no selected stream has nothing to draw and nothing is written. +/// +/// `budget` is what the export may still write (decoded XML), shared by all of its comments, as +/// import reads at most [`MAX_IMPORT_APPEARANCE_BYTES`]: an appearance past it is left out rather +/// than written for import to skip. It is charged for what is written. +pub(crate) fn encode(doc: &Document, d: &Dict, budget: &mut usize) -> Option { + if !has_appearance(doc, d) { + return None; + } + let ap = d.get(b"AP").map(|a| doc.resolve(a))?; + let mut ap = ap.as_dict()?.clone(); + let state = d.get(b"AS").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)); + for key in [b"N".as_slice(), b"R", b"D"] { + let Some(entry) = ap.get(key).map(|e| doc.resolve(e)) else { continue }; + let Object::Dict(states) = entry.as_ref() else { continue }; + match state.as_deref().and_then(|s| states.get(s)).cloned() { + Some(selected) => ap.set(key.to_vec(), selected), + None if key == b"N" => return None, + None => { + ap.remove(key); + } + } + } + let limit = MAX_APPEARANCE_BYTES.min(*budget); + let mut writer = Writer { doc, out: String::new(), values: MAX_VALUES, path: Vec::new(), limit }; + writer.dict(Some(b"AP"), &ap, 0)?; + // The checks while writing leave room for the closing tags and line breaks: the finished XML + // must fit what the reader accepts, or the comment would arrive without its appearance. + if writer.out.len() > limit { + return None; + } + *budget = budget.saturating_sub(writer.out.len()); + 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, + /// At most this much XML. + limit: usize, +} + +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() > self.limit { + 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"); + } + // A page (or the page tree) reached from the appearance is the source document's, not + // the comment's: written in place it would carry every page along. + // `/Type` may itself be a reference (`resolve` follows a bounded number of them). + Object::Dict(d) if d.get(b"Type").is_some_and(|t| matches!(self.doc.resolve(t).as_name(), Some(b"Page" | b"Pages"))) => { + self.out.push_str(&format!("\n")); + } + Object::Dict(d) => self.dict(key, d, depth)?, + Object::Stream(s) => { + // XFDF 3.0 counts a carriage return as printable, but XML reads one, alone or before + // a line feed, as a line feed (XML 1.0 §2.11): data holding one is written as + // hexadecimal so it comes back byte for byte (the 4 KiB rule "is not a rule"). + let printable = s.raw.iter().all(|&b| (b > 32 && b < 127) || b == b'\n'); + let ascii = s.raw.len() < ASCII_DATA_LIMIT && printable && !s.dict.contains(b"Filter"); + // The data, in the encoding it is written in, must fit what is left of the budget + // (as text it is at least its own length; the finished XML is checked again). + let size = if ascii { s.raw.len() } else { s.raw.len().saturating_mul(2) }; + if size.saturating_add(self.out.len()) > self.limit { + 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)?; + } + if ascii { + // 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 one stream: XFDF has no appearance state (`/AS`), so a +/// dictionary of states would leave nothing selected to draw (ISO 32000-2 §12.5.5). +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" && matches!(v, Node::Stream(..))), + _ => 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..dffd3f9098 100644 --- a/crates/xfdf/src/fdf.rs +++ b/crates/xfdf/src/fdf.rs @@ -86,13 +86,16 @@ 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. A stamp's appearance (`/AP`) is + /// copied with its streams and resources, so the stamp looks the same where it is imported; + /// other comments carry their formatting entries and leave the appearance out, as XFDF does. + /// The dictionary and every retained entry are charged before any owned copy is made. fn annotation_dict(&mut self, dict: &Dict) -> Result { self.charge_value(0)?; + let stamp = dict.name(b"Subtype") == Some(b"Stamp".as_slice()); let mut out = Dict::new(); for (key, value) in dict.iter() { - if matches!(key.as_slice(), b"P" | b"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") || (key.as_slice() == b"AP" && !stamp) { continue; } self.dict_entry(&mut out, key, value, 1)?; @@ -202,7 +205,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, a stamp with its appearance. Each comment is an /// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there /// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs. /// Source `/Page` and `/Pages` dictionaries become null at every copy depth. @@ -356,27 +359,69 @@ 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, +} + +/// How deeply [`deep`] follows a comment's objects, references included. A form that draws a form +/// costs four levels (the reference, the stream's dictionary, `/Resources`, `/XObject`), and +/// export writes a chain of them however long, so this leaves room for one sixty forms deep. +const MAX_COPY_DEPTH: usize = 4 * MAX_DEPTH; + /// 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. Objects nested past [`MAX_COPY_DEPTH`] are +/// an error, not a partial copy: the comment would arrive with part of its appearance missing. +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 { - return Ok(Object::Null); + if depth > MAX_COPY_DEPTH { + return Err(DataError::Malformed(format!("a comment's objects nest more than {MAX_COPY_DEPTH} levels deep"))); } Ok(match o { Object::Ref(r) => { 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::>()?), + // A page or page tree the comment reaches is the file's, not the comment's: copied in, it + // would bring its contents along (as export, which nulls them; `resolve` bounds the hops + // of an indirect /Type). + Object::Dict(d) if d.get(b"Type").is_some_and(|t| matches!(src.resolve(t).as_name(), Some(b"Page" | b"Pages"))) => Object::Null, 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 +514,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"); @@ -479,8 +533,19 @@ 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..984d1cdbe5 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). @@ -330,8 +338,15 @@ fn tab_delimited(t: &str) -> Result)>, DataError> { } /// Import comments and/or field values from XFDF, FDF, XML, CSV or tab-delimited text -/// (detected from the content). +/// (detected from the content). All or nothing: on an error `doc` is left as it was. pub fn import(doc: &mut Document, bytes: &[u8]) -> Result { + let mut work = doc.clone(); + let report = import_into(&mut work, bytes)?; + *doc = work; + Ok(report) +} + +fn import_into(doc: &mut Document, bytes: &[u8]) -> Result { let report = if bytes.starts_with(b"%FDF") { import_fdf(doc, bytes)? } else { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 52ce9e9508..2c1c06c988 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -567,8 +567,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); } @@ -2284,6 +2286,19 @@ fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() { assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy"); } +/// Comment import never takes the file's field values, whatever the placement (a non-finite +/// offset, which is ignored, and no page or prefix made it the full import). +#[test] +fn comment_import_leaves_field_values_alone() { + let xfdf = r#"from the file"#; + for offset in [Some([f64::NAN, 0.0]), None] { + let mut doc = one_field_named("Name", "mine"); + let r = import_comments(&mut doc, xfdf, None, None, offset).unwrap(); + assert_eq!((r.comments, r.fields), (1, 0), "{offset:?}: {r:?}"); + assert_eq!(values(&doc), [("Name".to_string(), vec!["mine".to_string()])], "{offset:?}"); + } +} + /// Other tools write comments straight into the page's `/Annots` array, without an object of /// their own: they copy like any other, and two source comments sharing a name give two copies. #[test] diff --git a/crates/xfdf/src/tests_format.rs b/crates/xfdf/src/tests_format.rs new file mode 100644 index 0000000000..35a9735ede --- /dev/null +++ b/crates/xfdf/src/tests_format.rs @@ -0,0 +1,1216 @@ +//! 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 fdf_carries_only_stamp_appearances() { + let mut src = pages(1); + image_stamp(&mut src, "sq", "Square"); + let mut ft = formatted_freetext(); + ft.set(b"NM".to_vec(), PdfString::text("box")); + let ft = put(&mut src, ft); + let n = sub_dict(&src, &named(&src, "sq"), b"AP"); + src.update_dict(ft, |d| d.set(b"AP".to_vec(), Object::Dict(n.clone()))).unwrap(); + let fdf = export_fdf(&src, true, false, "src.pdf").unwrap(); + assert!(!fdf.windows(3).any(|w| w == b"/AP"), "a text box and a square carry their formatting, not their appearance"); + let mut dst = pages(1); + import(&mut dst, &fdf).unwrap(); + let ft = named(&dst, "box"); + assert_eq!((ft.int(b"Q"), ft.int(b"Rotate")), (Some(2), Some(90))); + assert_eq!(text(&dst, &ft, b"DS").as_deref(), text(&src, &formatted_freetext(), b"DS").as_deref()); + let mut stamp = pages(1); + image_stamp(&mut stamp, "st", "Stamp"); + assert!(export_fdf(&stamp, true, false, "src.pdf").unwrap().windows(3).any(|w| w == b"/AP")); +} + +/// The intent (/IT) goes out under both spellings XFDF files use, and either comes back. +#[test] +fn the_intent_is_written_and_read_as_intent_or_it() { + let mut src = pages(1); + let mut d = annot("FreeText", "tw", [10.0, 10.0, 90.0, 30.0]); + d.set(b"IT".to_vec(), Object::name("FreeTextTypewriter")); + put(&mut src, d); + let xml = export_xfdf(&src, true, false, "src.pdf"); + assert!(xml.contains(r#"intent="FreeTextTypewriter" IT="FreeTextTypewriter""#), "{xml}"); + let only_it = r#" + + /Helvetica 9 Tffont: Helvetica ,sans-serif 9.00pt;color:#FF0000 + +"#; + let mut dst = pages(1); + import(&mut dst, only_it.as_bytes()).unwrap(); + let a = named(&dst, "a"); + assert_eq!(a.name(b"IT"), Some(b"FreeTextTypewriter".as_slice())); + assert_eq!((a.int(b"Q"), a.int(b"Rotate"), text(&dst, &a, b"Subj").as_deref()), (Some(0), Some(0), Some("Typewriter"))); + assert_eq!(f64s(&dst, a.get(b"Rect")), vec![71.0004, 737.255, 138.977, 747.299]); + assert_eq!(named(&dst, "b").name(b"IT"), Some(b"FreeTextCallout".as_slice())); +} + +#[test] +fn a_stamp_without_area_exports_no_appearance() { + let mut src = pages(1); + 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)), + ( + "states with none selected", + b64(r#"q Q"#), + ), + ] { + 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 an_appearance_that_names_its_page_does_not_carry_the_page_along() { + let mut src = pages(1); + image_stamp(&mut src, "pg", "Stamp"); + let d = named(&src, "pg"); + let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap(); + let page = page_ref(&src, 0); + let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") }; + form.dict.set(b"PieceInfo".to_vec(), Object::Ref(page)); + src.set(n, Object::Stream(form)); + let xml = export_xfdf(&src, true, false, "src.pdf"); + let b64: String = xml.split("").nth(1).unwrap().split("").next().unwrap().split_whitespace().collect(); + let inner = String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap(); + assert!(inner.contains(r#""#), "{inner}"); + assert!(!inner.contains("Kids") && !inner.contains("MediaBox")); +} + +#[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); + } +} + +/// A stamp on page 0 whose normal appearance is one form stream holding `content`. +fn content_stamp(doc: &mut Document, nm: &str, content: Vec) -> ObjRef { + 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, 100.0, 50.0])); + let n = doc.add(Object::Stream(Stream { dict: form, raw: content.into() })); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(n)); + let mut d = annot("Stamp", nm, [100.0, 500.0, 200.0, 550.0]); + d.set(b"AP".to_vec(), Object::Dict(ap)); + put(doc, d); + n +} + +/// The `` XML export wrote for the first comment of `xml`. +fn exported_appearance(xml: &str) -> String { + let b64: String = xml.split("").nth(1).unwrap().split("").next().unwrap().split_whitespace().collect(); + String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap() +} + +/// The write-time checks leave out the closing tags and the hexadecimal line breaks: an appearance +/// just under the limit there came out over it, and import then refused what export wrote. +#[test] +fn an_exported_appearance_always_fits_what_import_reads() { + let mut src = pages(1); + content_stamp(&mut src, "big", vec![0u8; (appearance::MAX_APPEARANCE_BYTES / 2) - 4096]); + let d = named(&src, "big"); + if let Some(text) = appearance::encode(&src, &d, &mut appearance::MAX_IMPORT_APPEARANCE_BYTES.clone()) { + let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES; + appearance::decode(&text, &mut budget).expect("what export writes, import reads"); + } + // Just under the limit once the line breaks are counted (one per 80 hexadecimal digits): it + // is written, and read back. + let mut src = pages(1); + content_stamp(&mut src, "near", vec![0u8; appearance::MAX_APPEARANCE_BYTES / 2 * 80 / 81 - 4096]); + let d = named(&src, "near"); + let text = appearance::encode(&src, &d, &mut appearance::MAX_IMPORT_APPEARANCE_BYTES.clone()).expect("under the limit"); + let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES; + appearance::decode(&text, &mut budget).expect("what export writes, import reads"); +} + +/// A page whose `/Type` is a reference is still the source page: written in place it would carry +/// the page's contents into a file meant to hold one comment. +#[test] +fn a_page_with_an_indirect_type_stays_out_of_an_exported_appearance() { + let mut src = pages(1); + let n = content_stamp(&mut src, "pg", b"q Q".to_vec()); + let page = page_ref(&src, 0); + let page_type = src.add(Object::name("Page")); + src.update_dict(page, |p| p.set(b"Type".to_vec(), Object::Ref(page_type))).unwrap(); + let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") }; + form.dict.set(b"PieceInfo".to_vec(), Object::Ref(page)); + src.set(n, Object::Stream(form)); + let inner = exported_appearance(&export_xfdf(&src, true, false, "src.pdf")); + assert!(inner.contains(r#""#), "{inner}"); + assert!(!inner.contains("MediaBox"), "{inner}"); +} + +/// XML reads a carriage return as a line feed, so data holding one goes out as hexadecimal. +#[test] +fn stream_data_with_carriage_returns_comes_back_byte_for_byte() { + let mut src = pages(1); + let data = b"q\r/Im0\rDo\r\nQ\r".to_vec(); + content_stamp(&mut src, "cr", data.clone()); + let (_, dst) = round_trip_xfdf(&src); + let d = named(&dst, "cr"); + 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_eq!(n.raw.as_ref(), data.as_slice()); +} + +/// XFDF has no appearance state: the stream `/AS` selects is what goes out. +#[test] +fn the_selected_appearance_state_is_what_xfdf_carries() { + let mut src = pages(1); + let on = content_stamp(&mut src, "st", b"0 0 1 rg 0 0 100 50 re f".to_vec()); + let off = src.add(Object::Stream(Stream { dict: Dict::new(), raw: b"%off".to_vec().into() })); + let mut states = Dict::new(); + states.set(b"On".to_vec(), Object::Ref(on)); + states.set(b"Off".to_vec(), Object::Ref(off)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(states.clone())); + ap.set(b"D".to_vec(), Object::Dict(states)); + let r = xfdf::find_by_name(&src, "st").unwrap(); + src.update_dict(r, |d| { + d.set(b"AP".to_vec(), Object::Dict(ap.clone())); + d.set(b"AS".to_vec(), Object::name("On")); + }) + .unwrap(); + let (_, dst) = round_trip_xfdf(&src); + let d = named(&dst, "st"); + let ap = sub_dict(&dst, &d, b"AP"); + for key in [b"N".as_slice(), b"D"] { + let s = dst.resolve(ap.get(key).unwrap()); + let Object::Stream(s) = s.as_ref() else { panic!("{key:?} is not the selected stream: {s:?}") }; + assert_eq!(s.raw.as_ref(), b"0 0 1 rg 0 0 100 50 re f"); + } + // A state no stream answers to has nothing to draw: no appearance goes out. + src.update_dict(r, |d| d.set(b"AS".to_vec(), Object::name("Gone"))).unwrap(); + assert!(!export_xfdf(&src, true, false, "src.pdf").contains("")); +} + +/// Forms that draw forms: FDF export writes the chain however long, and import copied only the +/// first dozen and nulled the rest without a word. A chain sixty forms deep comes back whole; one +/// deeper than import follows is refused rather than imported in part. +#[test] +fn a_chain_of_forms_comes_back_whole_from_fdf_or_is_refused() { + let chain = |forms: usize| { + let mut src = pages(1); + put(&mut src, annot("Text", "first", [10.0, 10.0, 30.0, 30.0])); + let mut inner = src.add(Object::Stream(Stream { dict: Dict::new(), raw: b"%innermost".to_vec().into() })); + for _ in 0..forms { + let mut xo = Dict::new(); + xo.set(b"Fm".to_vec(), Object::Ref(inner)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"Resources".to_vec(), Object::Dict(res)); + inner = src.add(Object::Stream(Stream { dict: form, raw: b"/Fm Do".to_vec().into() })); + } + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(inner)); + let mut d = annot("Stamp", "chain", [100.0, 500.0, 200.0, 550.0]); + d.set(b"AP".to_vec(), Object::Dict(ap)); + put(&mut src, d); + export_fdf(&src, true, false, "src.pdf").unwrap() + }; + let mut dst = pages(1); + import(&mut dst, &chain(60)).unwrap(); + let d = named(&dst, "chain"); + let mut at = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap()); + for level in 0..60 { + let Object::Stream(s) = at.as_ref() else { panic!("level {level}: {at:?}") }; + let fm = sub_dict(&dst, &sub_dict(&dst, &s.dict, b"Resources"), b"XObject").get(b"Fm").cloned().unwrap_or(Object::Null); + at = dst.resolve(&fm); + } + let Object::Stream(innermost) = at.as_ref() else { panic!("the innermost form was lost: {at:?}") }; + assert_eq!(innermost.raw.as_ref(), b"%innermost"); + let mut dst = pages(1); + let before = dst.add(Object::Null); + let err = import(&mut dst, &chain(80)).unwrap_err(); + assert!(err.to_string().contains("nest more than"), "{err}"); + // All or nothing: the note before the stamp, and the forms copied so far, are not left behind. + assert_eq!(dst.add(Object::Null).num, before.num + 1); + assert!(xfdf::find_by_name(&dst, "first").is_none()); +} + +/// A `/P` an FDF comment holds points into the FDF; it is replaced, and nothing it reaches is +/// copied into the document. +#[test] +fn an_fdf_comments_page_link_copies_nothing_in() { + let fdf = |p: &str| { + format!( + "%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [2 0 R] >> >>\nendobj\n2 0 obj\n<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (t) /Page 0{p} >>\nendobj\n3 0 obj\n<< /Type /Page /Junk 4 0 R >>\nendobj\n4 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + ) + .into_bytes() + }; + let added = |bytes: &[u8]| { + let mut dst = pages(1); + let before = dst.add(Object::Null); + import(&mut dst, bytes).unwrap(); + let p = named(&dst, "t").reference(b"P"); + assert_eq!(p, Some(page_ref(&dst, 0))); + dst.add(Object::Null).num - before.num + }; + assert_eq!(added(&fdf(" /P 3 0 R")), added(&fdf("")), "the FDF's page and its stream were not copied in"); +} + +/// Two line breaks in a row leave a blank line in the comment's plain text. +#[test] +fn consecutive_rich_text_line_breaks_are_kept() { + let xml = r#" +

one

two


three

four

+"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + assert_eq!(text(&dst, &named(&dst, "b"), b"Contents").as_deref(), Some("one\r\rtwo\r\rthree\rfour")); +} + +/// Rich text that is not XML, on a comment with no plain text, comes back as rich text, not as +/// the comment's text (a line would otherwise show the markup as its caption). +#[test] +fn rich_text_alone_that_is_not_xml_comes_back_as_rich_text() { + let mut src = pages(1); + let mut d = annot("Line", "ln", [10.0, 10.0, 90.0, 30.0]); + d.set(b"L".to_vec(), nums(&[10.0, 20.0, 90.0, 20.0])); + d.set(b"RC".to_vec(), PdfString::text("

unclosed")); + put(&mut src, d); + let (_, dst) = round_trip_xfdf(&src); + let d = named(&dst, "ln"); + assert_eq!(text(&dst, &d, b"RC").as_deref(), Some("

unclosed")); + assert_ne!(text(&dst, &d, b"Contents").as_deref(), Some("

unclosed")); +} + +/// A prefix the rich text inherits from the XFDF around it is declared on it, so the rich text +/// is XML of its own and goes out again as markup. +#[test] +fn rich_text_keeps_the_namespaces_it_inherits() { + let xml = r#" +hi

hi

+"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + let rc = text(&dst, &named(&dst, "ns"), b"RC").unwrap(); + assert!(common::parse_form_xml(&rc).is_ok(), "{rc}"); + let out = export_xfdf(&dst, true, false, "dst.pdf"); + assert!( + out.contains(r#"%nothing"#, + ); + for extra in [r#" style="cloudy""#, r#" overlay-text="日本語""#] { + let xml = appearance_xfdf(&inner, "redact").replace(r#"name="x""#, &format!(r#"name="x"{extra}"#)); + let mut dst = pages(1); + let before = dst.add(Object::Null); + import(&mut dst, xml.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!("{extra}: no appearance") }; + let drawn = n.decoded().unwrap(); + assert_ne!(drawn, b"%nothing", "{extra}"); + assert!(String::from_utf8_lossy(&drawn).contains("h S"), "{extra}: the outline is stroked: {}", String::from_utf8_lossy(&drawn)); + assert!(!d.contains(b"BE"), "{extra}"); + // The file's appearance was not read in at all. + for num in before.num..dst.add(Object::Null).num { + if let Object::Stream(s) = dst.get(ObjRef::new(num, 0)).as_ref() { + assert_ne!(s.raw.as_ref(), b"%nothing", "{extra}"); + } + } + } +} + +/// Appearances share one budget over the whole import: past it, the rest are skipped. +#[test] +fn the_import_budget_is_shared_by_every_comment() { + let one = b64(r#"0 0 m 1 1 l S"#); + let size = base64::engine::general_purpose::STANDARD.decode(&one).unwrap().len(); + let stamps: String = ["a", "b", "c"] + .iter() + .map(|nm| format!(r#"{one}"#)) + .collect(); + let xml = format!(r#"{stamps}"#); + let mut dst = pages(1); + let report = xfdf::import_xfdf_with(&mut dst, &xml, None, None, None, size * 5 / 2, false).unwrap(); + assert_eq!(report.comments, 3); + assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped); + assert!(report.skipped[0].starts_with("c: "), "{:?}", report.skipped); +} + +/// A dash array of any length export writes comes back (65 numbers were written and dropped). +#[test] +fn a_long_dash_array_comes_back() { + let mut src = pages(1); + let dashes: Vec = (1..=65).map(f64::from).collect(); + 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(&dashes)); + let mut d = annot("Square", "sq", [10.0, 10.0, 90.0, 60.0]); + d.set(b"BS".to_vec(), Object::Dict(bs)); + put(&mut src, d); + let (_, dst) = round_trip_xfdf(&src); + assert_eq!(f64s(&dst, sub_dict(&dst, &named(&dst, "sq"), b"BS").get(b"D")), dashes); +} + +/// Export stops writing appearances where import would stop reading them, so every appearance +/// written comes back. +#[test] +fn export_writes_no_more_appearance_than_import_reads() { + let mut src = pages(1); + content_stamp(&mut src, "a", b"0 0 m 1 1 l S".to_vec()); + let d = named(&src, "a"); + let mut probe = usize::MAX; + let one = appearance::encode(&src, &d, &mut probe).unwrap(); + let size = usize::MAX - probe; + let mut budget = size * 3 / 2; + assert_eq!(appearance::encode(&src, &d, &mut budget), Some(one)); + assert_eq!(appearance::encode(&src, &d, &mut budget), None, "past what import reads"); +} + +/// The export loop shares one budget over all of its comments: of five stamps whose appearances +/// together are past what import reads, the fifth goes out without one, and import reads every +/// appearance that was written. +#[test] +fn the_export_loop_writes_no_more_appearance_than_import_reads() { + let mut src = pages(1); + // About 14 MiB of XML each (hexadecimal): four fit in 64 MiB, five do not. + let form = content_stamp(&mut src, "s1", vec![0u8; 7 << 20]); + for nm in ["s2", "s3", "s4", "s5"] { + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(form)); + let mut d = annot("Stamp", nm, [100.0, 500.0, 200.0, 550.0]); + d.set(b"AP".to_vec(), Object::Dict(ap)); + put(&mut src, d); + } + let xml = export_xfdf(&src, true, false, "src.pdf"); + assert_eq!(xml.matches("").count(), 4); + let mut dst = pages(1); + let report = xfdf::import_xfdf(&mut dst, &xml).unwrap(); + assert_eq!(report.comments, 5); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); +} + +/// Printable data is written as it is, so a budget it fits as text takes it (the size check +/// counted it as hexadecimal, twice as long, and left the appearance out). +#[test] +fn printable_appearance_data_that_fits_the_budget_as_text_is_written() { + let mut src = pages(1); + // Printable as XFDF 3.0 counts it: no spaces. + content_stamp(&mut src, "a", "q\nQ\n".repeat(500).into_bytes()); + let d = named(&src, "a"); + let mut probe = usize::MAX; + let one = appearance::encode(&src, &d, &mut probe).unwrap(); + let mut budget = usize::MAX - probe; + assert!(budget < 3000, "written as text, not hexadecimal: {budget}"); + assert_eq!(appearance::encode(&src, &d, &mut budget), Some(one), "fits exactly"); + assert_eq!(budget, 0); +} + +/// A copied redaction mark goes without its appearance (import draws the mark itself and never +/// reads one), so it leaves the shared budget to the comments whose appearances are read. +#[test] +fn a_copied_redaction_mark_spends_no_appearance_budget() { + let mut src = pages(1); + let form = content_stamp(&mut src, "st", b"0 0 m 1 1 l S".to_vec()); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(form)); + let mut rd = annot("Redact", "rd", [300.0, 10.0, 400.0, 30.0]); + rd.set(b"AP".to_vec(), Object::Dict(ap)); + put(&mut src, rd); + let copy = export_xfdf_comments(&src, &[(0, 0), (0, 1)], "src.pdf"); + assert_eq!(copy.matches("").count(), 1, "{copy}"); + let redact = copy.split("").next().unwrap(); + assert!(!redact.contains(""), "{copy}"); +} + +/// A page an FDF stamp's appearance reaches (its /Type a reference here) is not copied in with +/// its contents. +#[test] +fn an_fdf_appearance_that_reaches_a_page_copies_no_page_in() { + let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [2 0 R] >> >>\nendobj\n\ +2 0 obj\n<< /Type /Annot /Subtype /Stamp /Rect [10 10 60 60] /NM (s) /Page 0 /AP << /N 3 0 R >> >>\nendobj\n\ +3 0 obj\n<< /Subtype /Form /BBox [0 0 1 1] /PieceInfo 4 0 R /Length 3 >>\nstream\nq Q\nendstream\nendobj\n\ +4 0 obj\n<< /Type 6 0 R /Contents 5 0 R >>\nendobj\n\ +5 0 obj\n<< /Length 11 >>\nstream\npagecontent\nendstream\nendobj\n\ +6 0 obj\n/Page\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + let mut dst = pages(1); + let before = dst.add(Object::Null); + import(&mut dst, fdf).unwrap(); + for num in before.num..dst.add(Object::Null).num { + if let Object::Stream(s) = dst.get(ObjRef::new(num, 0)).as_ref() { + assert_ne!(s.raw.as_ref(), b"pagecontent", "the page's contents were copied in"); + } + } + let n = dst.resolve(sub_dict(&dst, &named(&dst, "s"), b"AP").get(b"N").unwrap()); + let Object::Stream(n) = n.as_ref() else { panic!("no appearance") }; + assert!(matches!(n.dict.get(b"PieceInfo"), None | Some(Object::Null)), "{:?}", n.dict.get(b"PieceInfo")); +} + +/// Rich text keeps the default (XHTML) namespace it inherits from ``, and a +/// prefix it redeclares with spaces around `=` is not declared twice. +#[test] +fn rich_text_keeps_an_inherited_default_namespace() { + let xml = r#" +hi

hi

+hi

hi

+
"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + let rc = text(&dst, &named(&dst, "d"), b"RC").unwrap(); + let parsed = common::parse_form_xml(&rc).unwrap(); + assert_eq!(parsed.root_element().tag_name().namespace(), Some("http://www.w3.org/1999/xhtml"), "{rc}"); + let rc = text(&dst, &named(&dst, "w"), b"RC").unwrap(); + assert!(common::parse_form_xml(&rc).is_ok(), "{rc}"); + assert!(!rc.contains(r#"xmlns:a="urn:a""#), "{rc}"); +} + +/// A declaration written inside another attribute's value declares nothing, so the prefix the +/// rich text inherits is still declared on it; and a prefixed root keeps the default (XHTML) +/// namespace its unprefixed descendants inherit. A `>` in an attribute value is not the end of +/// the start tag. +#[test] +fn rich_text_namespaces_come_from_attributes_and_the_scope() { + let xml = r#" +hi

hi

+hi

hi

+hi

hi

+
"#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + let rc = text(&dst, &named(&dst, "v"), b"RC").unwrap(); + let parsed = common::parse_form_xml(&rc).unwrap_or_else(|e| panic!("{e}: {rc}")); + assert!(parsed.root_element().attributes().any(|a| a.namespace() == Some("urn:b")), "{rc}"); + let rc = text(&dst, &named(&dst, "g"), b"RC").unwrap(); + assert!(common::parse_form_xml(&rc).is_ok(), "a `>` in a value does not end the start tag: {rc}"); + let rc = text(&dst, &named(&dst, "p"), b"RC").unwrap(); + let parsed = common::parse_form_xml(&rc).unwrap(); + let p = parsed.descendants().find(|n| n.has_tag_name("p")).unwrap(); + assert_eq!(p.tag_name().namespace(), Some("http://www.w3.org/1999/xhtml"), "{rc}"); +} + +/// A redaction mark whose /Rect has no area shows its quads all the same: the /Rect grows to hold +/// them. +#[test] +fn a_redaction_marks_rect_holds_its_quads() { + let xml = r#""#; + let mut dst = pages(1); + import(&mut dst, xml.as_bytes()).unwrap(); + assert_eq!(f64s(&dst, named(&dst, "r").get(b"Rect")), vec![10.0, 20.0, 90.0, 50.0]); +} diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index 3368003483..c5ec3d642d 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,226 @@ 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; + +/// 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.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() { + // A line break is a line of its own, so two in a row leave a blank line; a paragraph + // starts on a new line. + if n.is_element() && n.tag_name().name() == "br" { + out.push('\r'); + } else if n.is_element() && matches!(n.tag_name().name(), "p" | "div" | "li") { + 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 without one (other tools /// leave /NM out) that is in `only` or that another annotation replies to or is grouped under /// (`/IRT`, ISO 32000-2 §12.5.6.2; a Replace Text pair is such a group), a name new to the @@ -143,15 +363,42 @@ 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) { - if let Some(c) = text_of(doc, d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); +/// 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: Option<&mut usize>) { + let rc = text_of(doc, d, b"RC"); + match text_of(doc, d, b"Contents") { + Some(c) => { + let _ = write!(out, "{}", esc(&c)); + } + // Rich text written as text (below) is read back as rich text only beside ``; + // alone it would become the comment's plain text. + None if rc.as_deref().is_some_and(|rc| rich_text_element(rc).is_none()) => out.push_str(""), + None => {} + } + if let Some(rc) = 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 let Some(budget) = ap + && let Some(b64) = super::appearance::encode(doc, d, budget) + { + 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(""); @@ -191,6 +438,8 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u // NM of every annotation, for inreplyto. A selected or replied-to annotation without one // (other tools leave /NM out) gets a name for this export, so its replies still find it. let names = annotation_names(doc, &pages, only); + // What the import reads of appearances, over all of the comments. + let mut appearance_budget = super::appearance::MAX_IMPORT_APPEARANCE_BYTES; for (pi, p) in pages.iter().enumerate() { for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { if only.is_some_and(|only| !only.contains(&(pi, ai))) { @@ -241,9 +490,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(",")); @@ -262,26 +509,14 @@ 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)); } } // What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow. + // Written under both spellings XFDF files use for it, `intent` and `IT`. if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } - // 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)); - } + let _ = write!(attrs, " intent=\"{0}\" IT=\"{0}\"", esc(&it)); } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); @@ -290,7 +525,13 @@ 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, except a + // redaction mark's, which import never reads (PdfCraft draws the mark) and which would + // only use up the budget the others share. + let has_area = rect.len() == 4 && rect[0] != rect[2] && rect[1] != rect[3]; + let carried = has_area && sub != b"Redact" && (own || sub == b"Stamp"); + xfdf_annot_children(doc, out, &d, pi, carried.then_some(&mut appearance_budget)); let _ = writeln!(out, ""); } } @@ -667,12 +908,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))); } @@ -688,22 +924,14 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } - if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { + // The intent (/IT), spelt `intent` or `IT`. + let intent = node.attribute("intent").filter(|it| !it.is_empty()).or_else(|| node.attribute("IT").filter(|it| !it.is_empty())); + if let Some(it) = intent { d.set(b"IT".to_vec(), Object::name(it)); } - if 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"))); + d.set(b"IT".to_vec(), Object::name(intent.unwrap_or("FreeTextCallout"))); // A callout's /LE is one name, not the head/tail pair of a line. match node.attribute("head") { Some(h) => d.set(b"LE".to_vec(), Object::name(h)), @@ -720,6 +948,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() @@ -739,10 +970,120 @@ 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 }; + let markup = match elements.as_slice() { + [_] => standalone(rich, markup), + _ => markup.to_string(), + }; + // 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))); + } +} + +/// Whether start tag `tag` (from `<` up to its closing `>`) declares attribute `name` (white +/// space may come before the `=`). Attribute values are skipped: `title=" xmlns:b='x'"` declares +/// nothing. +fn declares(tag: &str, name: &str) -> bool { + let mut quote = None; + let mut after_space = false; + for (i, c) in tag.char_indices() { + if let Some(q) = quote { + if c == q { + quote = None; + } + continue; + } + if c == '"' || c == '\'' { + quote = Some(c); + } else if after_space && tag.get(i..).and_then(|rest| rest.strip_prefix(name)).is_some_and(|a| a.trim_start().starts_with('=')) { + return true; + } + after_space = c.is_whitespace(); + } + false +} + +/// Where the start tag at the beginning of `markup` ends: its first `>` outside an attribute +/// value (the end of `markup` when there is none). +fn start_tag_end(markup: &str) -> usize { + let mut quote = None; + for (i, c) in markup.char_indices() { + match quote { + Some(q) if c == q => quote = None, + Some(_) => {} + None if c == '"' || c == '\'' => quote = Some(c), + None if c == '>' => return i, + None => {} + } + } + markup.len() +} + +/// The one element `markup` (cut out of `rich`, a ``) as an XML +/// document of its own: the namespaces it inherits from the XFDF around it are declared on it, +/// the default one (XHTML declared on ``, say; not XFDF's own, which a +/// standalone document has no use for) and the prefixes it uses. Unchanged when nothing is +/// inherited, or when the declarations would not make it parse. +fn standalone(rich: roxmltree::Node, markup: &str) -> String { + // The start tag's name ends at white space, `/` or `>`. + let Some(name_end) = markup.get(1..).and_then(|t| t.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map(|i| i + 1) else { + return markup.to_string(); + }; + let start_tag = markup.get(..start_tag_end(markup)).unwrap_or(markup); + let mut declared = String::new(); + // The default namespace in scope where the element sits: its own when it has no prefix, and + // its unprefixed descendants' when it has one. + if let Some(uri) = rich.namespaces().find(|ns| ns.name().is_none()).map(|ns| ns.uri()) + && !uri.is_empty() + && uri != XFDF_NS + && !declares(start_tag, "xmlns") + { + let _ = write!(declared, " xmlns=\"{}\"", esc(uri)); + } + for ns in rich.namespaces() { + let Some(prefix) = ns.name() else { continue }; + if prefix == "xml" || !markup.contains(&format!("{prefix}:")) || declares(start_tag, &format!("xmlns:{prefix}")) { + continue; + } + let _ = write!(declared, " xmlns:{prefix}=\"{}\"", esc(ns.uri())); + } + if declared.is_empty() { + return markup.to_string(); + } + let (Some(head), Some(tail)) = (markup.get(..name_end), markup.get(name_end..)) else { return markup.to_string() }; + let fixed = format!("{head}{declared}{tail}"); + if parse_form_xml(&fixed).is_ok() { fixed } else { markup.to_string() } +} + +/// XFDF's own namespace (ISO 19444-1). +const XFDF_NS: &str = "http://ns.adobe.com/xfdf/"; + /// 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 @@ -772,6 +1113,11 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result // without usable /QuadPoints removes its /Rect, which the outline is then drawn from. d.remove(b"CA"); d.remove(b"ca"); + // The outline is PdfCraft's own (an appearance the file carries could hide the mark), and + // a redaction has no border effect (ISO 32000-2 Table 195 lists none; §12.5.4 gives + // /BE to squares, circles, polygons and free text), which the outline could not draw. + d.remove(b"AP"); + d.remove(b"BE"); let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| { let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect(); let (quads, rest) = v.as_chunks::<8>(); @@ -791,7 +1137,32 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result } } } + // The outline is drawn inside /Rect: it is grown to hold every quad, so the whole area + // the mark removes shows (a /Rect with no area would show none of it). + let quads: Vec = + d.get(b"QuadPoints").and_then(Object::as_array).map(|q| q.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + if !quads.is_empty() { + let rect = rect_strict(doc, d.get(b"Rect")); + let start = rect.map(|[x0, y0, x1, y1]| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]); + let bounds = quads + .as_chunks::<2>() + .0 + .iter() + .fold(start, |b, [x, y]| Some(b.map_or([*x, *y, *x, *y], |[l, bo, r, t]| [l.min(*x), bo.min(*y), r.max(*x), t.max(*y)]))); + if let Some(b) = bounds + && Some(b) != rect + { + d.set(b"Rect".to_vec(), arr(&b)); + } + } } + // 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 redact = d.name(b"Subtype") == Some(b"Redact".as_slice()); + // A redaction mark with an area removes something when applied. + let marks = redact && d.contains(b"QuadPoints"); + let keep_appearance = !redact && 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) @@ -835,8 +1206,17 @@ 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 drawn = pdfcraft_annot::set_appearance(doc, r); + // A redaction mark that removes something but shows nothing is refused: applying it + // would remove content the reviewer never saw marked. + if let Err(why) = drawn + && marks + { + return Err(DataError::Malformed(format!("a redaction mark could not be drawn, so what it removes would not show ({why})"))); + } + } kept.push(Object::Ref(r)); doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?; Ok(r) @@ -872,19 +1252,35 @@ pub fn import_comments( fresh: Option<&str>, offset: Option<[f64; 2]>, ) -> Result { - import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite()))) + // Into a copy, kept only when the whole import succeeds. + let mut work = doc.clone(); + let report = import_xfdf_with( + &mut work, + text, + page, + fresh, + offset.filter(|o| o.iter().all(|v| v.is_finite())), + super::appearance::MAX_IMPORT_APPEARANCE_BYTES, + true, + )?; + *doc = work; + Ok(report) } pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { - import_xfdf_with(doc, text, None, None, None) + import_xfdf_with(doc, text, None, None, None, super::appearance::MAX_IMPORT_APPEARANCE_BYTES, false) } -fn import_xfdf_with( +/// The import, with `appearance_budget` decoded bytes of appearance for all of its comments; +/// `comments_only` leaves the file's field values out (comments placed as copies). +pub(crate) fn import_xfdf_with( doc: &mut Document, text: &str, target_page: Option, fresh: Option<&str>, offset: Option<[f64; 2]>, + mut appearance_budget: usize, + comments_only: bool, ) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; @@ -920,6 +1316,18 @@ 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. + // A redaction mark is always drawn by PdfCraft (see `place`): its appearance is not read. + if let Some(ap) = node.children().find(|c| c.has_tag_name("appearance")).filter(|_| *subtype != "Redact") { + 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). @@ -964,7 +1372,7 @@ fn import_xfdf_with( } } // Comments placed as copies: the file's field values are not part of the paste. - if fresh.is_some() || target_page.is_some() || offset.is_some() { + if comments_only { return Ok(report); } // Fields. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3213783a2d..b442b416a6 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", "crates/xfdf/src/tests_format.rs::the_intent_is_written_and_read_as_intent_or_it", "crates/xfdf/src/tests_format.rs::a_chain_of_forms_comes_back_whole_from_fdf_or_is_refused", "crates/xfdf/src/tests_format.rs::an_imported_redaction_mark_is_drawn_even_when_cloudy_or_undrawable", "crates/xfdf/src/tests_format.rs::the_import_budget_is_shared_by_every_comment", "crates/engine/src/tests.rs::a_comment_imported_without_its_appearance_is_said_in_the_repair_log"] +notes = "replies, pop-ups, ink, quads, line ends; same /NM replaces. XFDF formatting comes in as XFDF 3.0 names it: contents-richtext (/RC; plain text in it, or markup without , also gives /Contents), defaultstyle, width/style/dashes (a dashed border stays dashed), cloudy style and intensity, rotation, justification, fringe, leaderLength/leaderExtend/leader-offset, caption attributes, caret symbol, redaction overlay text, the intent (`intent` or `IT`). A stamp's XFDF (base 64 of the DICT/STREAM/DATA appearance XML other tools write) is kept; an unreadable or oversized one (16 MiB each, 64 MiB per import, 64 levels, 100,000 values) (or one whose normal appearance is several states, which XFDF cannot select) is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft. FDF /AP streams are kept with their resources; an FDF whose comments expand past the import budget (1,000,000 values or 64 MiB of data) or nest more than 256 levels is refused with an error and nothing is imported (an import is all or nothing); a page the file's appearance reaches is not copied in. A redaction mark is always drawn by PdfCraft, and one with an area it cannot draw is refused. 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, and does not turn it by /Rotate." [[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", "crates/xfdf/src/tests_format.rs::fdf_carries_only_stamp_appearances"] +notes = "FDF preserves shared/cyclic nested values. Discovery keeps lazy array owners and annotation positions; retained entries are budgeted before cloning, including after saved-PDF cache eviction. Copied-graph limits are 100,000 comments/objects/values, 64 direct levels and a conservative 64 MiB data budget; refusal precedes writing. Source parsing and page discovery retain existing COS/parser limits, with no total source-memory bound. Source /Page and /Pages dictionaries become null at every copy depth; indirect Type names check each reference generation with iterative cycle detection and a 64-reference limit; stale Type references preserve unrelated metadata; a saved/reopened three-page fixture verifies small output and retained unrelated metadata. FDF carries a stamp's appearance (/AP with the forms and images it draws), as XFDF does, and other comments their formatting entries without /AP; source-document links stay behind; FDF import copies a comment's streams only when the comment lands on a page. XFDF exports retain tabs and line breaks in comment identities and metadata, and write each comment's formatting as XFDF 3.0 elements and attributes (contents-richtext, defaultstyle, width/style/dashes also from an older /Border, cloudy style, rotation, justification, fringe on every type, line leaders and captions, caret symbol, redaction overlay text, the intent as both `intent` and `IT`) and a stamp's appearance as ; an appearance past the limits, or on a rect with no area, is left out. Readers that don't know an element skip it." [[feature]] id = "comment.export-selected" @@ -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"