mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:23:02 +00:00
M5.6: preserve nested annotation values in FDF exports (#1971)
* M5.6: preserve nested annotation values in FDF exports Copy and renumber retained object graphs without expanding aliases or following indirect cycles recursively. Refuse excessive work and propagate export errors before the destination is written. Cover saved geometry, colour and borders, shared graphs, streams, stale references and refusal through automation tools. Co-Authored-By: Codex <noreply@openai.com> * Bound FDF annotation copying before allocation Keep selected aliases shared, budget retained keys and values before copying, and add direct-payload and alias-count regressions. Retain copied-graph limits and document source-memory scope. * M5.6: bound FDF cache-eviction discovery and field values Extend the concurrent annotation-budget correction while retaining its direct-payload and alias regressions. Record annotation positions instead of resolved bodies so cache eviction cannot pin repeated parsed copies; borrow direct values and budget retained entries before cloning. Count every emitted multi-select field value before allocating its output array. Observed pre-fix allocation-cap and field-limit failures, then fixed tests passed. Final validation: 549 scoped XFDF/engine/automation tests passed, three existing ignored; strict all-targets Clippy, WASM, fmt, layers, assets, parity and diff checks passed. AI assistance: OpenAI Codex for @MMexicann. * M5.6: record bounded FDF export regression evidence Record comment cache-eviction discovery and emitted-field-value budget regressions without changing feature scores or status. Describe copied-graph limits and existing source-parser limits precisely. Final 549 scoped tests, strict Clippy, WASM, fmt, layers, assets and parity passed. AI assistance: OpenAI Codex for @MMexicann. * M5.6: align FDF progress with budgeted discovery Document lazy annotation positions, cache-eviction coverage and copied-graph limits while retaining the original nested-value progress entry and all upstream notes. Estimates unchanged. AI assistance: OpenAI Codex for @MMexicann. * M5.6: describe FDF discovery and copied-graph limits Align exchange documentation with the tested lazy-position discovery implementation and its copied-graph limits. Source parsing/page discovery keep existing limits. AI assistance: OpenAI Codex for @MMexicann. * M5.6: align milestone notes with FDF discovery limits Retain upstream milestone history and describe the tested discovery/copy budgets without increasing estimates or claiming a total source-memory bound. AI assistance: OpenAI Codex for @MMexicann. * M5.6: keep nested page links out of FDF exports Preserve graph aliases and existing budgets; exclude direct and indirect Page/Pages type names. Add saved-PDF regression and bounded type-resolution controls. Both pre-fix failures observed; 46 scoped tests, strict Clippy, WASM and formatting passed. * M5.6: document bounded nested page exclusion Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: record nested page-reference regression evidence Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: retain FDF limits in milestone evidence Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: cite nested page-reference exclusion tests Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: respect generations while identifying page references Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * XFDF: document stale Type preservation regression Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: record generation-aware page exclusion evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: propagate FDF export errors in usage example Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: consolidate roadmap change and target fields Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
@@ -176,6 +177,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
|
||||
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
|
||||
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
@@ -546,6 +552,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
|
||||
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
|
||||
|
||||
@@ -6766,6 +6766,65 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
use std::sync::Arc;
|
||||
let dir = workdir("fdf-nested-values");
|
||||
let mut source = Document::open(Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&source)[0].obj;
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"NM".to_vec(), PdfString::text("nested-values"));
|
||||
note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into()));
|
||||
note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into()));
|
||||
let note = source.add(note);
|
||||
source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
|
||||
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" }));
|
||||
let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "comment_list", json!({ "doc": reopened }));
|
||||
// The saved input has legal indirect scalar numbers. The engine's source inspection
|
||||
// currently reads those as defaults, so compare the restored data to the fixture's
|
||||
// known values (converted from PDF bottom-left to the tool's top-left coordinates).
|
||||
assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0]));
|
||||
assert_eq!(after["comments"][0]["color"], "#4080BF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_fdf_export_keeps_the_destination_and_source_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full};
|
||||
use std::sync::Arc;
|
||||
let dir = workdir("fdf-export-limit");
|
||||
let mut source = Document::open(Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&source)[0].obj;
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||||
note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001]));
|
||||
let note = source.add(note);
|
||||
source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
let bytes = write_full(&source, &SaveOptions::default()).unwrap();
|
||||
std::fs::write(dir.join("source.pdf"), &bytes).unwrap();
|
||||
std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err();
|
||||
assert!(error.to_string().contains("100,000 values"), "{error}");
|
||||
assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination");
|
||||
assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes);
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_and_callout_fill_and_border_through_tools() {
|
||||
let dir = workdir("textbox-fill");
|
||||
|
||||
@@ -4342,7 +4342,7 @@ impl Session {
|
||||
}
|
||||
guard(|| match format {
|
||||
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
|
||||
DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)),
|
||||
DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()),
|
||||
DataFormat::Xml | DataFormat::Xdp if xfa => {
|
||||
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
+16
-2
@@ -4,7 +4,7 @@ Layer L4: comment and form data exchange (execution plan M5.6, M6.7).
|
||||
|
||||
```rust
|
||||
let xml = export_xfdf(&doc, true, true, "form.pdf"); // comments and/or field values
|
||||
let fdf = export_fdf(&doc, false, true, "form.pdf");
|
||||
let fdf = export_fdf(&doc, false, true, "form.pdf")?;
|
||||
let txt = export_data(&doc, Format::Txt); // or Xml, Csv
|
||||
let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV or text, detected
|
||||
```
|
||||
@@ -14,7 +14,21 @@ 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.
|
||||
- **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects.
|
||||
- **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
|
||||
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
|
||||
64 references. Discovery borrows direct arrays and dictionaries,
|
||||
retains array owners lazily and records annotation positions instead of resolved bodies.
|
||||
Retained entries are budgeted before their keys or values are cloned, including when
|
||||
saved-PDF cache eviction causes annotation bodies to be parsed again. Export returns
|
||||
`Result` and refuses copied graphs above 100,000 comments/objects/values, 64 direct nesting
|
||||
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.
|
||||
- **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).
|
||||
|
||||
|
||||
+258
-34
@@ -11,7 +11,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum DataError {
|
||||
Malformed(String),
|
||||
#[error("nothing in the file matches this document")]
|
||||
NothingImported,
|
||||
#[error("FDF export exceeds the {0} limit; no data was exported")]
|
||||
ExportLimit(&'static str),
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -467,19 +469,223 @@ fn pdf_string(s: &str) -> Vec<u8> {
|
||||
v
|
||||
}
|
||||
|
||||
// Keep aliases as aliases instead of expanding a shared graph once per use. References are
|
||||
// queued, not followed recursively, so cycles and long indirect chains cost linear work.
|
||||
const MAX_FDF_OBJECTS: usize = 100_000;
|
||||
const MAX_FDF_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only
|
||||
/// indirect values need an owner, and annotation bodies are never retained in discovery.
|
||||
enum FdfValue<'a> {
|
||||
Borrowed(&'a Object),
|
||||
Indirect(std::sync::Arc<Object>),
|
||||
}
|
||||
|
||||
impl<'a> FdfValue<'a> {
|
||||
fn resolve(doc: &Document, value: &'a Object) -> Self {
|
||||
match value {
|
||||
Object::Ref(_) => Self::Indirect(doc.resolve(value)),
|
||||
_ => Self::Borrowed(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(&self) -> &Object {
|
||||
match self {
|
||||
Self::Borrowed(value) => value,
|
||||
Self::Indirect(value) => value.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FdfCopy<'a> {
|
||||
doc: &'a Document,
|
||||
objects: Vec<Object>,
|
||||
numbers: HashMap<ObjRef, u32>,
|
||||
pending: Vec<(ObjRef, usize)>,
|
||||
nodes: usize,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl<'a> FdfCopy<'a> {
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES }
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize) -> Result<(), DataError> {
|
||||
self.bytes = self.bytes.checked_sub(bytes).ok_or(DataError::ExportLimit("64 MiB data"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn slot(&mut self) -> Result<(usize, u32), DataError> {
|
||||
if self.objects.len() >= MAX_FDF_OBJECTS {
|
||||
return Err(DataError::ExportLimit("100,000 objects"));
|
||||
}
|
||||
// Object 1 is the FDF root; arena indices start at object 2. The arena is capped above.
|
||||
let index = self.objects.len();
|
||||
let number = index as u32 + 2;
|
||||
self.charge(100)?; // indirect-object and xref overhead
|
||||
self.objects.push(Object::Null);
|
||||
Ok((index, number))
|
||||
}
|
||||
|
||||
fn dict(&mut self, dict: &Dict, depth: usize) -> Result<Dict, DataError> {
|
||||
if dict.len() > self.nodes {
|
||||
return Err(DataError::ExportLimit("100,000 values"));
|
||||
}
|
||||
let mut out = Dict::new();
|
||||
for (key, value) in dict.iter() {
|
||||
self.dict_entry(&mut out, key, value, depth + 1)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn dict_entry(&mut self, out: &mut Dict, key: &[u8], value: &Object, depth: usize) -> Result<(), DataError> {
|
||||
self.charge(key.len().saturating_mul(3).saturating_add(2))?;
|
||||
let value = self.copy(value, depth)?;
|
||||
out.set(key.to_vec(), value);
|
||||
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.
|
||||
fn annotation_dict(&mut self, dict: &Dict) -> Result<Dict, DataError> {
|
||||
self.charge_value(0)?;
|
||||
let mut out = Dict::new();
|
||||
for (key, value) in dict.iter() {
|
||||
if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") {
|
||||
continue;
|
||||
}
|
||||
self.dict_entry(&mut out, key, value, 1)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn charge_value(&mut self, depth: usize) -> Result<(), DataError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(DataError::ExportLimit("64 direct nesting levels"));
|
||||
}
|
||||
self.nodes = self.nodes.checked_sub(1).ok_or(DataError::ExportLimit("100,000 values"))?;
|
||||
// Includes delimiters, spacing and even the longest finite f64 in plain decimal form.
|
||||
self.charge(330)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_source_page(&self, dict: &Dict) -> Result<bool, DataError> {
|
||||
let Some(value) = dict.get(b"Type") else { return Ok(false) };
|
||||
let mut value = FdfValue::Borrowed(value);
|
||||
let mut seen = HashSet::new();
|
||||
for hops in 0..=MAX_DEPTH {
|
||||
match value.object() {
|
||||
Object::Name(name) => return Ok(matches!(name.as_slice(), b"Page" | b"Pages")),
|
||||
Object::Ref(reference) => {
|
||||
if self.doc.generation(reference.num) != reference.generation || !seen.insert(*reference) {
|
||||
return Ok(false);
|
||||
}
|
||||
if hops == MAX_DEPTH {
|
||||
return Err(DataError::ExportLimit("64 Type reference levels"));
|
||||
}
|
||||
value = FdfValue::Indirect(self.doc.get(*reference));
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
}
|
||||
Err(DataError::ExportLimit("64 Type reference levels"))
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: &Object, depth: usize) -> Result<Object, DataError> {
|
||||
self.charge_value(depth)?;
|
||||
Ok(match value {
|
||||
Object::Ref(source) => {
|
||||
let number = if let Some(number) = self.numbers.get(source) {
|
||||
*number
|
||||
} else {
|
||||
let (index, number) = self.slot()?;
|
||||
self.numbers.insert(*source, number);
|
||||
self.pending.push((*source, index));
|
||||
number
|
||||
};
|
||||
Object::Ref(ObjRef::new(number, 0))
|
||||
}
|
||||
Object::Array(values) => {
|
||||
if values.len() > self.nodes {
|
||||
return Err(DataError::ExportLimit("100,000 values"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for value in values {
|
||||
out.push(self.copy(value, depth + 1)?);
|
||||
}
|
||||
Object::Array(out)
|
||||
}
|
||||
// Source page links must not drag page contents or the page tree into an FDF.
|
||||
Object::Dict(dict) if self.is_source_page(dict)? => Object::Null,
|
||||
Object::Dict(dict) => Object::Dict(self.dict(dict, depth)?),
|
||||
Object::String(s) => {
|
||||
self.charge(s.bytes.len().saturating_mul(4))?;
|
||||
value.clone()
|
||||
}
|
||||
Object::Name(name) => {
|
||||
self.charge(name.len().saturating_mul(3))?;
|
||||
value.clone()
|
||||
}
|
||||
// A PDF stream can only be an indirect object, never a dictionary/array value.
|
||||
Object::Stream(_) => return Err(DataError::Malformed("FDF export encountered a direct stream".into())),
|
||||
other => other.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn finish(&mut self, doc: &Document) -> Result<(), DataError> {
|
||||
let mut cursor = 0;
|
||||
while let Some((source, index)) = self.pending.get(cursor).copied() {
|
||||
// ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader
|
||||
// errors distinct so damaged objects cannot be silently lost during export.
|
||||
let value = if doc.generation(source.num) != source.generation {
|
||||
std::sync::Arc::new(Object::Null)
|
||||
} else {
|
||||
match doc.try_get(source.num) {
|
||||
Ok(value) => value,
|
||||
Err(pdfcraft_cos::CosError::MissingObject(_)) => std::sync::Arc::new(Object::Null),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
};
|
||||
let copied = match &*value {
|
||||
Object::Stream(stream) => {
|
||||
self.charge(stream.raw.len())?;
|
||||
Object::Stream(pdfcraft_cos::Stream { dict: self.dict(&stream.dict, 0)?, raw: stream.raw.clone() })
|
||||
}
|
||||
other => self.copy(other, 0)?,
|
||||
};
|
||||
let slot = self.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF object slot is missing".into()))?;
|
||||
*slot = copied;
|
||||
cursor += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// FDF with field values and/or comments (appearances are not carried). 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); nothing else refers back into the source document.
|
||||
pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Vec<u8> {
|
||||
/// (§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.
|
||||
/// Refuses exports above the object, value, direct-depth or data budget instead of losing data.
|
||||
pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> Result<Vec<u8>, DataError> {
|
||||
let mut copy = FdfCopy::new(doc);
|
||||
copy.charge(file.len().saturating_mul(8))?;
|
||||
let mut body: Vec<u8> = b"<< /FDF << ".to_vec();
|
||||
// Comment dictionaries, written after the root as objects 2, 3, …
|
||||
let mut annots: Vec<Dict> = Vec::new();
|
||||
if fields {
|
||||
body.extend_from_slice(b"/Fields [");
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if matches!(f.kind, FieldKind::PushButton | FieldKind::Signature) {
|
||||
continue;
|
||||
}
|
||||
// Field dictionary, /T string and /V scalar/array, plus every emitted list
|
||||
// entry. Other field kinds emit only their first value (or a default).
|
||||
let field_nodes = if matches!(f.kind, FieldKind::List) && f.value.len() > 1 { f.value.len().saturating_add(3) } else { 3 };
|
||||
copy.nodes = copy.nodes.checked_sub(field_nodes).ok_or(DataError::ExportLimit("100,000 values"))?;
|
||||
copy.charge(f.name.len().saturating_mul(8).saturating_add(100))?;
|
||||
for value in &f.value {
|
||||
copy.charge(value.len().saturating_mul(8).saturating_add(100))?;
|
||||
}
|
||||
body.extend_from_slice(b"<< /T ");
|
||||
body.extend(pdf_string(&f.name));
|
||||
body.extend_from_slice(b" /V ");
|
||||
@@ -498,44 +704,61 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
|
||||
body.extend_from_slice(b"] ");
|
||||
}
|
||||
if comments {
|
||||
// Every exported comment with its source reference, numbered in export order.
|
||||
let mut picked: Vec<(Option<ObjRef>, Dict, usize)> = Vec::new();
|
||||
for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||||
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
|
||||
if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) {
|
||||
picked.push((a.as_ref(), d, pi));
|
||||
// Keep the page/array owners once. Handles retain positions, never annotation
|
||||
// bodies: the document cache can evict and reparse even a repeated source reference.
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let mut arrays = Vec::new();
|
||||
let mut picked: Vec<(Option<ObjRef>, usize, usize)> = Vec::new();
|
||||
for (pi, page) in pages.iter().enumerate() {
|
||||
let owner = page.dict.get(b"Annots").map(|value| FdfValue::resolve(doc, value));
|
||||
let before = picked.len();
|
||||
if let Some(values) = owner.as_ref().and_then(|owner| owner.object().as_array()) {
|
||||
for (ai, a) in values.iter().enumerate() {
|
||||
let value = FdfValue::resolve(doc, a);
|
||||
let Some(d) = value.object().as_dict() else { continue };
|
||||
if d.name(b"Subtype").is_some_and(|sub| SUBTYPES.iter().any(|(s, _)| s.as_bytes() == sub)) {
|
||||
if picked.len() >= MAX_FDF_OBJECTS {
|
||||
return Err(DataError::ExportLimit("100,000 comments"));
|
||||
}
|
||||
picked.push((a.as_ref(), pi, ai));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Empty/unselected arrays need no owner. Resolve subsequent pages only after
|
||||
// this page passes the cap, rather than loading them all before discovery.
|
||||
arrays.push(if picked.len() > before { owner } else { None });
|
||||
}
|
||||
let numbers: HashMap<ObjRef, u32> =
|
||||
picked.iter().enumerate().filter_map(|(i, (r, _, _))| Some(((*r)?, u32::try_from(i).ok()?.checked_add(2)?))).collect();
|
||||
for (_, mut d, pi) in picked {
|
||||
let mut numbers = HashMap::new();
|
||||
for (source, _, _) in &picked {
|
||||
let (_, number) = copy.slot()?;
|
||||
if let Some(source) = source {
|
||||
numbers.insert(*source, number);
|
||||
}
|
||||
}
|
||||
copy.numbers = numbers.clone();
|
||||
let count = picked.len();
|
||||
for (index, (_, pi, ai)) in picked.into_iter().enumerate() {
|
||||
let annotation = arrays
|
||||
.get(pi)
|
||||
.and_then(Option::as_ref)
|
||||
.and_then(|owner| owner.object().as_array())
|
||||
.and_then(|values| values.get(ai))
|
||||
.ok_or_else(|| DataError::Malformed("FDF annotation slot is missing".into()))?;
|
||||
let value = FdfValue::resolve(doc, annotation);
|
||||
let d = value.object().as_dict().ok_or_else(|| DataError::Malformed("FDF comment is not a dictionary".into()))?;
|
||||
// A reply points at its parent's object in this FDF; a parent that isn't exported
|
||||
// leaves the reply unthreaded.
|
||||
let irt = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| numbers.get(&r)).copied();
|
||||
// Only direct values travel: references into this file would dangle.
|
||||
for k in [&b"P"[..], b"AP", b"Popup", b"IRT", b"Parent", b"FS", b"Sound"] {
|
||||
d.remove(k);
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = d.iter().filter(|(_, v)| matches!(v, Object::Ref(_))).map(|(k, _)| k.clone()).collect();
|
||||
for k in keys {
|
||||
let Some(o) = d.get(&k) else { continue };
|
||||
let v = (*doc.resolve(o)).clone();
|
||||
if matches!(v, Object::Stream(_)) {
|
||||
d.remove(&k);
|
||||
} else {
|
||||
d.set(k, v);
|
||||
}
|
||||
}
|
||||
let mut d = copy.annotation_dict(d)?;
|
||||
if let Some(num) = irt {
|
||||
d.set(b"IRT".to_vec(), Object::Ref(ObjRef::new(num, 0)));
|
||||
}
|
||||
d.set(b"Page".to_vec(), Object::Int(pi as i64));
|
||||
annots.push(d);
|
||||
let slot = copy.objects.get_mut(index).ok_or_else(|| DataError::Malformed("FDF comment slot is missing".into()))?;
|
||||
*slot = Object::Dict(d);
|
||||
}
|
||||
body.extend_from_slice(b"/Annots [");
|
||||
for i in 0..annots.len() {
|
||||
for i in 0..count {
|
||||
let _ = write!(body, "{} 0 R ", i + 2);
|
||||
}
|
||||
body.extend_from_slice(b"] ");
|
||||
@@ -543,16 +766,17 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
|
||||
body.extend_from_slice(b"/F ");
|
||||
body.extend(pdf_string(file));
|
||||
body.extend_from_slice(b" >> >>");
|
||||
copy.finish(doc)?;
|
||||
let mut out = b"%FDF-1.2\n%\xE2\xE3\xCF\xD3\n".to_vec();
|
||||
let mut offsets = Vec::with_capacity(annots.len() + 1);
|
||||
let mut offsets = Vec::with_capacity(copy.objects.len() + 1);
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(b"1 0 obj\n");
|
||||
out.extend(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
for (i, d) in annots.into_iter().enumerate() {
|
||||
for (i, object) in copy.objects.into_iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
let _ = writeln!(out, "{} 0 obj", i + 2);
|
||||
pdfcraft_cos::serialize(&Object::Dict(d), &mut out);
|
||||
pdfcraft_cos::serialize(&object, &mut out);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
@@ -561,7 +785,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
|
||||
let _ = writeln!(out, "{o:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(out, "trailer\n<< /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n");
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
/// XML 1.0 fifth-edition NameStartChar, excluding `:` for an unprefixed NCName.
|
||||
/// https://www.w3.org/TR/2008/REC-xml-20081126/#NT-NameStartChar
|
||||
|
||||
+366
-8
@@ -311,7 +311,7 @@ fn xfdf_round_trips_comments_and_values() {
|
||||
#[test]
|
||||
fn fdf_round_trips_values_and_comments() {
|
||||
let src = filled();
|
||||
let fdf = export_fdf(&src, true, true, "form.pdf");
|
||||
let fdf = export_fdf(&src, true, true, "form.pdf").unwrap();
|
||||
assert!(fdf.starts_with(b"%FDF-1.2"));
|
||||
let mut dst = blank();
|
||||
let r = import(&mut dst, &fdf).unwrap();
|
||||
@@ -353,7 +353,7 @@ fn non_utf8_check_box_states_round_trip() {
|
||||
let mut src = shift_jis_check_box();
|
||||
set_value(&mut src, "agree", &FieldValue::Check(true)).unwrap();
|
||||
// FDF writes the state's own bytes, not its #XX text.
|
||||
let fdf = export_fdf(&src, false, true, "form.pdf");
|
||||
let fdf = export_fdf(&src, false, true, "form.pdf").unwrap();
|
||||
let name: &[u8] = b"/#82#CD#82#A2";
|
||||
assert!(fdf.windows(name.len()).any(|w| w == name), "{}", String::from_utf8_lossy(&fdf));
|
||||
for data in [fdf, export_xfdf(&src, false, true, "form.pdf").into_bytes()] {
|
||||
@@ -389,7 +389,7 @@ fn fdf_keeps_replies_and_review_status_on_their_note() {
|
||||
let want = threads(&src);
|
||||
assert_eq!(want.iter().filter(|t| t.1.as_deref() == Some("note")).count(), 3, "{want:?}");
|
||||
|
||||
let fdf = export_fdf(&src, true, false, "notes.pdf");
|
||||
let fdf = export_fdf(&src, true, false, "notes.pdf").unwrap();
|
||||
// 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();
|
||||
@@ -648,7 +648,7 @@ fn bad_data_is_refused_with_a_reason() {
|
||||
fn data_files_merge_into_a_spreadsheet() {
|
||||
let src = filled();
|
||||
let xfdf = export_xfdf(&src, false, true, "form.pdf");
|
||||
let fdf = export_fdf(&src, false, true, "form.pdf");
|
||||
let fdf = export_fdf(&src, false, true, "form.pdf").unwrap();
|
||||
let mut other = blank();
|
||||
set_value(&mut other, "Name", &FieldValue::Text("Grace \"Amazing\" Hopper, RADM".into())).unwrap();
|
||||
let pdf = pdfcraft_cos::write_full(&other, &Default::default()).unwrap();
|
||||
@@ -718,7 +718,7 @@ fn replacing_a_note_keeps_existing_replies_and_review_status() {
|
||||
)
|
||||
.unwrap();
|
||||
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
|
||||
for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] {
|
||||
for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||||
// Start with the parent, reply and review status in the existing PDF revision.
|
||||
let mut doc = Document::open(original.clone()).unwrap();
|
||||
let mut want = threads(&doc);
|
||||
@@ -741,7 +741,7 @@ fn replacing_a_note_keeps_existing_replies_and_review_status() {
|
||||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||||
let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap();
|
||||
assert_eq!(note.contents.as_deref(), Some("Updated note"));
|
||||
let fdf = export_fdf(&reopened, true, false, "notes.pdf");
|
||||
let fdf = export_fdf(&reopened, true, false, "notes.pdf").unwrap();
|
||||
let mut dst = blank();
|
||||
import(&mut dst, &fdf).unwrap();
|
||||
let mut got = threads(&dst);
|
||||
@@ -785,7 +785,7 @@ fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() {
|
||||
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
|
||||
let mut incoming = blank();
|
||||
import(&mut incoming, update).unwrap();
|
||||
for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] {
|
||||
for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||||
let mut doc = Document::open(original.clone()).unwrap();
|
||||
let refs = annotation_refs(&doc);
|
||||
assert_eq!(refs.len(), 3);
|
||||
@@ -848,7 +848,7 @@ fn the_last_same_named_record_controls_its_reply_parent() {
|
||||
refs.push(incoming.add(d));
|
||||
}
|
||||
incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap();
|
||||
for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf")] {
|
||||
for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||||
let mut doc = blank();
|
||||
import(&mut doc, &data).unwrap();
|
||||
let notes = summaries(&doc);
|
||||
@@ -1156,6 +1156,238 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_keeps_indirect_values_nested_in_annotation_properties() {
|
||||
let mut src = blank();
|
||||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||||
let mut annotation = Dict::new();
|
||||
annotation.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
annotation.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
annotation.set(b"NM".to_vec(), PdfString::text("indirect-values"));
|
||||
let rect = [50.0, 60.0, 100.0, 120.0].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||||
annotation.set(b"Rect".to_vec(), Object::Array(rect.into()));
|
||||
let color = [0.25, 0.5, 0.75].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||||
annotation.set(b"C".to_vec(), Object::Array(color.into()));
|
||||
let mut border = Dict::new();
|
||||
border.set(b"W".to_vec(), Object::Ref(src.add(Object::Real(2.5))));
|
||||
border.set(b"S".to_vec(), Object::name("D"));
|
||||
let dash = [3.0, 2.0].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||||
border.set(b"D".to_vec(), Object::Ref(src.add(Object::Array(dash.into()))));
|
||||
annotation.set(b"BS".to_vec(), Object::Ref(src.add(border)));
|
||||
let note = src.add(annotation);
|
||||
src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
|
||||
// This is an actual saved PDF: indirect scalar numbers, arrays and dictionaries are legal
|
||||
// PDF objects, and source object numbers have no meaning in an exported FDF.
|
||||
let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(summaries(&src)[0].rect, [50.0, 60.0, 100.0, 120.0]);
|
||||
assert_eq!(summaries(&src)[0].color, Some([0.25, 0.5, 0.75]));
|
||||
let fdf = export_fdf(&src, true, false, "source.pdf").unwrap();
|
||||
let exported = Document::open(Arc::new(fdf.clone())).unwrap();
|
||||
let body = fdf_body(&exported).unwrap();
|
||||
let annots = exported.resolve(body.get(b"Annots").unwrap());
|
||||
let note = exported.resolve(&annots.as_array().unwrap()[0]);
|
||||
let d = note.as_dict().unwrap();
|
||||
assert_eq!(nums_of(&exported, d.get(b"Rect")), [50.0, 60.0, 100.0, 120.0], "{}", String::from_utf8_lossy(&fdf));
|
||||
assert_eq!(nums_of(&exported, d.get(b"C")), [0.25, 0.5, 0.75]);
|
||||
let bs = exported.resolve(d.get(b"BS").unwrap());
|
||||
let bs = bs.as_dict().unwrap();
|
||||
assert_eq!(exported.resolve(bs.get(b"W").unwrap()).as_f64(), Some(2.5));
|
||||
assert_eq!(nums_of(&exported, bs.get(b"D")), [3.0, 2.0]);
|
||||
|
||||
let mut dst = blank();
|
||||
import(&mut dst, &fdf).unwrap();
|
||||
let dst = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let notes = summaries(&dst);
|
||||
assert_eq!(notes[0].rect, [50.0, 60.0, 100.0, 120.0]);
|
||||
assert_eq!(notes[0].color, Some([0.25, 0.5, 0.75]));
|
||||
}
|
||||
|
||||
/// Put extension data on a real exported comment without changing its normal properties.
|
||||
fn fdf_note_with_data(doc: &mut Document, data: Object) {
|
||||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||||
note.set(b"PCData".to_vec(), data);
|
||||
let note = doc.add(note);
|
||||
doc.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
}
|
||||
|
||||
fn exported_note_data(doc: &Document) -> Object {
|
||||
let body = fdf_body(doc).unwrap();
|
||||
let annots = doc.resolve(body.get(b"Annots").unwrap());
|
||||
let note = doc.resolve(&annots.as_array().unwrap()[0]);
|
||||
note.as_dict().unwrap().get(b"PCData").unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion() {
|
||||
let mut src = blank();
|
||||
let leaf = src.add(PdfString::text("shared"));
|
||||
let mut shared = leaf;
|
||||
for _ in 0..35 {
|
||||
shared = src.add(Object::Array(vec![Object::Ref(shared), Object::Ref(shared)]));
|
||||
}
|
||||
let cycle = src.add(Object::Null);
|
||||
src.set(cycle, Object::Array(vec![Object::Ref(cycle)]));
|
||||
let mut stream_dict = Dict::new();
|
||||
stream_dict.set(b"PCMeta".to_vec(), Object::Ref(leaf));
|
||||
let stream = src.add(pdfcraft_cos::Stream::from_raw(stream_dict, b"original bytes".to_vec()));
|
||||
let data = Object::Array(vec![Object::Ref(shared), Object::Ref(shared), Object::Ref(cycle), Object::Ref(stream)]);
|
||||
fdf_note_with_data(&mut src, data);
|
||||
let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let bytes = export_fdf(&src, true, false, "source.pdf").unwrap();
|
||||
assert!(bytes.len() < 10_000, "a shared graph must not expand");
|
||||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||||
let data = exported_note_data(&fdf);
|
||||
let data = data.as_array().unwrap();
|
||||
assert_eq!(data[0], data[1]);
|
||||
let mut current = data[0].clone();
|
||||
for _ in 0..35 {
|
||||
let next = fdf.resolve(¤t);
|
||||
let values = next.as_array().unwrap();
|
||||
assert_eq!(values[0], values[1]);
|
||||
current = values[0].clone();
|
||||
}
|
||||
assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "shared");
|
||||
let cycle = fdf.resolve(&data[2]);
|
||||
assert_eq!(cycle.as_array().unwrap()[0], data[2]);
|
||||
let stream = fdf.resolve(&data[3]);
|
||||
let Object::Stream(stream) = &*stream else { panic!("copied stream") };
|
||||
assert_eq!(&*stream.raw, b"original bytes");
|
||||
assert_eq!(fdf.resolve(stream.dict.get(b"PCMeta").unwrap()).as_string().unwrap().to_text(), "shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_walks_long_indirect_chains_and_nulls_absent_or_stale_refs() {
|
||||
let mut src = blank();
|
||||
let live = src.add(PdfString::text("live"));
|
||||
let mut head = live;
|
||||
for _ in 0..3_000 {
|
||||
head = src.add(Object::Array(vec![Object::Ref(head)]));
|
||||
}
|
||||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Ref(head), Object::Ref(ObjRef::new(999_999, 0)), Object::Ref(ObjRef::new(live.num, 1))]));
|
||||
let fdf = Document::open(Arc::new(export_fdf(&src, true, false, "source.pdf").unwrap())).unwrap();
|
||||
let data = exported_note_data(&fdf);
|
||||
let data = data.as_array().unwrap();
|
||||
assert_eq!(*fdf.resolve(&data[1]), Object::Null);
|
||||
assert_eq!(*fdf.resolve(&data[2]), Object::Null);
|
||||
let mut current = data[0].clone();
|
||||
for _ in 0..3_000 {
|
||||
current = fdf.resolve(¤t).as_array().unwrap()[0].clone();
|
||||
}
|
||||
assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "live");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_refuses_excessive_direct_depth_values_and_payload() {
|
||||
let mut src = blank();
|
||||
let mut deep = Object::Null;
|
||||
for _ in 0..70 {
|
||||
deep = Object::Array(vec![deep]);
|
||||
}
|
||||
fdf_note_with_data(&mut src, deep);
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 direct nesting levels")));
|
||||
// Not exporting comments must not inspect their extension data.
|
||||
assert!(export_fdf(&src, false, true, "source.pdf").is_ok());
|
||||
|
||||
let mut src = blank();
|
||||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; MAX_FDF_OBJECTS + 1]));
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values")));
|
||||
|
||||
let mut src = blank();
|
||||
let stream = src.add(pdfcraft_cos::Stream::from_raw(Dict::new(), vec![0; MAX_FDF_BYTES + 1]));
|
||||
fdf_note_with_data(&mut src, Object::Ref(stream));
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data")));
|
||||
|
||||
let mut src = blank();
|
||||
// Strings need up to four serialized bytes per source byte. Refuse before copying
|
||||
// the direct payload, just as the indirect stream case above refuses its raw data.
|
||||
fdf_note_with_data(&mut src, Object::String(PdfString::literal(vec![b'x'; MAX_FDF_BYTES / 4 + 1])));
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_refuses_aliases_before_cloning_large_annotation_values() {
|
||||
let mut src = blank();
|
||||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; 1_024]));
|
||||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||||
let annots = src.get(page).as_dict().unwrap().get(b"Annots").unwrap().clone();
|
||||
let note = annots.as_array().unwrap()[0].as_ref().unwrap();
|
||||
// The source holds one 1,024-value dictionary. The aliases must not clone its
|
||||
// values a hundred thousand times before the comment-count refusal is reached.
|
||||
src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note); MAX_FDF_OBJECTS + 1]))).unwrap();
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 comments")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_refuses_repeated_saved_annotations_after_cache_eviction() {
|
||||
let bytes = {
|
||||
let mut src = blank();
|
||||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||||
let mut notes = Vec::new();
|
||||
// More than the editor cache's 1,024 objects. Four passes force reparsing;
|
||||
// retaining each resolved body during discovery would pin all four copies.
|
||||
for _ in 0..1_025 {
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||||
note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 1_024]));
|
||||
notes.push(Object::Ref(src.add(note)));
|
||||
}
|
||||
let aliases = (0..4).flat_map(|_| notes.iter().cloned()).collect();
|
||||
src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(aliases))).unwrap();
|
||||
write_full(&src, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap()
|
||||
};
|
||||
// Drop the overlay-backed source before reopening: its persistent Arc objects
|
||||
// would hide the parsed-object cache eviction exercised by this fixture.
|
||||
let src = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(src.repair_log().is_empty());
|
||||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_counts_emitted_field_values_before_allocating_lists() {
|
||||
let field_document = |kind: &str, count: usize| {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut field = Dict::new();
|
||||
field.set(b"FT".to_vec(), Object::name(kind));
|
||||
field.set(b"T".to_vec(), PdfString::text("Pick"));
|
||||
field.set(b"Ff".to_vec(), Object::Int(1 << 21)); // multi-select for a /Ch list
|
||||
field.set(b"V".to_vec(), Object::Array(vec![Object::String(PdfString::text("")); count]));
|
||||
let field = doc.add(field);
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)]));
|
||||
doc.update_dict(doc.root().unwrap(), |root| root.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
|
||||
assert_eq!(fields(&doc)[0].value.len(), count);
|
||||
doc
|
||||
};
|
||||
|
||||
// A field dictionary, /T string and /V array use three nodes before its entries.
|
||||
let doc = field_document("Ch", MAX_FDF_OBJECTS - 3);
|
||||
assert_eq!(fields(&doc)[0].kind, FieldKind::List);
|
||||
let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap();
|
||||
assert_eq!(data_values(&bytes).unwrap()[0].1.len(), MAX_FDF_OBJECTS - 3);
|
||||
for count in [MAX_FDF_OBJECTS - 2, MAX_FDF_OBJECTS + 1] {
|
||||
let doc = field_document("Ch", count);
|
||||
assert!(matches!(export_fdf(&doc, false, true, "field.pdf"), Err(DataError::ExportLimit("100,000 values"))));
|
||||
}
|
||||
|
||||
// A hostile array on a scalar field still emits just its first string. The node
|
||||
// budget counts output values, rather than charging entries that are not copied.
|
||||
let doc = field_document("Tx", MAX_FDF_OBJECTS + 1);
|
||||
assert_eq!(fields(&doc)[0].kind, FieldKind::Text);
|
||||
let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap();
|
||||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||||
let body = fdf_body(&fdf).unwrap();
|
||||
let exported = fdf.resolve(body.get(b"Fields").unwrap());
|
||||
let exported = fdf.resolve(&exported.as_array().unwrap()[0]);
|
||||
assert!(matches!(exported.as_dict().unwrap().get(b"V"), Some(Object::String(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_form_import_preserves_text_around_comments_and_instructions() {
|
||||
for (body, expected) in [
|
||||
@@ -1187,6 +1419,132 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() {
|
||||
for indirect_type in [false, true] {
|
||||
let source = {
|
||||
let mut doc = Document::new_empty();
|
||||
let root = doc.root().unwrap();
|
||||
let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let mut pages = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let contents = doc.add(pdfcraft_cos::Stream::from_raw(
|
||||
Dict::new(),
|
||||
b"% source-page-content-must-stay-in-the-PDF\nq 0 g 0 0 10 10 re f Q\n".repeat(1_024),
|
||||
));
|
||||
let mut page = Dict::new();
|
||||
let page_type = if indirect_type { Object::Ref(doc.add(Object::name("Page"))) } else { Object::name("Page") };
|
||||
page.set(b"Type".to_vec(), page_type);
|
||||
page.set(b"Parent".to_vec(), Object::Ref(tree));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
pages.push(doc.add(page));
|
||||
}
|
||||
let tree_type = if indirect_type { Object::Ref(doc.add(Object::name("Pages"))) } else { Object::name("Pages") };
|
||||
doc.update_dict(tree, |dict| {
|
||||
dict.set(b"Type".to_vec(), tree_type);
|
||||
dict.set(b"Count".to_vec(), Object::Int(3));
|
||||
dict.set(b"Kids".to_vec(), Object::Array(pages.iter().copied().map(Object::Ref).collect()));
|
||||
})
|
||||
.unwrap();
|
||||
let mut action = Dict::new();
|
||||
action.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(pages[1]), Object::name("Fit")]));
|
||||
let mut extension = Dict::new();
|
||||
extension.set(b"Type".to_vec(), Object::name("PCMetadata"));
|
||||
extension.set(b"Target".to_vec(), Object::Ref(pages[1]));
|
||||
extension.set(b"Label".to_vec(), PdfString::text("retained metadata"));
|
||||
let extension = doc.add(extension);
|
||||
let type_cycle = doc.add(Object::Null);
|
||||
doc.set(type_cycle, Object::Ref(type_cycle));
|
||||
let mut cyclic_type = Dict::new();
|
||||
cyclic_type.set(b"Type".to_vec(), Object::Ref(type_cycle));
|
||||
cyclic_type.set(b"Label".to_vec(), PdfString::text("cyclic type is not a page"));
|
||||
fdf_note_with_data(
|
||||
&mut doc,
|
||||
Object::Array(vec![Object::Dict(action), Object::Ref(extension), Object::Array(vec![Object::Ref(tree)]), Object::Dict(cyclic_type)]),
|
||||
);
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
};
|
||||
let source = Document::open(Arc::new(source)).unwrap();
|
||||
assert!(source.repair_log().is_empty());
|
||||
assert_eq!(pdfcraft_model::pages(&source).len(), 3);
|
||||
let bytes = export_fdf(&source, true, false, "source.pdf").unwrap();
|
||||
assert!(bytes.len() < 4_096, "a comment export must not carry source pages or their content ({} bytes)", bytes.len());
|
||||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||||
let data = exported_note_data(&fdf);
|
||||
let data = data.as_array().unwrap();
|
||||
let action = data[0].as_dict().unwrap();
|
||||
assert_eq!(action.name(b"S"), Some(b"GoTo".as_slice()));
|
||||
let destination = action.get(b"D").unwrap().as_array().unwrap();
|
||||
assert_eq!(*fdf.resolve(&destination[0]), Object::Null);
|
||||
assert_eq!(destination[1], Object::name("Fit"));
|
||||
let extension = fdf.resolve(&data[1]);
|
||||
let extension = extension.as_dict().unwrap();
|
||||
assert_eq!(extension.name(b"Type"), Some(b"PCMetadata".as_slice()));
|
||||
assert_eq!(extension.get(b"Target"), Some(&destination[0]), "the excluded page reference remains aliased");
|
||||
assert_eq!(extension.get(b"Label").unwrap().as_string().unwrap().to_text(), "retained metadata");
|
||||
let tree = data[2].as_array().unwrap();
|
||||
assert_eq!(*fdf.resolve(&tree[0]), Object::Null);
|
||||
|
||||
let cyclic_type = data[3].as_dict().unwrap();
|
||||
assert_eq!(cyclic_type.get(b"Label").unwrap().as_string().unwrap().to_text(), "cyclic type is not a page");
|
||||
let type_cycle = cyclic_type.get(b"Type").unwrap();
|
||||
assert_eq!(&*fdf.resolve(type_cycle), type_cycle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_bounds_indirect_type_alias_lookups() {
|
||||
for extra_aliases in [MAX_DEPTH - 1, MAX_DEPTH] {
|
||||
let mut doc = blank();
|
||||
let mut page_type = doc.add(Object::name("Page"));
|
||||
for _ in 0..extra_aliases {
|
||||
page_type = doc.add(Object::Ref(page_type));
|
||||
}
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::Ref(page_type));
|
||||
fdf_note_with_data(&mut doc, Object::Dict(page));
|
||||
let exported = export_fdf(&doc, true, false, "");
|
||||
if extra_aliases < MAX_DEPTH {
|
||||
let fdf = Document::open(Arc::new(exported.unwrap())).unwrap();
|
||||
assert_eq!(exported_note_data(&fdf), Object::Null);
|
||||
} else {
|
||||
assert!(matches!(exported, Err(DataError::ExportLimit("64 Type reference levels"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_preserves_metadata_with_stale_type_references() {
|
||||
for alias in [false, true] {
|
||||
let mut doc = blank();
|
||||
let current = doc.add(Object::name("Page"));
|
||||
doc.update_dict(doc.root().unwrap(), |dict| dict.set(b"PCPageType".to_vec(), Object::Ref(current))).unwrap();
|
||||
let mut doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let current = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"PCPageType").unwrap();
|
||||
let stale = ObjRef::new(current.num, current.generation + 1);
|
||||
let page_type = if alias { doc.add(Object::Ref(stale)) } else { stale };
|
||||
let mut metadata = Dict::new();
|
||||
metadata.set(b"Type".to_vec(), Object::Ref(page_type));
|
||||
metadata.set(b"Label".to_vec(), PdfString::text("preserved despite stale type"));
|
||||
fdf_note_with_data(&mut doc, Object::Dict(metadata));
|
||||
assert_eq!(doc.generation(stale.num), current.generation);
|
||||
// A warm number-keyed cache must not make the stale Type reference live.
|
||||
assert_eq!(*doc.try_get(stale.num).unwrap(), Object::name("Page"));
|
||||
let bytes = export_fdf(&doc, true, false, "").unwrap();
|
||||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||||
let data = exported_note_data(&fdf);
|
||||
let metadata = data.as_dict().expect("stale Type must not classify unrelated metadata as a source page");
|
||||
assert_eq!(metadata.get(b"Label").unwrap().as_string().unwrap().to_text(), "preserved despite stale type");
|
||||
let mut page_type = fdf.resolve(metadata.get(b"Type").unwrap());
|
||||
if alias {
|
||||
page_type = fdf.resolve(&page_type);
|
||||
}
|
||||
assert_eq!(*page_type, Object::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_headers_with_tabs_survive_export_import_and_save() {
|
||||
for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] {
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
@@ -131,7 +132,7 @@ total matches the parity estimate.
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
@@ -199,6 +200,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
|
||||
| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
|
||||
|
||||
@@ -4160,8 +4160,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.export"]
|
||||
tools = ["doc_export_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/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 = "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/automation.rs::fdf_nested_comment_values_survive_export_import_and_save_through_tools", "crates/automation/tests/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/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."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.export-selected"
|
||||
@@ -4692,8 +4692,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.export_data"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
evidence = ["crates/xfdf/src/tests.rs::fdf_export_counts_emitted_field_values_before_allocating_lists", "crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "XFDF, FDF; FDF counts emitted field values, including every multi-select list entry, before allocating the output list and refuses exports beyond its shared 100,000-value budget. Existing source-field parsing limits remain unchanged. XFA forms also export their data as XML or XDP (form.xfa-data). XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
|
||||
[[feature]]
|
||||
id = "form.data-xml-csv-txt"
|
||||
|
||||
Reference in New Issue
Block a user