Merge #6610: M6: a field whose normal appearance gives no stream is drawn from its value, /MK and /BS (view.render-annotation-appearances)

# Conflicts:
#	parity/acrobat-features.toml
This commit is contained in:
bflatastic committed 2026-11-09 08:19:52 +07:00
commit eb47b57b4f
3 files changed
+76 -13

No files matched your search

+25 -11
View File
@@ -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<Document> {
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,
};
@@ -104,6 +105,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
+49
View File
@@ -3996,6 +3996,55 @@ fn without_need_appearances_only_missing_appearances_are_drawn() {
assert!(with_field_appearances(&copy).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(&copy);
let x = ap(&copy, &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(&copy, &field(&all, name).widgets[0]).contains("(v) Tj"), "{name}");
}
assert!(ap(&copy, &field(&all, "p").widgets[0]).contains("(Go) Tj"));
assert!(state_ap(&copy, &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(&copy), t(&doc), "a state with a stream is the file's own");
assert!(with_field_appearances(&copy).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]
+2 -2
View File
@@ -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", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::text_markup_reach_reads_quads_like_the_drawing", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/annot/src/tests.rs::indirect_border_style_and_colour_are_drawn", "crates/annot/src/tests.rs::callout_without_rd_is_drawn"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; 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::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", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::text_markup_reach_reads_quads_like_the_drawing", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/annot/src/tests.rs::indirect_border_style_and_colour_are_drawn", "crates/annot/src/tests.rs::callout_without_rd_is_drawn", "crates/forms/src/tests.rs::normal_appearances_that_give_no_stream_are_drawn"]
notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; 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"