Forms: check box and radio button styles (#94)

Field Properties > Options > Check Box Style (Button Style for radio
buttons): Check, Circle, Cross, Diamond, Square or Star, as Acrobat
offers. The choice is stored in each widget's /MK /CA with Acrobat's
ZapfDingbats codes (4 l 8 u n H), so other viewers draw the same mark,
and PrintCraft draws it as paths, needing no symbol font. Styles set
by other apps are read back too; the defaults stay a check for check
boxes and a circle for radio buttons.

- forms: CheckStyle, check_style(), FieldProps::check_style; set_props
  writes /MK /CA (resolving an indirect /MK so its colours survive) and
  redraws; text and choice fields refuse a style.
- engine: Document::field_check_style.
- automation: form_set_props takes check_style, form_fields reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-07 09:42:39 +07:00
co-authored by Claude Opus 5.5
parent 55c581760a
commit cdb3f3d106
11 changed files with 305 additions and 49 deletions
+96 -45
View File
@@ -320,6 +320,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let (frame_c, _) = frame(doc, &wd, width, height);
let style = crate::author::CheckStyle::of_widget(doc, &wd, kind);
let mut form = |content: String| -> Object {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
@@ -328,51 +329,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes()))))
};
let s = width.min(height);
let (cx, cy) = (width / 2.0, height / 2.0);
let mark = if kind == FieldKind::Radio {
let r = s * 0.25;
let k = 0.5523 * r;
format!(
"0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n",
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)
)
} else {
format!(
"0 G\n{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
n((s * 0.1).max(1.0)),
n(cx - s * 0.28),
n(cy),
n(cx - s * 0.08),
n(cy - s * 0.22),
n(cx + s * 0.3),
n(cy + s * 0.25)
)
};
let mark = check_mark(style, width / 2.0, height / 2.0, s);
let mut nd = Dict::new();
nd.set(on_name.as_bytes().to_vec(), form(format!("{frame_c}{mark}")));
nd.set(b"Off".to_vec(), form(frame_c));
@@ -381,6 +338,100 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name
ap
}
/// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is
/// `s`, drawn in black.
fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String {
use crate::author::CheckStyle;
// A filled polygon through `pts`.
let polygon = |pts: &[(f64, f64)]| {
let mut c = String::from("0 g\n");
for (i, (x, y)) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" }));
}
c.push_str("h f\n");
c
};
let line = n((s * 0.1).max(1.0));
match style {
CheckStyle::Check => format!(
"0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
n(cx - s * 0.28),
n(cy),
n(cx - s * 0.08),
n(cy - s * 0.22),
n(cx + s * 0.3),
n(cy + s * 0.25)
),
CheckStyle::Cross => {
let d = s * 0.25;
format!(
"0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n",
n(cx - d),
n(cy - d),
n(cx + d),
n(cy + d),
n(cx - d),
n(cy + d),
n(cx + d),
n(cy - d)
)
}
CheckStyle::Square => {
let d = s * 0.22;
format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d))
}
CheckStyle::Diamond => {
let d = s * 0.3;
polygon(&[(cx, cy + d), (cx + d, cy), (cx, cy - d), (cx - d, cy)])
}
CheckStyle::Star => {
// Five points: outer and inner radii alternate, the first point straight up.
let (outer, inner) = (s * 0.32, s * 0.32 * 0.382);
let pts: Vec<(f64, f64)> = (0..10)
.map(|i| {
let a = std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
let r = if i % 2 == 0 { outer } else { inner };
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
polygon(&pts)
}
CheckStyle::Circle => {
let r = s * 0.25;
let k = 0.5523 * r;
format!(
"0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n",
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)
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+70
View File
@@ -89,6 +89,8 @@ pub struct FieldProps {
pub locked: Option<bool>,
/// Actions tab: every trigger's action (replacing the field's).
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
/// Options tab: the mark of a check box or radio button (every widget).
pub check_style: Option<CheckStyle>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@@ -215,6 +217,63 @@ impl FieldFont {
}
}
/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style).
/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PrintCraft draws the
/// mark as paths, so no symbol font is needed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CheckStyle {
Check,
Circle,
Cross,
Diamond,
Square,
Star,
}
impl CheckStyle {
pub const ALL: [CheckStyle; 6] =
[CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star];
fn code(self) -> &'static str {
match self {
CheckStyle::Check => "4",
CheckStyle::Circle => "l",
CheckStyle::Cross => "8",
CheckStyle::Diamond => "u",
CheckStyle::Square => "n",
CheckStyle::Star => "H",
}
}
pub fn label(self) -> &'static str {
match self {
CheckStyle::Check => "Check",
CheckStyle::Circle => "Circle",
CheckStyle::Cross => "Cross",
CheckStyle::Diamond => "Diamond",
CheckStyle::Square => "Square",
CheckStyle::Star => "Star",
}
}
/// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios.
pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self {
let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default();
let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text()));
CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio {
CheckStyle::Circle
} else {
CheckStyle::Check
})
}
}
/// A check box's or radio button's style (from its first widget).
pub fn check_style(doc: &Document, f: &Field) -> CheckStyle {
let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default();
CheckStyle::of_widget(doc, &wd, f.kind)
}
fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> {
let v: Vec<f64> = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect();
match v.len() {
@@ -702,6 +761,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
return invalid("a list needs at least one option");
}
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
return invalid("only check boxes and radio buttons have a check style");
}
doc.update_dict(f.obj, |d| {
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
if let Some(t) = &props.tooltip {
@@ -785,6 +847,14 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
})?;
}
}
if let Some(style) = props.check_style {
for w in &f.widgets {
// Resolved first: an indirect /MK keeps its colours.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(style.code()));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
}
if let Some((wi, r)) = props.rect {
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
+2 -1
View File
@@ -23,7 +23,8 @@ pub mod detect;
mod scripting;
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
pub use author::{
BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props,
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, NewField, add_field, check_style, delete_field, duplicate_field, look, redraw_field,
set_button_icon, set_props,
};
pub use scripting::{
FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
+52
View File
@@ -523,6 +523,58 @@ fn options_tab_flags_alignment_and_defaults() {
assert!(set_props(&mut doc, &text, &FieldProps { quadding: Some(5), ..FieldProps::default() }).is_err());
}
#[test]
fn check_box_styles_change_the_mark() {
// #94: Field Properties ▸ Options ▸ Check Box Style (stored as /MK /CA).
let mut doc = one_page();
let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap();
let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap();
let text = add_field(&mut doc, 0, [50.0, 500.0, 250.0, 520.0], &NewField::Text { multiline: false }, None).unwrap();
let style = |doc: &Document, name: &str| check_style(doc, field(&fields(doc), name));
assert_eq!((style(&doc, &check), style(&doc, &radio)), (CheckStyle::Check, CheckStyle::Circle), "the defaults");
// The "on" appearance: the widget's /AP /N entry for its on state.
let on_ap = |doc: &Document, name: &str| {
let all = fields(doc);
let w = &field(&all, name).widgets[0];
let on = w.on_state.clone().unwrap();
let n = doc
.get(w.obj)
.as_dict()
.unwrap()
.get(b"AP")
.unwrap()
.as_dict()
.unwrap()
.get(b"N")
.unwrap()
.as_dict()
.unwrap()
.reference(on.as_bytes())
.unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
};
let before = on_ap(&doc, &check);
for (s, code) in
[(CheckStyle::Cross, "8"), (CheckStyle::Square, "n"), (CheckStyle::Star, "H"), (CheckStyle::Diamond, "u"), (CheckStyle::Circle, "l")]
{
set_props(&mut doc, &check, &FieldProps { check_style: Some(s), ..FieldProps::default() }).unwrap();
let doc2 = reopen(&doc);
assert_eq!(style(&doc2, &check), s, "saved and read back");
let mk = doc2.get(field(&fields(&doc2), &check).widgets[0].obj).as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap();
assert_eq!(mk.get(b"CA").and_then(|c| c.as_string()).map(|c| c.to_text()).as_deref(), Some(code));
assert!(mk.get(b"BC").is_some(), "the border colour is kept");
assert_ne!(on_ap(&doc, &check), before, "{s:?} draws a different mark");
}
assert!(on_ap(&doc, &check).contains(" c f"), "a circle is a filled curve");
set_props(&mut doc, &radio, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap();
assert!(on_ap(&doc, &radio).contains(" re f"), "a square");
assert!(
set_props(&mut doc, &text, &FieldProps { check_style: Some(CheckStyle::Star), ..FieldProps::default() }).is_err(),
"text fields have no style"
);
}
#[test]
fn ordering_tabs_manually() {
let mut doc = one_page();