diff --git a/ROADMAP.md b/ROADMAP.md index 80b3687175..03f730704e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,9 @@ > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged) +> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -318,6 +321,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. - **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. - **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates are indirect, requiring exactly four finite resolved values. +- **2026-10-26 (M10):** Vector printing follows up to 64 indirect references for annotation rectangles, coordinates and appearance-state names, rejecting cycles and longer chains without changing source objects. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries. + Synthetic CLI and source regressions cover the behavior. + Estimates unchanged. + - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed. @@ -870,6 +879,9 @@ Estimates unchanged. | 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | +| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/crates/print/README.md b/crates/print/README.md index 9b11216f3d..d4478fcbf5 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -132,3 +132,10 @@ a matching page. On Windows craft-print sends the sheets as vectors with a Print for the sheet's exact paper and no scaling (#599's approach), validated by the driver: a job is refused, not shrunk, when the driver lacks the paper or would change it. Verify the driver's paper settings and physical calibration before relying on drawing scale. + +Vector printing resolves direct and indirect annotation rectangle coordinates before placing existing appearances. +Rectangles must contain exactly four finite numbers; malformed rectangles are skipped. +For an appearance-state dictionary, direct or indirect `AS` names select the existing artwork. +Rectangle arrays, individual coordinates and `AS` names follow up to 64 indirect references with cycle detection. +Missing, invalid, unresolved, cyclic, overly deep, or unknown state names leave that annotation unprinted; a single normal appearance stream does not require `AS`. +The print content mode still controls whether a field or comment prints, and printing preserves the source objects. diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 94add4713b..b59e620328 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -111,15 +111,30 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { } } -/// The normal appearance stream to draw for an annotation (honouring `/AS`). +/// Read an annotation value through at most 64 indirect references. +/// Standalone indirect objects can themselves hold references (ISO 32000-2 §7.3.10). +/// Cycles and longer chains are unusable, like other invalid annotation values. +fn annotation_value(doc: &Document, value: &Object) -> Option> { + let mut value = std::sync::Arc::new(value.clone()); + let mut seen = std::collections::HashSet::new(); + while let Object::Ref(reference) = &*value { + if seen.len() == 64 || !seen.insert(*reference) { + return None; + } + value = doc.get(*reference); + } + Some(value) +} + +/// The normal appearance stream to draw, resolving the annotation's `/AS` state name. pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { let ap = doc.resolve(d.get(b"AP")?); let normal = ap.as_dict()?.get(b"N")?.clone(); match &*doc.resolve(&normal) { Object::Stream(_) => normal.as_ref(), Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() + let state = annotation_value(doc, d.get(b"AS")?)?; + states.get(state.as_name()?)?.as_ref() } _ => None, } @@ -182,12 +197,15 @@ pub(crate) fn page_form( if !prints(doc, &d, content) { continue; } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + // A rectangle has four numbers, and each can be an indirect object (ISO 32000-2 §7.3.10). + let rect = d.get(b"Rect").and_then(|r| { + let rect = annotation_value(doc, r)?; + let [x0, y0, x1, y1] = rect.as_array()?.as_slice() else { return None }; + let number = |v: &Object| annotation_value(doc, v)?.as_f64().filter(|v| v.is_finite()); + Some([number(x0)?, number(y0)?, number(x1)?, number(y1)?]) + }); + let Some([x0, y0, x1, y1]) = rect else { continue }; + let rect = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]; let Some(ap) = appearance(doc, &d) else { continue }; let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index c10948de75..bcb0dd60e5 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -79,6 +79,14 @@ fn saved(doc: &Document) -> Vec { write_full(doc, &SaveOptions::default()).unwrap() } +/// Wrap a synthetic value in exactly `depth` standalone indirect objects. +fn reference_chain(doc: &mut Document, mut value: Object, depth: usize) -> Object { + for _ in 0..depth { + value = Object::Ref(doc.add(value)); + } + value +} + fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { let resources = doc.resolve(owner.get(b"Resources").unwrap()); let objects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); @@ -185,6 +193,291 @@ fn annotation_appearances_do_not_replace_page_xobjects() { } } +/// Existing field/comment artwork must survive vector printing when any Rect coordinate is indirect. +#[test] +fn indirect_annotation_rectangles_keep_their_printed_appearances() { + for (subtype, content) in [("Widget", Content::Document), ("Square", Content::DocumentAndMarkups)] { + for outer_indirect in [false, true] { + for reversed in [false, true] { + for mask in [0usize, 1, 0b1010, 0b1111] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let values = if reversed { [50.0, 40.25, 10.0, 20.25] } else { [10.0, 20.25, 50.0, 40.25] }; + let coordinates = values.into_iter().enumerate().map(|(i, value)| { + let value = if i % 2 == 0 { Object::Int(value as i64) } else { Object::Real(value) }; + if mask & (1 << i) != 0 { Object::Ref(doc.add(value)) } else { value } + }); + let rect = Object::Array(coordinates.collect()); + let rect = if outer_indirect { Object::Ref(doc.add(rect)) } else { rect }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), rect); + }) + .unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), [0, 255, 0, 255], "source appearance"); + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + [0, 255, 0, 255], + "{subtype}, outer_indirect={outer_indirect}, reversed={reversed}, mask={mask:04b}" + ); + assert_eq!(printed.rgba, source.rgba, "reopened printed pixels match the source"); + assert_eq!(saved(&doc), before, "printing leaves the source objects unchanged"); + } + } + } + } +} + +/// State dictionaries select the same artwork for direct and indirect AS names, for both +/// widgets and comments, without changing the document or the selected print content mode. +#[test] +fn indirect_annotation_states_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for indirect_state in [false, true] { + for indirect_states in [false, true] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let states = Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])); + let states = if indirect_states { Object::Ref(doc.add(states)) } else { states }; + let state = Object::name(state_name); + let state = if indirect_state { Object::Ref(doc.add(state)) } else { state }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Widget" { + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"V".to_vec(), Object::name(state_name)); + } else { + d.remove(b"FT"); + } + d.set(b"AS".to_vec(), state); + d.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), states)]))); + }) + .unwrap(); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), color, "the source selects its stated appearance"); + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, indirect_state={indirect_state}, indirect_states={indirect_states}, {content:?}" + ); + assert_eq!(pixel(&printed, 4, 96), if content == Content::FormFieldsOnly { [255; 4] } else { [0, 0, 255, 255] }); + if shown && content != Content::FormFieldsOnly { + assert_eq!(printed.rgba, source.rgba, "the saved/reopened print keeps all page and annotation pixels"); + } + } + assert_eq!(saved(&doc), before, "printing does not rewrite the source annotation or appearance state"); + } + } + } + } +} + +/// Standalone indirect objects can contain further references, including rectangle arrays, +/// individual coordinates and AS names. Printing must match the equivalent direct values. +#[test] +fn chained_annotation_values_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for (rect_depth, coordinate_depth, state_depth) in [(2, 0, 0), (0, 2, 0), (0, 0, 2), (2, 2, 2), (64, 64, 64)] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let values = [Object::Int(50), Object::Real(40.25), Object::Int(10), Object::Real(20.25)]; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), Object::Array(values.to_vec())); + d.set(b"AS".to_vec(), Object::name(state_name)); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([( + b"N".to_vec(), + Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])), + )])), + ); + }) + .unwrap(); + let modes = [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly]; + let controls: Vec<_> = modes.iter().map(|&content| render(impose(&doc, &print_settings(content)).unwrap())).collect(); + // Mix direct, one-hop and chained integer/real coordinates in the same rectangle. + let coordinates = values + .into_iter() + .enumerate() + .map(|(i, value)| { + reference_chain( + &mut doc, + value, + if coordinate_depth == 0 || i == 0 { + 0 + } else if i == 2 { + 1 + } else { + coordinate_depth + }, + ) + }) + .collect(); + let rect = reference_chain(&mut doc, Object::Array(coordinates), rect_depth); + let state = reference_chain(&mut doc, Object::name(state_name), state_depth); + doc.update_dict(annot, |d| { + d.set(b"Rect".to_vec(), rect); + d.set(b"AS".to_vec(), state); + }) + .unwrap(); + // ISO 32000-2 permits reference-to-reference objects outside object streams. + let options = SaveOptions { object_streams: false, ..SaveOptions::default() }; + let doc = Document::open(Arc::new(write_full(&doc, &options).unwrap())).unwrap(); + let before = write_full(&doc, &options).unwrap(); + let state = doc.read_state(); + let graph: Vec<_> = doc.scan_objects().collect(); + for (&content, control) in modes.iter().zip(controls) { + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, {state_name}, depths={rect_depth}/{coordinate_depth}/{state_depth}, {content:?}" + ); + assert_eq!(printed.rgba, control.rgba, "chained values preserve the complete direct-control output"); + } + assert!(state.matches(&doc), "printing preserves the source read state"); + assert_eq!(doc.scan_objects().collect::>(), graph, "printing preserves the source object graph"); + assert_eq!(write_full(&doc, &options).unwrap(), before, "printing preserves standalone reference chains"); + } + } + } +} + +/// Cycles, missing targets and reference chains beyond the print reader's limit skip only +/// the unusable annotation, retaining page content and leaving source values unchanged. +#[test] +fn invalid_annotation_reference_chains_are_skipped_when_printing() { + for property in ["rectangle", "coordinate", "state"] { + for kind in ["cycle", "unresolved", "too-deep"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let value = match kind { + "cycle" => { + let first = doc.add(Object::Null); + let second = doc.add(Object::Ref(first)); + doc.set(first, Object::Ref(second)); + Object::Ref(first) + } + "unresolved" => reference_chain(&mut doc, Object::Ref(ObjRef::new(999_999, 0)), 2), + "too-deep" => { + let value = match property { + "rectangle" => numbers(&[10.0, 20.0, 50.0, 40.0]), + "coordinate" => Object::Int(40), + "state" => Object::name("Yes"), + _ => panic!("unknown property {property}"), + }; + reference_chain(&mut doc, value, 65) + } + _ => panic!("unknown kind {kind}"), + }; + doc.update_dict(annot, |d| match property { + "rectangle" => d.set(b"Rect".to_vec(), value), + "coordinate" => d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), value])), + "state" => { + d.set(b"AS".to_vec(), value); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + } + _ => panic!("unknown property {property}"), + }) + .unwrap(); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "invalid {property} chain ({kind}) does not select artwork"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page content survives invalid {property} chain ({kind})"); + assert_eq!(saved(&doc), before, "invalid {property} chain ({kind}) is not rewritten"); + } + } +} + +/// Malformed, missing and cyclic AS values do not select arbitrary appearance states, while +/// a single normal appearance stream continues to work without a usable state name. +#[test] +fn invalid_annotation_states_do_not_select_an_appearance() { + for kind in ["absent", "null", "integer", "string", "missing-name", "unresolved", "cyclic"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let state = match kind { + "absent" => None, + "null" => Some(Object::Null), + "integer" => Some(Object::Int(1)), + "string" => Some(Object::String(pdfcraft_cos::PdfString::literal(b"Yes".to_vec()))), + "missing-name" => Some(Object::name("Missing")), + "unresolved" => Some(Object::Ref(ObjRef::new(999_999, 0))), + "cyclic" => { + let r = doc.add(Object::Null); + doc.set(r, Object::Ref(r)); + Some(Object::Ref(r)) + } + _ => panic!("unknown fixture kind {kind}"), + }; + doc.update_dict(annot, |d| { + if let Some(state) = state { + d.set(b"AS".to_vec(), state); + } + }) + .unwrap(); + assert_eq!(appearance(&doc, doc.get(annot).as_dict().unwrap()), Some(ap), "a normal stream does not need AS: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "the normal stream still prints: {kind}"); + doc.update_dict(annot, |d| { + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + }) + .unwrap(); + let before = saved(&doc); + assert!(appearance(&doc, doc.get(annot).as_dict().unwrap()).is_none(), "invalid state must not choose Yes: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "the invalid state is skipped: {kind}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page artwork is retained: {kind}"); + assert_eq!(saved(&doc), before, "invalid source data is not rewritten: {kind}"); + } +} + +/// Malformed Rect values must not become printable rectangles by dropping invalid entries. +#[test] +fn invalid_annotation_rectangles_are_skipped_when_printing() { + for invalid in [ + Object::Null, + numbers(&[10.0, 20.0, 50.0]), + numbers(&[10.0, 20.0, 50.0, 40.0, 60.0]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), 40.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(ObjRef::new(999_999, 0))]), + numbers(&[f64::NAN, 20.0, 50.0, 40.0]), + numbers(&[10.0, f64::INFINITY, 50.0, 40.0]), + numbers(&[10.0, 20.0, f64::NEG_INFINITY, 40.0]), + ] { + for outer_indirect in [false, true] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let rect = if outer_indirect { Object::Ref(doc.add(invalid.clone())) } else { invalid.clone() }; + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), rect)).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "invalid={invalid:?}, outer_indirect={outer_indirect}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "ordinary page content still prints"); + } + } + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let cycle = doc.add(Object::Null); + doc.set(cycle, Object::Ref(cycle)); + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(cycle)]))).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "cyclic coordinate references are skipped"); +} + #[test] fn missing_subtype_appearance_survives_vector_print() { let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); diff --git a/docs/roadmap.md b/docs/roadmap.md index b2674a9776..11c7fcf655 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,6 +38,9 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged) +> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the optimizer stores Group 3 fax images as Group 4, and packs black-and-white RGB and indexed images that use only palette entries 0 and 1 to one bit; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white images above 450 ppi are averaged down to 300 ppi by the optimizer; estimates unchanged) @@ -262,6 +265,10 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries. Rectangle arrays, coordinates and state names follow up to 64 indirect references with cycle detection. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | @@ -361,6 +368,9 @@ Estimates unchanged. | 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | +| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c198e6f4e1..ce9188932e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8345,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients"] -notes = "Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path." +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"] +notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path." [[feature]] id = "print.print-with-comment-summary"