M6: leave the combo box drop-down arrow to #3567

The on-screen drop-down button for combo boxes drawn display-only, the
narrowed text area beside it, and the Print as Image copy without it
(with_printed_field_appearances, printed_display_bytes) are removed.
Open PR #3567 draws the arrow for every combo box in the page view.
Check boxes, radio buttons, push buttons and comb lines are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410
2026-10-24 12:01:55 +08:00
co-authored by Claude Opus 5.5
parent 5233e12281
commit dd1a894b2d
8 changed files with 17 additions and 117 deletions
-10
View File
@@ -934,16 +934,6 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
}
}
/// The display copy as it prints ([`print_raster`]): [`display_bytes`] without what is only for the
/// screen, a drawn combo box's drop-down button. `None` when it would be the display copy itself
/// (no field is drawn for display, or the document is not open for editing) or cannot be written.
pub(crate) fn printed_display_bytes(doc: &Document) -> Option<Arc<Vec<u8>>> {
let e = doc.editor.as_ref()?;
let comments = pdfcraft_annot::with_missing_appearances(&e.cos);
let fields = pdfcraft_forms::with_printed_field_appearances(comments.as_ref().unwrap_or(&e.cos))?;
write_incremental(&fields, &SaveOptions::default()).ok().map(Arc::new)
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
+1 -3
View File
@@ -23,9 +23,7 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
// The copy below holds only what prints, so the viewer's own comment hiding plays no part.
config.hide_comments = false;
let printed = guard(|| -> Result<Vec<u8>, String> {
// The display copy, less what it shows only on screen.
let shown = crate::printed_display_bytes(doc).unwrap_or_else(|| src.bytes.clone());
let mut cos = pdfcraft_cos::Document::open_with_password(shown, config.password.as_deref()).map_err(|e| e.to_string())?;
let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
pdfcraft_print::keep_printed(&mut cos, content);
write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())
})??;
+6 -11
View File
@@ -7440,22 +7440,20 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
}
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
/// field and a combo box the file leaves to the viewer. The page as displayed shows the check
/// box's caption symbol in its text colour, the button's grey face, the comb's cells in the border
/// colour and the combo box's drop-down button; printing leaves that button out, and saving
/// writes the file as it was.
/// field the file leaves to the viewer. The page as displayed and printed as an image shows the
/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in
/// the border colour, and saving writes the file as it was.
#[test]
fn buttons_and_combs_left_to_the_viewer_are_displayed() {
let pdf = b"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R 9 0 R] >> endobj\n\
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\
8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\
9 0 obj << /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (m) /Opt [(Apple) (Banana)] /V (Apple) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [20 20 220 50] /F 4 /P 3 0 R >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
.to_vec();
let render = |bytes: Arc<Vec<u8>>| {
@@ -7484,15 +7482,12 @@ fn buttons_and_combs_left_to_the_viewer_are_displayed() {
assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}");
}
assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell");
// The combo box's drop-down button: a 28 pt square inside the right edge.
let button = (193, 22, 218, 48);
assert!(count(&shown, button, &grey) > 300, "the drop-down button on screen");
// Print as Image: the page as a picture, at 72 dpi so it is the size of the page here.
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
let printed = render(Arc::new(pictures));
assert_eq!(count(&printed, button, &grey), 0, "not on paper");
assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints");
assert!(count(&printed, (104, 154, 216, 186), &grey) > 1000, "and the push button");
// The file is saved as it was: no widget has an appearance.
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
for f in pdfcraft_forms::fields(&saved) {
+1 -3
View File
@@ -58,9 +58,7 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
one field redraws that field, not the form. Check boxes and radio buttons with no appearance for
their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. On
screen a drawn combo box also shows a drop-down button; `with_printed_field_appearances` leaves it
out for printing.
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption.
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
+3 -43
View File
@@ -390,25 +390,6 @@ fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> Str
c
}
/// A combo box's drop-down button for the screen: a `size`-wide light grey face at the right
/// edge inside the `bw` border of a `w` × `h` widget, parted from the text by a line in the
/// border's colour, with a downward triangle.
fn drop_down_button(doc: &Document, wd: &Dict, w: f64, h: f64, bw: f64, size: f64) -> String {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let (x0, y0, x1, y1) = (w - bw - size, bw, w - bw, h - bw);
let mut c = format!("0.85 g\n{} {} {} {} re f\n", n(x0), n(y0), n(size), n(y1 - y0));
let lw = line_width(doc, wd);
if let Some(bc) = color_array(doc, mk.as_ref(), b"BC")
&& lw > 0.0
&& lw.is_finite()
{
c.push_str(&format!("{}\n{} w\n{} {} m {} {} l S\n", stroke_op(&bc), n(lw), n(x0), n(y0), n(x0), n(y1)));
}
let (cx, cy, d) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, size * 0.22);
c.push_str(&format!("0 g\n{} {} m {} {} l {} {} l h f\n", n(cx - d), n(cy + d / 2.0), n(cx + d), n(cy + d / 2.0), n(cx), n(cy - d / 2.0)));
c
}
/// Where a list box draws its options: one row per option from the top, starting with option
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
/// it, so a click picks the option drawn under the pointer (#1524).
@@ -499,19 +480,6 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
/// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't
/// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF.
pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
field_look(doc, f, w, values, format, false)
}
/// [`field_appearance_drawn`] as the page view shows a field the file leaves to the viewer: a
/// combo box also gets its drop-down button at the right edge, which the text keeps clear of.
/// The button is something to click on screen, not part of the field's printed look, so it is
/// drawn only here and never into an appearance PdfCraft saves or prints.
pub(crate) fn field_appearance_on_screen(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
field_look(doc, f, w, values, format, true)
}
/// The appearance of a text or choice field, with a combo box's drop-down button when `screen`.
fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool, screen: bool) -> (Stream, Option<char>) {
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
let values = if format
@@ -552,15 +520,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format:
let width_of = |text: &str, size: f64| enc.width(text, size);
let (mut c, bw) = frame(doc, &wd, width, height);
let pad = 2.0 + bw;
// A combo box's drop-down button on screen: a square as tall as the room inside the border,
// never wider than that room.
let button = if screen && f.kind == FieldKind::Combo { (height - 2.0 * bw).min(width - 2.0 * bw).max(0.0) } else { 0.0 };
if button > 0.0 {
c.push_str(&drop_down_button(doc, &wd, width, height, bw, button));
}
// The text and its clip keep to the room left of the button.
let text_w = width - button;
let inner_w = (text_w - 2.0 * pad).max(1.0);
let inner_w = (width - 2.0 * pad).max(1.0);
let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding);
let mut body: Vec<u8> = Vec::new();
// The first character drawn that the font can't hold (drawn as `?`), for the caller to report.
@@ -579,7 +539,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format:
let tw = width_of(text, size);
match q {
1 => pad + (inner_w - tw) / 2.0,
2 => text_w - pad - tw,
2 => width - pad - tw,
_ => pad,
}
};
@@ -649,7 +609,7 @@ fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format:
"/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n",
n(bw),
n(bw),
n(text_w - 2.0 * bw),
n(width - 2.0 * bw),
n(height - 2.0 * bw),
font_name,
n(size),
+2 -16
View File
@@ -1304,9 +1304,8 @@ pub fn need_appearances(doc: &Document) -> bool {
/// 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 push buttons with none from their `/MK` and `/BS` (Table 192); the page view
/// also shows a drawn combo box's drop-down button. `doc` is not changed: saving writes the file
/// as it was.
/// and caption, and push buttons with none 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())
}
@@ -1314,18 +1313,6 @@ pub fn with_field_appearances(doc: &Document) -> Option<Document> {
/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are
/// the same objects as then (see [`FieldAppearanceCache`]).
pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option<Document> {
fields_shown(doc, cache, true)
}
/// [`with_field_appearances`] for printing: the same, without the drop-down buttons of combo
/// boxes, which are for clicking on screen and not part of a field's printed look.
pub fn with_printed_field_appearances(doc: &Document) -> Option<Document> {
fields_shown(doc, &mut FieldAppearanceCache::default(), false)
}
/// [`with_field_appearances_cached`]; `screen` adds the on-screen parts (a combo box's button).
/// One cache serves one of the two: the engine keeps one for the screen and prints uncached.
fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool) -> Option<Document> {
let all = need_appearances(doc);
let mut copy: Option<Document> = None;
for f in fields(doc) {
@@ -1365,7 +1352,6 @@ fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool)
let stream = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")),
FieldKind::PushButton => appearance::push_button_shown(doc, &f, w),
_ if screen => appearance::field_appearance_on_screen(doc, &f, w, &f.value, true).0,
_ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0,
};
cache.insert(w.obj, inputs, &stream);
+2 -29
View File
@@ -3098,8 +3098,8 @@ fn a_skipped_field_leaves_its_widget_to_the_next() {
assert_eq!(widgets, [("b", 1)]);
}
/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field
/// and a combo box, for the display copy.
/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field,
/// a combo box and a list box, for the display copy.
fn left_buttons() -> Document {
let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>");
document(&[
@@ -3226,30 +3226,3 @@ fn comb_fields_show_their_cells() {
let plain = saved_look(&doc, "d");
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
}
/// A combo box drawn for display shows its drop-down button at the right edge and keeps its text
/// clear of it; a list box, a text field, the combo box's saved look and its printed one have none.
#[test]
fn combo_boxes_shown_on_screen_have_a_drop_down_button() {
let doc = left_buttons();
let screen = with_field_appearances(&doc).expect("fields to draw");
let printed = with_printed_field_appearances(&doc).expect("fields to draw");
let m = ap(&screen, &field(&fields(&screen), "m").widgets[0]);
// A 22 pt square inside the 1 pt border, a line in the border colour, a triangle.
assert!(m.contains("0.85 g\n97 1 22 22 re f\n0 0 0 RG\n1 w\n97 1 m 97 23 l S\n0 g\n"), "{m}");
// The text's clip stops at the button.
assert!(m.contains("1 1 96 22 re W n"), "{m}");
let printed_m = ap(&printed, &field(&fields(&printed), "m").widgets[0]);
assert!(!printed_m.contains("0.85 g") && printed_m.contains("1 1 118 22 re W n"), "{printed_m}");
let saved = saved_look(&doc, "m");
assert!(!saved.contains("0.85 g"), "{saved}");
// Right-aligned text moves left by the button's width.
let x = |s: &str| -> f64 {
s.split(" Tm ").next().and_then(|s| s.rsplit("1 0 0 1 ").next()).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap()
};
assert!((x(&saved) - x(&m) - 22.0).abs() < 0.01, "{saved} / {m}");
for other in ["n", "d"] {
let s = ap(&screen, &field(&fields(&screen), other).widgets[0]);
assert!(!s.contains("0.85 g"), "{other}: {s}");
}
}
+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", "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::combo_boxes_shown_on_screen_have_a_drop_down_button"]
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); 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), push buttons with none from /MK and /BS with their caption, and a drawn combo box shows a drop-down button on screen only (not in Print as Image); 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/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"]
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); 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 push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet"
[[feature]]
id = "view.render-error-isolation"