Merge PR #4371: M2: Invisible annotations of unknown types and popups don't paint

Conflicts: vendor/README.md: hayro-interpret row keeps #4345's patch (55) and #4371's patch (56), with both test lists.
This commit is contained in:
aiongg
2026-10-24 06:24:03 +00:00
6 changed files with 149 additions and 4 deletions
+39 -1
View File
@@ -719,19 +719,57 @@ pub fn keep_printed(doc: &mut Document, content: Content) {
}
}
/// The standard annotation types of ISO 32000-2 Table 171.
const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [
b"Text",
b"Link",
b"FreeText",
b"Line",
b"Square",
b"Circle",
b"Polygon",
b"PolyLine",
b"Highlight",
b"Underline",
b"Squiggly",
b"StrikeOut",
b"Caret",
b"Stamp",
b"Ink",
b"Popup",
b"FileAttachment",
b"Sound",
b"Movie",
b"Screen",
b"Widget",
b"PrinterMark",
b"TrapNet",
b"Watermark",
b"3D",
b"Redact",
b"Projection",
b"RichMedia",
];
/// Does annotation `d` print under `content`?
fn prints(doc: &Document, d: &Dict, content: Content) -> bool {
const INVISIBLE: i64 = 1;
const HIDDEN: i64 = 2;
const PRINT: i64 = 4;
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & PRINT == 0 || flags & HIDDEN != 0 {
return false;
}
let subtype = d.name(b"Subtype").unwrap_or(b"");
// `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first.
let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s));
let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b"");
match subtype {
b"Widget" => true,
b"Link" | b"Popup" => false,
b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly,
// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table
// 171) is not printed even with its Print flag set.
_ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false,
_ => content == Content::DocumentAndMarkups,
}
}
+28
View File
@@ -961,3 +961,31 @@ fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() {
// A range with no page of the kind prints nothing.
assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages));
}
/// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard does not print,
/// even with its Print flag; Invisible means nothing for a standard type.
#[test]
fn invisible_annotations_of_unknown_types_do_not_print() {
let doc = fixture(1);
let annot = |subtype: Option<&str>, flags: i64| {
let mut d = Dict::new();
if let Some(s) = subtype {
d.set(b"Subtype".to_vec(), Object::name(s));
}
d.set(b"F".to_vec(), Object::Int(flags));
d
};
let all = Content::DocumentAndMarkups;
assert!(prints(&doc, &annot(Some("MyCustomType"), 4), all), "an unknown type prints without Invisible");
assert!(!prints(&doc, &annot(Some("MyCustomType"), 5), all), "Invisible + Print on an unknown type");
assert!(!prints(&doc, &annot(None, 5), all), "no Subtype is no standard type");
assert!(prints(&doc, &annot(Some("Square"), 5), all), "Invisible doesn't apply to a standard type");
assert!(prints(&doc, &annot(Some("Widget"), 5), Content::FormFieldsOnly), "nor to a field");
// An indirect `/Subtype` (§7.3.10) is resolved: a standard type stays standard.
let mut doc = doc;
let square = doc.add(Object::name("Square"));
let mut indirect = annot(None, 5);
indirect.set(b"Subtype".to_vec(), Object::Ref(square));
assert!(prints(&doc, &indirect, all), "an indirect standard Subtype");
}
+34
View File
@@ -3349,6 +3349,40 @@ trailer << /Root 1 0 R >>
assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay");
}
/// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and
/// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted.
#[test]
fn invisible_unknown_annotations_and_popups_paint_nothing() {
let annots = [
"/Subtype /MyCustomType /F 0",
"/Subtype /MyCustomType /F 1",
"/Subtype /MyCustomType /F 5",
"/F 1",
"/Subtype /Square /F 1",
"/Subtype /Popup /F 4 /Open true",
];
let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
pdf.push_str("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
let refs: Vec<String> = (0..annots.len()).map(|i| format!("{} 0 R", 10 + i)).collect();
pdf.push_str(&format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 50] /Annots [{}] >> endobj\n", refs.join(" ")));
pdf.push_str("4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream\n0 1 0 rg 0 0 30 30 re f\nendstream endobj\n");
for (i, a) in annots.iter().enumerate() {
let x = 10 + 50 * i;
pdf.push_str(&format!("{} 0 obj << /Type /Annot {a} /Rect [{x} 10 {} 40] /AP << /N 4 0 R >> >> endobj\n", 10 + i, x + 30));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let px = |x: u32| p.rgba[((25 * p.width + x) * 4) as usize..][..4].to_vec();
let (green, white) = (vec![0, 255, 0, 255], vec![255, 255, 255, 255]);
assert_eq!(px(25), green, "an unknown type without Invisible is drawn");
assert_eq!(px(75), white, "Invisible hides an unknown type");
assert_eq!(px(125), white, "even with the Print flag");
assert_eq!(px(175), white, "no Subtype is no standard type");
assert_eq!(px(225), green, "Invisible means nothing for a standard type");
assert_eq!(px(275), white, "a popup's appearance is not painted on the page");
}
#[test]
fn inline_pool_renders_in_priority_order() {
let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
+2 -2
View File
@@ -806,8 +806,8 @@ milestone = "M2"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet"
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
[[feature]]
id = "view.render-error-isolation"
+1 -1
View File
File diff suppressed because one or more lines are too long
+45
View File
@@ -190,6 +190,15 @@ pub fn interpret_page<'a>(
if flags & (2 | 32) != 0 {
continue;
}
// PdfCraft patch (56): Invisible (bit 1) hides an annotation of a type that isn't one
// of the standard ones (ISO 32000-2 Table 167), and a popup has no appearance of its
// own (§12.5.6.14): the viewer shows it as a window for its parent, not on the page.
let subtype = annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE);
if subtype.as_deref() == Some(b"Popup")
|| (flags & 1 != 0 && !subtype.as_deref().is_some_and(is_standard_annotation_type))
{
continue;
}
// PdfCraft patch: "Hide all comments" keeps widgets and links.
if context.settings.hide_comments
&& !matches!(annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link"))
@@ -286,6 +295,42 @@ pub fn interpret_page<'a>(
}
}
/// PdfCraft patch (56): whether `subtype` is one of the standard annotation types of
/// ISO 32000-2 Table 171.
fn is_standard_annotation_type(subtype: &[u8]) -> bool {
matches!(
subtype,
b"Text"
| b"Link"
| b"FreeText"
| b"Line"
| b"Square"
| b"Circle"
| b"Polygon"
| b"PolyLine"
| b"Highlight"
| b"Underline"
| b"Squiggly"
| b"StrikeOut"
| b"Caret"
| b"Stamp"
| b"Ink"
| b"Popup"
| b"FileAttachment"
| b"Sound"
| b"Movie"
| b"Screen"
| b"Widget"
| b"PrinterMark"
| b"TrapNet"
| b"Watermark"
| b"3D"
| b"Redact"
| b"Projection"
| b"RichMedia"
)
}
/// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2
/// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints
/// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content