M10: preserve annotation layers in vector print output (#5019)

* M10: preserve annotation layers in vector printing (#5005)

* #5019 test: a Popup is the annotation that never prints; a Link prints since #5028

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Kyle KassenandFredApps authored and GitHub committed 2026-11-10 18:26:50 +01:00
1 parent 655aa92b5e
commit 92b965dba3
7 files changed
+612 -5

No files matched your search

+6
View File
@@ -40,6 +40,7 @@
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged)
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
>
>
@@ -252,6 +253,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-11-09 (M10, #5130):** 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. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance.
Shared appearances and existing layer state remain independent; estimates unchanged.
- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear.
Print eligibility, source dictionaries and shared appearance streams remain unchanged.
@@ -864,6 +868,8 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
Source resources and shared appearances remain unchanged.
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
+5
View File
@@ -54,6 +54,11 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr
annotations); sheets place them with a clip. The result is a fresh, unencrypted,
garbage-collected file (callers check the print permission).
Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix.
Vector printing also carries each annotation's optional-content membership onto its own appearance invocation.
Hidden groups, membership policies, visibility expressions and existing layer overrides therefore remain effective in the generated PDF, including when appearances are shared.
Malformed top-level membership or required resource containers produce a print error instead of an unguarded appearance.
Nested policy data is preserved without a new evaluator; readers remain responsible for optional-content semantics.
The separate on-screen and Print as image annotation-visibility gap is tracked in #3572 / #3624.
Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices.
Existing printable NoRotate appearances keep their orientation and upper-left rectangle anchor when the source page is rotated.
Text annotations use this upright placement even with the NoRotate flag clear (ISO 32000-1 section 12.5.6.4).
+91 -3
View File
@@ -163,6 +163,56 @@ pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) ->
Some(a)
}
/// Resolve optional-content resources without turning a loading failure or reference cycle
/// into an absent entry. Keep the terminal object's identity for catalog OCG state.
fn optional_object(doc: &Document, value: &Object) -> Result<(Option<ObjRef>, std::sync::Arc<Object>), PrintError> {
let mut object = std::sync::Arc::new(value.clone());
let mut reference = None;
let mut seen = std::collections::HashSet::new();
while let Object::Ref(r) = &*object {
if seen.len() >= 64 || !seen.insert(*r) {
return Err(PrintError::Invalid("an optional-content resource has a cyclic reference or more than 64 references".into()));
}
reference = Some(*r);
object = doc.try_get(r.num)?;
}
Ok((reference, object))
}
/// Preserve the complete membership rather than evaluating visibility here. The reader owns
/// policy/VE evaluation; the annotation and its appearance can have independent memberships.
fn annotation_membership(doc: &mut Document, annotation: &Dict) -> Result<Option<ObjRef>, PrintError> {
let Some(value) = annotation.get(b"OC") else { return Ok(None) };
let (reference, object) = optional_object(doc, value)?;
let dict = match &*object {
Object::Null => return Ok(None), // Null and undefined references are absent PDF dictionary entries.
Object::Dict(dict) => dict,
_ => return Err(PrintError::Invalid("an annotation's optional-content entry is not an OCG or OCMD dictionary".into())),
};
let kind = dict.get(b"Type").map(|value| optional_object(doc, value)).transpose()?;
match kind.as_ref().and_then(|(_, object)| object.as_name()) {
Some(b"OCG") => reference.map(Some).ok_or_else(|| PrintError::Invalid("an annotation's optional-content group must be indirect".into())),
Some(b"OCMD") => {
// Named Properties are indirect for interoperable OCG identity and for the renderer's
// resource path. An OCMD has no group identity; promoting its intact dict is harmless.
Ok(Some(reference.unwrap_or_else(|| doc.add(Object::Dict(dict.clone())))))
}
_ => Err(PrintError::Invalid("an annotation's optional-content dictionary must have Type OCG or OCMD".into())),
}
}
/// Clone existing marked-content properties, including unrelated entries. Never replace an
/// unreadable map with an empty one: doing so can expose the page's own optional content.
fn optional_properties(doc: &Document, resources: &Dict) -> Result<Dict, PrintError> {
let Some(value) = resources.get(b"Properties") else { return Ok(Dict::new()) };
let (_, object) = optional_object(doc, value)?;
match &*object {
Object::Null => Ok(Dict::new()),
Object::Dict(dict) => Ok(dict.clone()),
_ => Err(PrintError::Invalid("cannot preserve annotation layers: page Properties is not a dictionary".into())),
}
}
/// ISO 32000-1 section 12.5.3: counterrotate the fitted appearance about Rect's upper-left
/// corner in default user space. Crop, UserUnit and sheet transforms remain outside it.
fn no_rotate_placement(placement: Matrix, rect: [f64; 4], rotation: i64) -> Option<Matrix> {
@@ -189,7 +239,8 @@ pub(crate) fn page_form(
) -> Result<ObjRef, PrintError> {
let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?;
let crop = page.crop(doc);
let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let resources = page.dict.get(b"Resources").map(|value| optional_object(doc, value)).transpose();
let mut res = resources.as_ref().ok().and_then(|value| value.as_ref()).and_then(|(_, object)| object.as_dict()).cloned().unwrap_or_default();
let mut body = if content == Content::FormFieldsOnly {
Vec::new()
} else {
@@ -205,6 +256,8 @@ pub(crate) fn page_form(
}
let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default();
let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default();
let mut properties = None;
let mut property_index = 0usize;
let mut k = 0usize;
for a in annots {
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
@@ -232,6 +285,31 @@ pub(crate) fn page_form(
} else {
m
};
let membership = annotation_membership(doc, &d)?;
let property = if let Some(membership) = membership {
if properties.is_none() {
match &resources {
Err(error) => return Err(PrintError::Invalid(format!("cannot preserve annotation layers: {error}"))),
Ok(Some((_, object))) if !matches!(&**object, Object::Null | Object::Dict(_)) => {
return Err(PrintError::Invalid("cannot preserve annotation layers: page Resources is not a dictionary".into()));
}
_ => {}
}
properties = Some(optional_properties(doc, &res)?);
}
let properties = properties.get_or_insert_with(Dict::new);
let name = loop {
let name = format!("PCPrOC{property_index}");
property_index = property_index.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation layer resources".into()))?;
if !properties.contains(name.as_bytes()) {
break name;
}
};
properties.set(name.as_bytes().to_vec(), Object::Ref(membership));
Some(name)
} else {
None
};
let ap = if !form.contains(b"Subtype")
&& let Object::Stream(stream) = &*doc.get(ap)
{
@@ -259,6 +337,9 @@ pub(crate) fn page_form(
}
};
let [a0, b0, c0, d0, e0, f0] = m.0;
if let Some(name) = &property {
body.extend(format!("/OC /{name} BDC\n").bytes());
}
body.extend(
format!(
"q {} {} {} {} {} {} cm /{name} Do Q\n",
@@ -271,16 +352,23 @@ pub(crate) fn page_form(
)
.bytes(),
);
if property.is_some() {
body.extend_from_slice(b"EMC\n");
}
xobjects.set(name.into_bytes(), Object::Ref(ap));
}
// With no appearance added the form keeps the page's own Resources, shared by reference,
// rather than a deep copy per printed page.
// rather than a deep copy per printed page. An appearance in a layer (#5005) adds its
// membership to the copy's Properties, so a page with one is never shared.
let resources = match page.dict.get(b"Resources") {
Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(),
Some(r) if k == 0 && properties.is_none() && doc.resolve(r).as_dict().is_some() => r.clone(),
_ => {
if !xobjects.is_empty() {
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
}
if let Some(properties) = properties {
res.set(b"Properties".to_vec(), Object::Dict(properties));
}
Object::Dict(res)
}
};
+1
View File
@@ -1452,6 +1452,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() {
mod appearances;
mod font_names;
mod optional_content;
#[test]
fn repeated_content_references_share_one_decode_budget() {
+502
View File
@@ -0,0 +1,502 @@
use super::*;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
fn numbers(values: &[f64]) -> Object {
Object::Array(values.iter().copied().map(Object::Real).collect())
}
fn layer_fixture(off: bool) -> (Document, ObjRef, ObjRef, ObjRef) {
let mut doc = fixture(1);
let page = pdfcraft_model::pages(&doc)[0].obj;
let group = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("OCG")),
(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Annotation Layer"))),
])));
let properties = Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)])),
(
b"D".to_vec(),
Object::Dict(Dict::from_iter([
(b"BaseState".to_vec(), Object::name("ON")),
(b"OFF".to_vec(), Object::Array(if off { vec![Object::Ref(group)] } else { vec![] })),
])),
),
]);
doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap();
let form = Dict::from_iter([
(b"Subtype".to_vec(), Object::name("Form")),
(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])),
(b"Resources".to_vec(), Object::Dict(Dict::new())),
]);
let appearance = doc.add(Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f")));
let annotation = doc.add(Object::Dict(Dict::from_iter([
(b"Subtype".to_vec(), Object::name("Square")),
(b"F".to_vec(), Object::Int(4)),
(b"Rect".to_vec(), numbers(&[10.0, 20.0, 50.0, 40.0])),
(b"OC".to_vec(), Object::Ref(group)),
(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(appearance))]))),
])));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 1 rg 0 0 8 8 re f")));
doc.update_dict(page, |d| {
d.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0]));
d.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
d.set(b"Contents".to_vec(), Object::Ref(contents));
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation)]));
})
.unwrap();
(doc, group, annotation, appearance)
}
fn render(bytes: Vec<u8>) -> RenderedPage {
let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
page: 0,
kind: RequestKind::Pixels,
tile: None,
scale: 1.0,
tag: 0,
preview: false,
});
assert_eq!(page.error, None);
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
assert_eq!((page.width, page.height), (100, 100));
page
}
fn print(doc: &Document) -> Result<Vec<u8>, PrintError> {
impose(doc, &Settings { pages: vec![0], paper: (100.0, 100.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() })
}
#[test]
fn vector_print_preserves_annotation_layer_membership() {
for off in [false, true] {
let (doc, _, _, _) = layer_fixture(off);
let before = write_full(&doc, &SaveOptions::default()).unwrap();
let printed = print(&doc).unwrap();
let reopened = Document::open(Arc::new(printed.clone())).unwrap();
assert_eq!(pdfcraft_model::pages(&reopened).len(), 1);
let page = render(printed);
let pixel = |x: usize, y: usize| &page.rgba[(y * 100 + x) * 4..(y * 100 + x + 1) * 4];
assert_eq!(pixel(30, 70), if off { [255, 255, 255, 255] } else { [0, 255, 0, 255] }, "layer off={off}");
assert_eq!(pixel(4, 96), [0, 0, 255, 255], "ordinary page artwork");
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
}
fn saved(doc: &Document) -> Vec<u8> {
write_full(doc, &SaveOptions::default()).unwrap()
}
fn pixel(page: &RenderedPage, x: usize, y: usize) -> &[u8] {
let offset = (y * page.width as usize + x) * 4;
&page.rgba[offset..offset + 4]
}
fn assert_appearance(page: &RenderedPage, visible: bool) {
assert_eq!(pixel(page, 30, 70), if visible { [0, 255, 0, 255] } else { [255, 255, 255, 255] });
assert_eq!(pixel(page, 4, 96), [0, 0, 255, 255], "ordinary page artwork");
}
fn add_group(doc: &mut Document, name: &str, off: bool) -> ObjRef {
let group = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("OCG")),
(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text(name))),
])));
let root = doc.root().unwrap();
let mut properties = doc.resolve(doc.get(root).as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone();
let mut groups = properties.get(b"OCGs").unwrap().as_array().unwrap().clone();
groups.push(Object::Ref(group));
properties.set(b"OCGs".to_vec(), Object::Array(groups));
if off {
let mut config = properties.get(b"D").unwrap().as_dict().unwrap().clone();
let mut hidden = config.get(b"OFF").unwrap().as_array().unwrap().clone();
hidden.push(Object::Ref(group));
config.set(b"OFF".to_vec(), Object::Array(hidden));
properties.set(b"D".to_vec(), Object::Dict(config));
}
doc.update_dict(root, |d| d.set(b"OCProperties".to_vec(), Object::Dict(properties))).unwrap();
group
}
fn membership(groups: Vec<Object>, policy: Option<&str>, expression: Option<Vec<Object>>) -> Dict {
let mut dict = Dict::from_iter([
(b"Type".to_vec(), Object::name("OCMD")),
(b"OCGs".to_vec(), Object::Array(groups)),
(b"PrivatePolicy".to_vec(), Object::Array(vec![Object::Int(19), Object::name("KeepMe")])),
]);
if let Some(policy) = policy {
dict.set(b"P".to_vec(), Object::name(policy));
}
if let Some(expression) = expression {
dict.set(b"VE".to_vec(), Object::Array(expression));
}
dict
}
fn printed_form(doc: &Document) -> Dict {
let page = pdfcraft_model::pages(doc)[0].dict.clone();
let resources = doc.resolve(page.get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
doc.resolve(xobjects.as_dict().unwrap().get(b"P0").unwrap()).as_dict().unwrap().clone()
}
fn form_properties(doc: &Document) -> Dict {
let form = printed_form(doc);
let resources = doc.resolve(form.get(b"Resources").unwrap());
doc.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()).as_dict().unwrap().clone()
}
#[test]
fn vector_print_preserves_ocmd_policies_and_visibility_expressions() {
for indirect in [false, true] {
for group_shape in 0..3 {
let single_group = group_shape != 0;
for (policy, visible) in [("AllOn", false), ("AnyOn", !single_group), ("AnyOff", true), ("AllOff", single_group)] {
let (mut doc, off, annotation, appearance) = layer_fixture(true);
let on = add_group(&mut doc, "Visible", false);
let groups = if single_group { vec![Object::Ref(off)] } else { vec![Object::Ref(off), Object::Ref(on)] };
let mut dict = membership(groups, Some(policy), None);
if group_shape == 2 {
dict.set(b"OCGs".to_vec(), Object::Ref(off));
}
let private = dict.get(b"PrivatePolicy").unwrap().clone();
let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) };
doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap();
let before = saved(&doc);
let original_appearance = doc.get(appearance);
let printed = print(&doc).unwrap();
assert_appearance(&render(printed.clone()), visible);
let reopened = Document::open(Arc::new(printed)).unwrap();
let props = form_properties(&reopened);
let membership = props.get(b"PCPrOC0").unwrap();
assert!(membership.as_ref().is_some(), "direct OCMD must be promoted for named-resource rendering");
assert_eq!(reopened.resolve(membership).as_dict().unwrap().get(b"PrivatePolicy"), Some(&private));
assert_appearance(&render(print(&reopened).unwrap()), visible);
assert_eq!(doc.get(appearance), original_appearance);
assert_eq!(saved(&doc), before, "policy={policy}, indirect={indirect}, single={single_group}");
}
}
for case in 0..6 {
let (mut doc, off, annotation, _) = layer_fixture(true);
let on = add_group(&mut doc, "Visible", false);
let mut dict = membership(vec![Object::Ref(off), Object::Ref(on)], None, None);
let visible = match case {
0 => true, // Missing P defaults to AnyOn.
1 => {
dict.set(b"P".to_vec(), Object::name("AllOn"));
dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(off)]));
true // VE overrides the false AllOn policy.
}
2 => {
dict.set(b"P".to_vec(), Object::name("AnyOn"));
dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)]));
false // VE also overrides a true policy.
}
3 => {
dict.remove(b"OCGs");
true // Missing OCGs is a valid no-op.
}
4 => {
dict.set(b"OCGs".to_vec(), Object::Array(vec![]));
true
}
_ => {
dict.remove(b"OCGs");
dict.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(on)]));
false // VE-only membership is valid.
}
};
let oc = if indirect { Object::Ref(doc.add(Object::Dict(dict))) } else { Object::Dict(dict) };
doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), oc)).unwrap();
let before = saved(&doc);
assert_appearance(&render(print(&doc).unwrap()), visible);
assert_eq!(saved(&doc), before, "OCMD case={case}, indirect={indirect}");
}
}
}
#[test]
fn annotation_and_appearance_layers_are_independent_and_shared_appearances_stay_shared() {
for annotation_off in [false, true] {
for appearance_off in [false, true] {
let (mut doc, _, annotation, appearance) = layer_fixture(annotation_off);
let appearance_group = add_group(&mut doc, "Appearance", appearance_off);
doc.update_dict(appearance, |d| d.set(b"OC".to_vec(), Object::Ref(appearance_group))).unwrap();
let before = saved(&doc);
let first = print(&doc).unwrap();
assert_appearance(&render(first.clone()), !annotation_off && !appearance_off);
let reopened = Document::open(Arc::new(first)).unwrap();
assert_appearance(&render(print(&reopened).unwrap()), !annotation_off && !appearance_off);
assert_eq!(saved(&doc), before);
assert_eq!(doc.get(annotation).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N"), Some(appearance));
}
}
let (mut doc, _, annotation, appearance) = layer_fixture(true);
let visible = add_group(&mut doc, "Visible", false);
let mut other = doc.get(annotation).as_dict().unwrap().clone();
other.set(b"OC".to_vec(), Object::Ref(visible));
other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0]));
let other = doc.add(Object::Dict(other));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)]))).unwrap();
let before = saved(&doc);
let original = doc.get(appearance);
let first = print(&doc).unwrap();
let reopened = Document::open(Arc::new(first.clone())).unwrap();
for bytes in [first, print(&reopened).unwrap()] {
let image = render(bytes);
assert_appearance(&image, false);
assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255], "the other annotation uses the same appearance in a visible layer");
}
let form = printed_form(&reopened);
let resources = reopened.resolve(form.get(b"Resources").unwrap());
let objects = reopened.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
let objects = objects.as_dict().unwrap();
assert_eq!(objects.get(b"PCPrA0"), objects.get(b"PCPrA1"), "membership belongs to each invocation, not an appearance copy");
assert_eq!(doc.get(appearance), original);
assert_eq!(saved(&doc), before);
}
#[test]
fn annotation_layers_keep_colliding_inherited_and_shared_page_properties() {
for indirect_resources in [false, true] {
for indirect_properties in [false, true] {
for inherited in [false, true] {
let (mut doc, hidden, annotation, appearance) = layer_fixture(true);
let visible = add_group(&mut doc, "Visible", false);
let unrelated = Object::Dict(Dict::from_iter([(b"PrivateKey".to_vec(), Object::name("KeepMe"))]));
let properties = Dict::from_iter([
(b"PCPrOC0".to_vec(), Object::Ref(hidden)),
(b"PCPrOC1".to_vec(), Object::Ref(visible)),
(b"PCPrOC3".to_vec(), Object::Null),
(b"Unrelated".to_vec(), unrelated.clone()),
]);
let properties = if indirect_properties { Object::Ref(doc.add(Object::Dict(properties))) } else { Object::Dict(properties) };
let resources = Dict::from_iter([(b"Properties".to_vec(), properties)]);
let resources = if indirect_resources { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) };
// The appearance shares the page's resource owner, so in-place extension would change both.
doc.update_dict(appearance, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap();
let mut other = doc.get(annotation).as_dict().unwrap().clone();
other.set(b"OC".to_vec(), Object::Ref(visible));
other.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0]));
let other = doc.add(Object::Dict(other));
let contents = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"0 0 1 rg 0 0 8 8 re f\n/OC /PCPrOC0 BDC 1 0 0 rg 60 60 10 10 re f EMC\n/OC /PCPrOC1 BDC 0 0 1 rg 70 60 10 10 re f EMC\n/Span /Unrelated BDC 1 0 1 rg 80 60 10 10 re f EMC",
)));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(other)]));
})
.unwrap();
if inherited {
let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap();
doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
doc.update_dict(page, |d| {
d.remove(b"Resources");
})
.unwrap();
} else {
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
}
let before = saved(&doc);
let original_appearance = doc.get(appearance);
let first = print(&doc).unwrap();
let reopened = Document::open(Arc::new(first.clone())).unwrap();
let properties = form_properties(&reopened);
assert_eq!(properties.get(b"PCPrOC3"), Some(&Object::Null));
assert_eq!(properties.get(b"Unrelated"), Some(&unrelated));
assert_eq!(properties.get(b"PCPrOC2"), properties.get(b"PCPrOC0"));
assert_eq!(properties.get(b"PCPrOC4"), properties.get(b"PCPrOC1"));
for bytes in [first, print(&reopened).unwrap()] {
let image = render(bytes);
assert_appearance(&image, false);
assert_eq!(pixel(&image, 30, 40), [0, 255, 0, 255]);
assert_eq!(pixel(&image, 65, 35), [255, 255, 255, 255], "existing hidden page content");
assert_eq!(pixel(&image, 75, 35), [0, 0, 255, 255], "existing visible page content");
assert_eq!(pixel(&image, 85, 35), [255, 0, 255, 255], "unrelated marked-content property");
}
assert_eq!(doc.get(appearance), original_appearance);
assert_eq!(saved(&doc), before, "resources={indirect_resources}, properties={indirect_properties}, inherited={inherited}");
}
}
}
}
fn reference_chain(doc: &mut Document, mut value: Object, references: usize) -> Object {
for _ in 0..references {
value = Object::Ref(doc.add(value));
}
value
}
fn cycle(doc: &mut Document, two_nodes: bool) -> Object {
let first = doc.add(Object::Null);
let second = if two_nodes { doc.add(Object::Ref(first)) } else { first };
doc.set(first, Object::Ref(second));
Object::Ref(first)
}
#[test]
fn absent_annotation_membership_and_null_resource_maps_remain_valid() {
for case in 0..6 {
let (mut doc, _, annotation, _) = layer_fixture(true);
match case {
0 => {
doc.update_dict(annotation, |d| {
d.remove(b"OC");
})
.unwrap();
}
1 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Null)).unwrap(),
2 => doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))).unwrap(),
_ => {
let page = pdfcraft_model::pages(&doc)[0].obj;
let resources = match case {
3 => Object::Null,
4 => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Null)])),
_ => Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Ref(ObjRef::new(900_000, 0)))])),
};
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
}
}
let before = saved(&doc);
assert_appearance(&render(print(&doc).unwrap()), case < 3);
assert_eq!(saved(&doc), before, "null/absent case={case}");
}
}
#[test]
fn invalid_annotation_membership_is_refused_without_mutation() {
for case in 0..9 {
let (mut doc, _, annotation, _) = layer_fixture(false);
let value = match case {
0 => Object::Int(7),
1 => Object::name("Layer"),
2 => Object::Array(vec![]),
3 => Object::Dict(Dict::new()),
4 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("Page"))])),
5 => Object::Dict(Dict::from_iter([(b"Type".to_vec(), Object::name("OCG"))])),
6 => Object::Ref(doc.add(Object::Stream(Stream::flate(membership(vec![], None, None), b"")))),
7 => cycle(&mut doc, false),
_ => cycle(&mut doc, true),
};
doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap();
let before = saved(&doc);
assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "invalid membership case={case}");
assert_eq!(saved(&doc), before, "invalid membership case={case}");
}
}
#[test]
fn optional_content_reference_resolution_is_bounded_and_keeps_terminal_group_identity() {
for references in [64, 65] {
for target in ["membership", "type", "properties", "resources"] {
let (mut doc, group, annotation, _) = layer_fixture(true);
match target {
"membership" => {
// The final OCG is already indirect and counts as one hop.
let value = reference_chain(&mut doc, Object::Ref(group), references - 1);
doc.update_dict(annotation, |d| d.set(b"OC".to_vec(), value)).unwrap();
}
"type" => {
let value = reference_chain(&mut doc, Object::name("OCG"), references);
doc.update_dict(group, |d| d.set(b"Type".to_vec(), value)).unwrap();
}
_ => {
let value = reference_chain(&mut doc, Object::Dict(Dict::new()), references);
let resources = if target == "properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value };
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
}
}
let before = saved(&doc);
let result = print(&doc);
if references == 64 {
let bytes = result.unwrap();
// Check Type aliases structurally: rendering them would also test the
// renderer's separate reference-resolution behavior.
if target == "type" {
let reopened = Document::open(Arc::new(bytes)).unwrap();
let root = reopened.get(reopened.root().unwrap());
let catalog = reopened.resolve(root.as_dict().unwrap().get(b"OCProperties").unwrap());
let groups = catalog.as_dict().unwrap().get(b"OCGs").unwrap().as_array().unwrap();
assert_eq!(form_properties(&reopened).get(b"PCPrOC0"), groups.first());
} else {
assert_appearance(&render(bytes), false);
}
} else {
assert!(matches!(result, Err(PrintError::Invalid(_))), "target={target}");
}
assert_eq!(saved(&doc), before, "target={target}, references={references}");
}
}
}
#[test]
fn malformed_page_resource_maps_cannot_remove_optional_content_guards() {
for target in ["Resources", "Properties"] {
for case in 0..4 {
let (mut doc, _, _, _) = layer_fixture(true);
let value = match case {
0 => Object::Int(7),
1 => Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b"")))),
2 => cycle(&mut doc, false),
_ => cycle(&mut doc, true),
};
let resources = if target == "Properties" { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), value)])) } else { value };
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
let before = saved(&doc);
assert!(matches!(print(&doc), Err(PrintError::Invalid(_))), "target={target}, case={case}");
assert_eq!(saved(&doc), before, "target={target}, case={case}");
}
}
}
#[test]
fn malformed_membership_on_annotations_that_do_not_draw_does_not_block_printing() {
for case in 0..6 {
let (mut doc, _, annotation, _) = layer_fixture(false);
doc.update_dict(annotation, |d| {
d.set(b"OC".to_vec(), Object::Int(7));
match case {
0 => d.set(b"F".to_vec(), Object::Int(0)),
1 => d.set(b"F".to_vec(), Object::Int(6)),
// A Popup never prints. (A Link did not either, until #5028 printed existing
// Link appearances.)
2 => d.set(b"Subtype".to_vec(), Object::name("Popup")),
3 => {
d.remove(b"AP");
}
4 => d.set(b"Rect".to_vec(), Object::Array(vec![])),
_ => {}
}
})
.unwrap();
// Resource validation is also lazy: these maps are irrelevant when no guarded
// annotation invocation will be appended.
let page = pdfcraft_model::pages(&doc)[0].obj;
let resources =
if case % 2 == 0 { Object::Dict(Dict::from_iter([(b"Properties".to_vec(), Object::Int(7))])) } else { cycle(&mut doc, false) };
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), resources)).unwrap();
let before = saved(&doc);
let bytes = if case == 5 {
impose(
&doc,
&Settings {
pages: vec![0],
paper: (100.0, 100.0),
layout: Layout::Size(SizeMode::Actual),
content: Content::Document,
..Settings::default()
},
)
.unwrap()
} else {
print(&doc).unwrap()
};
assert_appearance(&render(bytes), false);
assert_eq!(saved(&doc), before, "excluded annotation case={case}");
}
}
+5
View File
@@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged)
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M10 #5029: Text appearances have implicit NoRotate during vector printing even when the flag is clear; estimates unchanged)
>
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
@@ -266,6 +267,7 @@ total matches the parity estimate.
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| 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.<br>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.<br>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).<br>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. 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. Vector printing preserves each annotation's OCG/OCMD membership and shared-appearance independence (#5005). |
| 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).<br>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. 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. Appearance placement retains small transform coefficients for large bounding boxes and matrices. Existing printable NoRotate appearances retain orientation and the upper-left rectangle anchor through source page rotation, including implicit NoRotate on Text annotations with the flag clear; later sheet transforms still apply (#5029). |
| 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 |
| 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.<br>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). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. 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 |
@@ -323,6 +325,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
## Revision history
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
Source resources and shared appearances remain unchanged.
Estimates unchanged.
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
+2 -2
View File
@@ -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", "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", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling"]
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. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. 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", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling", "crates/print/src/tests/optional_content.rs::vector_print_preserves_annotation_layer_membership"]
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. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. Vector output preserves per-annotation OCG/OCMD membership without changing shared appearances, including existing layer overrides; nested policy data remains reader-evaluated, and the separate raster annotation-visibility gap remains. Missing or unusable appearance streams remain outside this path."
[[feature]]
id = "print.print-with-comment-summary"