Legacy form fields are drawn: check boxes, and drop-downs' selected entry (#2076)

* Legacy form fields are drawn: check boxes, and drop-downs' selected entry

Legacy form fields keep their w:ffData since the last round, but were
not drawn: a FORMCHECKBOX has an empty result, so a form of check
boxes came out with none, and a FORMDROPDOWN showed its saved result
rather than its selected entry.

- wordcraft-doc: `legacy_form` reads what layout needs from the kept
  w:ffData (kind, name, check box state and size, drop-down entries and
  selection, text field length); `InlineObject::legacy_form()`.
- wordcraft-layout: a check box takes the text line's height of room
  and is drawn as Word draws it; a drop-down shows its selected entry
  (w:result, else w:default, else the first).

Measured on Word's PDF export with a synthetic document (Calibri):

  text size | Word square | WordCraft
  8 pt      | 7.44        | 7.45
  10 pt     | 9.96        | 9.89
  12 pt     | 12.36       | 12.33
  20 pt     | 22.08       | 22.09
  w:size 15 | 16.08       | 15.99

The square sits 1.16 pt inside the line height on every side (room on
the line within 0.2 pt of Word's), has a 0.72 pt border and, checked,
an X from corner to corner in 0.48 pt lines; w:checked beats
w:default. docs/file-format-parity.md is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw::Shape takes the dash list main added

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 11:33:15 -04:00
1 parent 70fc00c80f
commit 072acce71b
7 files changed
+297 -3

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
//! Legacy form fields (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): what their `w:ffData` says,
//! read from the raw XML a [`crate::InlineObject::Field`] keeps in its `form` (ECMA-376 Part 1
//! §17.16.17). Only what layout and PDF export need; the XML itself stays the source of truth
//! for saving.
/// Most drop-down entries kept (Word allows 25).
pub const MAX_ENTRIES: usize = 64;
/// A legacy form field's kind and state.
#[derive(Clone, Debug, PartialEq)]
pub enum LegacyForm {
/// A check box: `checked` is its state (`w:checked`, else `w:default`), `size` its explicit
/// size in points (`w:size`), `None` when it follows the text (`w:sizeAuto`).
CheckBox { name: String, checked: bool, size: Option<f32> },
/// A text field; its text is the field's result.
Text { name: String, max_len: Option<u32> },
/// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first).
DropDown { name: String, entries: Vec<String>, selected: usize },
}
impl LegacyForm {
/// The field's definition, or `None` when `ff_data` holds none of the three.
pub fn parse(ff_data: &str) -> Option<LegacyForm> {
let name = attr_of(ff_data, "w:name").unwrap_or_default();
if let Some(cb) = element(ff_data, "w:checkBox") {
let default = attr_of(cb, "w:default").is_some_and(|v| on(&v));
// `<w:checked/>` with no value means checked.
let checked = match tag(cb, "w:checked") {
Some(t) => attr_in(t).is_none_or(|v| on(&v)),
None => default,
};
let size =
attr_of(cb, "w:size").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| (v / 2.0).min(1584.0));
return Some(LegacyForm::CheckBox { name, checked, size });
}
if let Some(dd) = element(ff_data, "w:ddList") {
let entries: Vec<String> = tags(dd, "w:listEntry").filter_map(attr_in).take(MAX_ENTRIES).collect();
let pick = |n: &str| attr_of(dd, n).and_then(|v| v.trim().parse::<usize>().ok());
let selected = pick("w:result").or_else(|| pick("w:default")).filter(|i| *i < entries.len()).unwrap_or(0);
return Some(LegacyForm::DropDown { name, entries, selected });
}
if ff_data.contains("<w:textInput") {
let max_len =
element(ff_data, "w:textInput").and_then(|t| attr_of(t, "w:maxLength")).and_then(|v| v.trim().parse::<u32>().ok()).filter(|n| *n > 0);
return Some(LegacyForm::Text { name, max_len });
}
None
}
/// The text a drop-down shows (its selected entry); `None` for other kinds or an empty list.
pub fn shown_text(&self) -> Option<&str> {
match self {
LegacyForm::DropDown { entries, selected, .. } => entries.get(*selected).map(String::as_str),
_ => None,
}
}
/// The field's bookmark-style name (`w:name`), possibly empty.
pub fn name(&self) -> &str {
match self {
LegacyForm::CheckBox { name, .. } | LegacyForm::Text { name, .. } | LegacyForm::DropDown { name, .. } => name,
}
}
}
fn on(v: &str) -> bool {
!matches!(v.trim(), "0" | "false" | "off")
}
/// The element `name` in `xml` with its content: from its start tag to its end tag, or the
/// self-closing tag alone.
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let open = tag(xml, name)?;
let start = xml.find(open)?;
if open.ends_with("/>") {
return Some(open);
}
let close = format!("</{name}>");
let end = xml.get(start..)?.find(&close)? + start + close.len();
xml.get(start..end)
}
/// The first start (or empty) tag `<name ...>` in `xml`.
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
tags(xml, name).next()
}
/// Every start (or empty) tag `<name ...>` in `xml`, in order.
fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
let pat = format!("<{name}");
let mut from = 0;
std::iter::from_fn(move || {
loop {
let at = from + xml.get(from..)?.find(&pat)?;
let after = at + pat.len();
from = after;
// `<w:checkBox` must not match `<w:checkBoxes`: the name ends at a space, `/` or `>`.
if xml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c == ' ' || c == '/' || c == '>') {
let end = after + xml.get(after..)?.find('>')? + 1;
return xml.get(at..end);
}
}
})
}
/// The `w:val` of the first tag `name` in `xml`.
fn attr_of(xml: &str, name: &str) -> Option<String> {
tag(xml, name).and_then(attr_in)
}
/// The `w:val` of a single tag, XML entities resolved.
fn attr_in(tag: &str) -> Option<String> {
let at = tag.find("w:val=")? + "w:val=".len();
let quote = tag.get(at..)?.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = tag.get(at + 1..)?;
let end = rest.find(quote)?;
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_boxes_text_fields_and_drop_downs() {
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None })
);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="1"/>"#)).map(|f| matches!(f, LegacyForm::CheckBox { checked: true, .. })),
Some(true)
);
// `w:checked` wins over `w:default`; without a value it means checked.
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#)),
Some(LegacyForm::CheckBox { checked: false, .. })
));
assert!(matches!(LegacyForm::parse(&cb(r#"<w:default w:val="0"/><w:checked/>"#)), Some(LegacyForm::CheckBox { checked: true, .. })));
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:size w:val="30"/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { size: Some(15.0), .. })
));
let dd = r#"<w:ffData><w:name w:val="List1"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="B &amp; C"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(dd).unwrap();
assert_eq!(f.shown_text(), Some("B & C"));
assert_eq!(f.name(), "List1");
let dd0 = r#"<w:ffData><w:ddList><w:listEntry w:val="Only"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only"));
let tx = r#"<w:ffData><w:name w:val="Name1"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData>"#;
assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) }));
assert_eq!(LegacyForm::parse(""), None);
assert_eq!(LegacyForm::parse("<w:ffData><w:name w:val=\"x\"/></w:ffData>"), None);
}
}
+1
View File
@@ -24,6 +24,7 @@ pub mod eq;
pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
pub mod locale_number;
+8
View File
@@ -743,6 +743,14 @@ impl InlineObject {
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
+35
View File
@@ -1097,6 +1097,13 @@ fn lines(
equation(&ml.items, cx, base, alpha, opts.placeholders, out);
}
}
Some(o @ InlineObject::Field { .. }) => {
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() {
let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None));
let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d);
check_box(frame, checked, color, alpha, out);
}
}
_ => {}
}
}
@@ -1604,6 +1611,34 @@ fn author_index(doc: &Document, author: &str) -> u32 {
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
}
/// How far a legacy check box's square sits inside the room it takes on its line, on every side.
pub const CHECK_BOX_INSET: f32 = 1.16;
/// A legacy form check box (`FORMCHECKBOX`) in `frame`, the room it takes on its line (as wide
/// as the text's line is tall): a square inset [`CHECK_BOX_INSET`] with a 0.72 pt border, and,
/// checked, an X from corner to corner in 0.48 pt lines, in the text's colour. Measured on Word's
/// PDF export: a 9.96 pt square for 10 pt Calibri, 22.08 pt for 20 pt.
pub fn check_box(frame: Rect, checked: bool, color: Rgb, alpha: f32, out: &mut Vec<Draw>) {
let i = CHECK_BOX_INSET.min(frame.w / 4.0).min(frame.h / 4.0).max(0.0);
let sq = Rect::new(frame.x + i, frame.y + i, (frame.w - 2.0 * i).max(0.5), (frame.h - 2.0 * i).max(0.5));
let line =
|x0: f32, y0: f32, x1: f32, y1: f32, width: f32| Draw::Line { x0, y0, x1, y1, width, color, stroke: Stroke::Solid, alpha, text_bg: None };
let (l, t, r, b) = (sq.x, sq.y, sq.x + sq.w, sq.y + sq.h);
out.push(Draw::Shape {
rect: sq,
kind: ShapeKind::Rectangle,
fill: None,
stroke: Some(color),
stroke_width: 0.72,
effects: Default::default(),
corner: None,
dash: Vec::new(),
});
if checked {
out.extend([line(l, t, r, b, 0.48), line(r, t, l, b, 0.48)]);
}
}
/// Automatic colour is black, or white on a dark background.
pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
match c {
+26 -2
View File
@@ -1540,8 +1540,32 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
push(&mut b, ClKind::Object(k), 0.0, 0.0);
}
}
Some(InlineObject::Field { instr, result, code, .. }) => {
if env.field_codes != *code {
Some(o @ InlineObject::Field { instr, result, code, .. }) => {
let form = if env.field_codes != *code { None } else { o.legacy_form() };
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { size, .. }) = form {
// Word draws a legacy check box as a square as tall as the text's
// line (its ascent and descent), scaled to an explicit size, and
// takes that much room on the line (see `display::check_box`).
let (a, d) = b.styles.get(si as usize).map_or((rc.size * 0.8, rc.size * 0.2), |s| (s.ascent, s.descent));
let scale = size.map_or(1.0, |sz| sz / rc.size.max(0.5));
let (a, d) = (a * scale, d * scale);
b.clusters.push(Cluster {
start,
end,
adv: a + d,
kind: ClKind::Object(k),
style: si,
g0: g,
g1: g,
break_after: false,
obj_h: a.max(0.01),
obj_d: d,
dot: false,
});
} else if let Some(text) = form.as_ref().and_then(|f| f.shown_text()) {
// A drop-down shows its selected entry, whatever result was saved.
b.shape_atomic(text, start, end, &rc);
} else if env.field_codes != *code {
// The code in braces on grey, like Word's field shading.
let mut shaded = (*rc).clone();
if shaded.highlight.is_none() && shaded.shading.is_none() {
+62
View File
@@ -729,6 +729,68 @@ fn page_break_char_starts_new_page() {
assert_eq!(l.pages.len(), 2);
}
/// Legacy form fields are drawn as Word draws them: a check box is a square as tall as the text's
/// line, inset 1.16 pt, taking that line height of room, with an X when checked (`w:checked`
/// beating `w:default`); a drop-down shows its selected entry whatever result was saved.
#[test]
fn legacy_form_fields_are_drawn() {
let field = |form: &str| InlineObject::Field {
instr: "FORMCHECKBOX".into(),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: form.into(),
};
let check_box = |state: &str| format!(r#"<w:ffData><w:name w:val="Box"/><w:checkBox><w:sizeAuto/>{state}</w:checkBox></w:ffData>"#);
let draws_of = |o: InlineObject| {
let mut d = Document::from_text("ab");
d.insert_object(&Pos::body(0, 1), o, &Default::default()).unwrap();
let l = lay(&d);
let b_x = l.caret(&Pos::body(0, 1 + wordcraft_doc::para::OBJ.len_utf8())).unwrap().x;
let a_x = l.caret(&Pos::body(0, 1)).unwrap().x;
(display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()), b_x - a_x)
};
let squares = |items: &[display::Draw]| -> Vec<wordcraft_geom::Rect> {
items
.iter()
.filter_map(|i| {
if let display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, stroke: Some(_), .. } = i {
Some(*rect)
} else {
None
}
})
.collect()
};
let diagonals = |items: &[display::Draw]| {
items.iter().filter(|i| matches!(i, display::Draw::Line { x0, x1, y0, y1, .. } if (x1 - x0).abs() > 1.0 && (y1 - y0).abs() > 1.0)).count()
};
let (items, room) = draws_of(field(&check_box(r#"<w:default w:val="0"/>"#)));
let sq = squares(&items);
assert_eq!(sq.len(), 1, "one square: {items:?}");
assert!((sq[0].w - sq[0].h).abs() < 0.01 && (room - sq[0].w - 2.0 * display::CHECK_BOX_INSET).abs() < 0.01, "{:?} in {room}", sq[0]);
assert!(sq[0].w > 8.0 && sq[0].w < 14.0, "about the line's height: {}", sq[0].w);
assert_eq!(diagonals(&items), 0, "unchecked");
assert_eq!(diagonals(&draws_of(field(&check_box(r#"<w:default w:val="1"/>"#))).0), 2, "checked by default");
assert_eq!(diagonals(&draws_of(field(&check_box(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#))).0), 0, "w:checked wins");
// An explicit size: 20 pt is about twice the 10-11 pt text's square.
let (big, _) = draws_of(field(&check_box(r#"<w:size w:val="40"/><w:default w:val="0"/>"#)));
assert!(squares(&big)[0].w > sq[0].w * 1.6, "{:?} vs {:?}", squares(&big), sq);
// A drop-down shows its selected entry.
let dd = InlineObject::Field {
instr: "FORMDROPDOWN".into(),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: r#"<w:ffData><w:name w:val="List"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="Beta"/></w:ddList></w:ffData>"#.into(),
};
let (items, _) = draws_of(dd);
let text: String = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect();
assert!(text.contains("Beta") && !text.contains("Alpha"), "{text}");
}
#[test]
fn a_missing_roman_font_is_drawn_as_word_draws_it() {
// Measured in Word: a font that isn't installed is drawn in Cambria when the font table calls
+2 -1
View File
@@ -83,7 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion |
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit |
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes — as Word blocks property edits until Stop Protection — while other properties (title, tag, placeholder…) stay editable |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
| Legacy font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint` in `w:rPr`) | ✅ | ✅ | Drawn on screen and in PDFs as Word's PDFs show them (#2534): hollow outlined letters; a sharp grey copy down-right (shadow, emboss) or up-left (imprint); WordArt effects on the same run win; tracked insertions and deletions keep them. Not drawn on colour emoji, or on top-to-bottom text with upright East Asian characters. Emboss and imprint are approximations (no highlight edge) |
@@ -354,3 +354,4 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too |
| 2026-10-15 | trivial | DOCX: runs mixing right-to-left and left-to-right text are split by direction on save; right-to-left pieces get `w:rtl` and `w:lang w:bidi`, so Word shows the order WordCraft shows (#1543) |
| 2026-10-16 | trivial | DOCX: OpenType ligatures (`w14:ligatures`) read and written in runs, styles and document defaults |
| 2026-10-15 | trivial | Legacy form fields are drawn: check boxes as Word's squares (checked with an X), drop-downs with their selected entry |