mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
M12: round XFA check buttons draw a round background and border
This commit is contained in:
@@ -4675,6 +4675,35 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A round XFA check button opens drawn round: the appearance its laid-out widget gets draws a
|
||||
/// circle, the on state with its mark in it, while a square one keeps its box.
|
||||
#[test]
|
||||
fn round_xfa_check_buttons_open_drawn_round() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
<field name="round" w="1in" h="0.3in"><ui><checkButton shape="round"/></ui><items><integer>1</integer><integer>0</integer></items></field>
|
||||
<field name="square" w="1in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items></field>
|
||||
</subform></template></xdp:xdp>"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone();
|
||||
let all = pdfcraft_forms::fields(&cos);
|
||||
// The decoded appearance of `name`'s widget in `state`.
|
||||
let drawn = |name: &str, state: &[u8]| {
|
||||
let w = all.iter().find(|f| f.name.ends_with(name)).unwrap_or_else(|| panic!("no field {name}")).widgets[0].obj;
|
||||
let wd = cos.get(w).as_dict().cloned().unwrap();
|
||||
let n = cos.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap());
|
||||
let r = n.as_dict().unwrap().reference(state).unwrap_or_else(|| panic!("{name} has no {} state", String::from_utf8_lossy(state)));
|
||||
let pdfcraft_cos::Object::Stream(st) = &*cos.get(r) else { panic!("a stream") };
|
||||
String::from_utf8_lossy(&st.decoded().unwrap()).into_owned()
|
||||
};
|
||||
for state in [&b"Off"[..], b"1"] {
|
||||
let round = drawn("round", state);
|
||||
assert!(round.contains(" c S") && !round.contains(" re S"), "the round one's border is a circle: {round}");
|
||||
assert!(drawn("square", state).contains(" re S"), "the square one keeps its box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() {
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3)));
|
||||
|
||||
@@ -277,10 +277,14 @@ fn stroke_op(fill: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background and border from `/MK` and `/BS`; returns (content, border width).
|
||||
/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA
|
||||
/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle.
|
||||
fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let mut c = String::new();
|
||||
if is_round(doc, wd) {
|
||||
return round_frame(doc, wd, mk.as_ref(), w, h);
|
||||
}
|
||||
if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") {
|
||||
c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h)));
|
||||
}
|
||||
@@ -346,6 +350,71 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
(c, 0.0)
|
||||
}
|
||||
|
||||
/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`, written by the XFA layout).
|
||||
fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||||
}
|
||||
|
||||
/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC`
|
||||
/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A
|
||||
/// dashed border stays dashed; any other style is drawn solid. Returns the border width.
|
||||
fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) {
|
||||
let mut c = String::new();
|
||||
let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0);
|
||||
if let Some(bg) = color_array(doc, mk, b"BG") {
|
||||
c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r)));
|
||||
}
|
||||
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
|
||||
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
|
||||
match color_array(doc, mk, b"BC") {
|
||||
Some(bc) if bw > 0.0 => {
|
||||
let dashed = bs.as_ref().and_then(|b| b.name(b"S")) == Some(b"D");
|
||||
// The stroke is centred on the path: inset by half its width, it stays inside.
|
||||
let path = circle_path(cx, cy, (r - bw / 2.0).max(0.0));
|
||||
c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" }));
|
||||
(c, bw)
|
||||
}
|
||||
_ => (c, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space
|
||||
/// so a paint operator can follow.
|
||||
fn circle_path(cx: f64, cy: f64, r: f64) -> String {
|
||||
// The control-point distance for a quarter circle.
|
||||
let k = 0.5523 * r;
|
||||
format!(
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ",
|
||||
n(cx + r),
|
||||
n(cy),
|
||||
n(cx + r),
|
||||
n(cy + k),
|
||||
n(cx + k),
|
||||
n(cy + r),
|
||||
n(cx),
|
||||
n(cy + r),
|
||||
n(cx - k),
|
||||
n(cy + r),
|
||||
n(cx - r),
|
||||
n(cy + k),
|
||||
n(cx - r),
|
||||
n(cy),
|
||||
n(cx - r),
|
||||
n(cy - k),
|
||||
n(cx - k),
|
||||
n(cy - r),
|
||||
n(cx),
|
||||
n(cy - r),
|
||||
n(cx + k),
|
||||
n(cy - r),
|
||||
n(cx + r),
|
||||
n(cy - k),
|
||||
n(cx + r),
|
||||
n(cy)
|
||||
)
|
||||
}
|
||||
|
||||
/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a
|
||||
/// border colour, doubled for beveled and inset borders, and 0 without a border.
|
||||
fn border_width(doc: &Document, wd: &Dict) -> f64 {
|
||||
|
||||
@@ -387,6 +387,49 @@ fn opt_export_values_select_button_states() {
|
||||
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
|
||||
}
|
||||
|
||||
/// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background
|
||||
/// and border as a circle inside the widget, inset by half the border so the stroke stays in;
|
||||
/// one without the key keeps its rectangle.
|
||||
#[test]
|
||||
fn a_round_xfa_check_button_draws_a_round_background_and_border() {
|
||||
let widget = |name: &str, extra: &str| {
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>"
|
||||
)
|
||||
};
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /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 [5 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R 6 0 R 7 0 R] >>".into(),
|
||||
widget("round", " /PCXfaRound true"),
|
||||
widget("square", ""),
|
||||
widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
for name in ["round", "square", "dashed"] {
|
||||
redraw_field(&mut doc, name).unwrap();
|
||||
}
|
||||
// The Off state: only the frame.
|
||||
let off = |name: &str| {
|
||||
let all = fields(&doc);
|
||||
let w = field(&all, name).widgets[0].obj;
|
||||
let wd = doc.get(w).as_dict().cloned().unwrap();
|
||||
let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap());
|
||||
let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance");
|
||||
let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") };
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
};
|
||||
let round = off("round");
|
||||
assert!(!round.contains(" re "), "no rectangle: {round}");
|
||||
assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}");
|
||||
assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}");
|
||||
let square = off("square");
|
||||
assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}");
|
||||
let dashed = off("dashed");
|
||||
assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}");
|
||||
}
|
||||
|
||||
/// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an
|
||||
/// empty value clears it.
|
||||
#[test]
|
||||
|
||||
@@ -160,6 +160,11 @@ pub struct Written {
|
||||
/// layer reads this key).
|
||||
pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage";
|
||||
|
||||
/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its
|
||||
/// background and border are drawn as a circle, not the widget's rectangle (the forms layer
|
||||
/// reads this key).
|
||||
pub const ROUND_KEY: &[u8] = b"PCXfaRound";
|
||||
|
||||
/// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A
|
||||
/// re-layout (after every script event) finds the XObjects on the pages it replaces by this,
|
||||
/// and reuses them instead of decoding and embedding the same picture again. Nothing is kept
|
||||
@@ -244,6 +249,8 @@ impl Emitter<'_> {
|
||||
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
|
||||
}
|
||||
|
||||
/// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with
|
||||
/// its border, background and kind, and return the annotation's object.
|
||||
fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result<Option<ObjRef>, XfaError> {
|
||||
let r = w.rect;
|
||||
let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y);
|
||||
@@ -271,6 +278,9 @@ impl Emitter<'_> {
|
||||
bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) }));
|
||||
bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" }));
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) {
|
||||
d.set(ROUND_KEY.to_vec(), Object::Bool(true));
|
||||
}
|
||||
let mut ff: i64 = 0;
|
||||
if w.read_only {
|
||||
ff |= 1;
|
||||
|
||||
@@ -350,6 +350,52 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() {
|
||||
assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts")));
|
||||
}
|
||||
|
||||
/// A check box or radio button laid out from a round check button (`shape="round"`) carries
|
||||
/// `ROUND_KEY`, so the forms layer draws its background and border round; a square one (the
|
||||
/// default) doesn't.
|
||||
#[test]
|
||||
fn round_check_buttons_are_marked_for_a_round_appearance() {
|
||||
let check = |name: &str, shape: &str, items: &str| {
|
||||
format!(r#"<field name="{name}" w="1in" h="0.3in"><ui><checkButton{shape}/></ui><items>{items}</items></field>"#)
|
||||
};
|
||||
let on_off = "<integer>1</integer><integer>0</integer>";
|
||||
let xdp = format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
|
||||
{}{}<exclGroup name="pick" layout="tb">{}{}</exclGroup>
|
||||
</subform></template></xdp:xdp>"##,
|
||||
check("roundBox", r#" shape="round""#, on_off),
|
||||
check("squareBox", "", on_off),
|
||||
check("roundRadio", r#" shape="round""#, "<text>a</text>"),
|
||||
check("squareRadio", r#" shape="square""#, "<text>b</text>"),
|
||||
);
|
||||
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
// Every widget among the form's fields and their kids (a radio group's buttons), by the
|
||||
// last step of its SOM path.
|
||||
let catalog = doc.get(doc.root().unwrap());
|
||||
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let mut todo: Vec<_> =
|
||||
doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect();
|
||||
let mut round = std::collections::BTreeMap::new();
|
||||
while let Some(r) = todo.pop() {
|
||||
let node = doc.get(r);
|
||||
let d = node.as_dict().unwrap();
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)) {
|
||||
todo.extend(kids.as_array().unwrap().iter().filter_map(Object::as_ref));
|
||||
continue;
|
||||
}
|
||||
let som = d.get(SOM_KEY).and_then(Object::as_string).map(|s| s.to_text()).unwrap_or_default();
|
||||
let name = som.rsplit('.').next().unwrap_or_default().split('[').next().unwrap_or_default().to_string();
|
||||
round.insert(name, matches!(d.get(crate::pdf::ROUND_KEY), Some(Object::Bool(true))));
|
||||
}
|
||||
let expected: std::collections::BTreeMap<String, bool> = [("roundBox", true), ("squareBox", false), ("roundRadio", true), ("squareRadio", false)]
|
||||
.into_iter()
|
||||
.map(|(n, r)| (n.to_string(), r))
|
||||
.collect();
|
||||
assert_eq!(round, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_xfa_are_not_touched() {
|
||||
let mut doc = Document::new_empty();
|
||||
|
||||
@@ -4452,8 +4452,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.fields"]
|
||||
tools = ["form_fill"]
|
||||
evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows"]
|
||||
notes = "NoToggleToOff honoured; widgets without appearances get our own. Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too"
|
||||
evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round"]
|
||||
notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too"
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-choice"
|
||||
|
||||
Reference in New Issue
Block a user