From ccc2cd0dee0fab98d0fd48a1ff657add82216df3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 17:29:41 +0800 Subject: [PATCH] M6: a field whose normal appearance gives no stream is drawn from its value, /MK and /BS (view.render-annotation-appearances) A widget's /AP /N that is neither a stream nor a state subdictionary with a stream for /AS draws nothing (ISO 32000-2 12.5.5, Table 170), yet the display copy treated any /N as the file's own appearance. Text, choice and push-button widgets whose /N is null, a form XObject dictionary without its stream data, or a subdictionary without /AS now get the drawn appearance a widget without /N gets, and check boxes and radio buttons whose state entry is not a stream are drawn from /MK. The test is the same one the renderer uses to pick the stream. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/forms/src/appearances.rs | 36 ++++++++++++++++-------- crates/forms/src/tests.rs | 49 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 02c1eec5bc..a01ebe56a1 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -10,12 +10,12 @@ pub fn need_appearances(doc: &Document) -> bool { /// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for /// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from /// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the -/// writer left the appearances to the viewer), and otherwise each widget without a normal -/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons -/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own -/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is -/// not changed: saving writes the file as it was. +/// writer left the appearances to the viewer), and otherwise each widget whose normal appearance +/// (`/AP /N`) gives no stream to draw, as typing the value would draw it (#3470). Check boxes and +/// radio buttons with no normal appearance stream for their state (`/AS`; an Off state may have +/// none) are drawn from their `/MK` background, border and caption, and with `/NeedAppearances` +/// every one that has an `/MK` is, over its own appearance; push buttons with none are drawn from +/// their `/MK` and `/BS` (Table 192). `doc` is not changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -48,13 +48,14 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); let missing = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, - FieldKind::PushButton => normal.is_none(), + FieldKind::Text | FieldKind::Combo | FieldKind::List => all || !shows_stream(doc, normal.as_deref(), w.state.as_deref()), + FieldKind::PushButton => !shows_stream(doc, normal.as_deref(), w.state.as_deref()), FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { _ if all && described() => true, - (None, _) => true, - (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), - _ => false, + (Some(Object::Stream(_)), _) => false, + // An Off state may go without an appearance; an on state needs a stream. + (Some(Object::Dict(_)), Some("Off")) => false, + (n, s) => !shows_stream(doc, n, s), }, FieldKind::Signature => false, }; @@ -101,6 +102,19 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance copy } +/// Whether a widget's normal appearance `normal` (`/AP /N`, resolved) gives a stream to draw: the +/// entry itself when it is a stream, or the stream it names for the appearance state `state` +/// (`/AS`) when it is a subdictionary of states (ISO 32000-2 12.5.5, Table 170), as the renderer +/// picks it. Anything else (no entry, null, a subdictionary without `/AS` or without a stream for +/// that state, a dictionary that only looks like a form XObject) draws nothing. +fn shows_stream(doc: &Document, normal: Option<&Object>, state: Option<&str>) -> bool { + match (normal, state) { + (Some(Object::Stream(_)), _) => true, + (Some(Object::Dict(states)), Some(s)) => states.get(&name_bytes(s)).is_some_and(|a| matches!(doc.resolve(a).as_ref(), Object::Stream(_))), + _ => false, + } +} + /// The objects a widget's drawn appearance is built from, as the document holds them: the widget /// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the /// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3948e06e83..f5a2203d97 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3395,6 +3395,55 @@ fn without_need_appearances_only_missing_appearances_are_drawn() { assert!(with_field_appearances(©).is_none(), "every widget has one now"); } +/// ISO 32000-2 12.5.5 and Table 170: a normal appearance is a stream or a subdictionary of +/// streams chosen by `/AS`. A form XObject's dictionary without its stream (`x`: object 6 says +/// it is one, but 7.5.7 keeps streams out of object streams, so a writer that packed it there +/// lost the data), a subdictionary with no `/AS` (`s`), or a state whose entry is no stream +/// (`u`, the push button `p`, the check box `k`) draws nothing, so these are drawn like widgets +/// without one: `x` with its `/MK /BC` border at its `/BS /W` of 3, given by reference. A +/// subdictionary whose `/AS` names a stream (`t`) is the file's own. +fn appearances_without_a_stream() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let tx = "/FT /Tx /V (v) /DA (/Helv 12 Tf 0 g)"; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + "<< /Fields [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 106 14] >>".into(), // 6 + "<< /W 3 >>".into(), + "<< /Length 18 >>\nstream\nBT (old T) Tj ET\nendstream".into(), // 8 + widget("x", &format!("{tx} /Rect [54 705 160 719] /MK << /BC [0 0 0] /BG [0.88 0.88 0.88] >> /BS 7 0 R /AP << /N 6 0 R >>")), + widget("s", &format!("{tx} /Rect [54 680 160 694] /AP << /N << /On 8 0 R >> >>")), // 10 + widget("t", &format!("{tx} /Rect [54 655 160 669] /AS /On /AP << /N << /On 8 0 R >> >>")), + widget("u", &format!("{tx} /Rect [54 630 160 644] /AS /On /AP << /N << /On 6 0 R >> >>")), + widget("p", "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Go) /BC [0 0 0] >> /Rect [54 600 160 620] /AP << /N 6 0 R >>"), + widget( + "k", + "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [200 700 224 724] /AP << /N << /Yes 6 0 R /Off 8 0 R >> >>", + ), + ]) +} + +#[test] +fn normal_appearances_that_give_no_stream_are_drawn() { + let doc = appearances_without_a_stream(); + let copy = with_field_appearances(&doc).expect("x, s, u, p and k draw nothing of their own"); + let all = fields(©); + let x = ap(©, &field(&all, "x").widgets[0]); + assert!(x.contains("0.88 0.88 0.88 rg\n0 0 106 14 re f") && x.contains("0 0 0 RG\n3 w\n1.5 1.5 103 11 re S"), "{x}"); + assert!(x.contains("(v) Tj"), "{x}"); + for name in ["s", "u"] { + assert!(ap(©, &field(&all, name).widgets[0]).contains("(v) Tj"), "{name}"); + } + assert!(ap(©, &field(&all, "p").widgets[0]).contains("(Go) Tj")); + assert!(state_ap(©, &field(&all, "k").widgets[0], "Yes").contains("(4) Tj")); + let t = |d: &Document| d.get(field(&fields(d), "t").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(t(©), t(&doc), "a state with a stream is the file's own"); + assert!(with_field_appearances(©).is_none(), "every widget has a stream now"); +} + /// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in /// one field of a form that leaves every appearance to the viewer otherwise redrew all of them. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2e3a3ff72c..479f1b42e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.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/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -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; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; 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); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; 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)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.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::normal_appearances_that_give_no_stream_are_drawn", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +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; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #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); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; 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"