mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
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:
co-authored by
Claude Opus 5.5
parent
55c581760a
commit
cdb3f3d106
@@ -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::*;
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user