From 072acce71bdd1bfbb1bceda4d8ee9a710a49df47 Mon Sep 17 00:00:00 2001 From: Morzilla Date: Sun, 18 Oct 2026 17:33:15 +0200 Subject: [PATCH] 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 * Draw::Shape takes the dash list main added --------- Co-authored-by: Daniele Mori Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/legacy_form.rs | 163 ++++++++++++++++++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/para.rs | 8 ++ crates/layout/src/display.rs | 35 ++++++++ crates/layout/src/para.rs | 28 +++++- crates/layout/src/tests.rs | 62 +++++++++++++ docs/file-format-parity.md | 3 +- 7 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 crates/doc/src/legacy_form.rs diff --git a/crates/doc/src/legacy_form.rs b/crates/doc/src/legacy_form.rs new file mode 100644 index 000000000..36c42f48b --- /dev/null +++ b/crates/doc/src/legacy_form.rs @@ -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 }, + /// A text field; its text is the field's result. + Text { name: String, max_len: Option }, + /// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first). + DropDown { name: String, entries: Vec, 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 { + 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)); + // `` 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::().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 = 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::().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("().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!(""); + let end = xml.get(start..)?.find(&close)? + start + close.len(); + xml.get(start..end) +} + +/// The first start (or empty) tag `` in `xml`. +fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + tags(xml, name).next() +} + +/// Every start (or empty) tag `` in `xml`, in order. +fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator + 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; + // ``. + 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 { + tag(xml, name).and_then(attr_in) +} + +/// The `w:val` of a single tag, XML entities resolved. +fn attr_in(tag: &str) -> Option { + 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("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_boxes_text_fields_and_drop_downs() { + let cb = |inner: &str| format!(r#"{inner}"#); + assert_eq!( + LegacyForm::parse(&cb(r#""#)), + Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None }) + ); + assert_eq!( + LegacyForm::parse(&cb(r#""#)).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#""#)), + Some(LegacyForm::CheckBox { checked: false, .. }) + )); + assert!(matches!(LegacyForm::parse(&cb(r#""#)), Some(LegacyForm::CheckBox { checked: true, .. }))); + assert!(matches!( + LegacyForm::parse(&cb(r#""#)), + Some(LegacyForm::CheckBox { size: Some(15.0), .. }) + )); + let dd = r#""#; + let f = LegacyForm::parse(dd).unwrap(); + assert_eq!(f.shown_text(), Some("B & C")); + assert_eq!(f.name(), "List1"); + let dd0 = r#""#; + assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only")); + let tx = r#""#; + assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) })); + assert_eq!(LegacyForm::parse(""), None); + assert_eq!(LegacyForm::parse(""), None); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6870a16b3..c41a14b2b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index e8bafa6f0..643cc8d4e 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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 { + 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!( diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index a6f4eced3..b9526037b 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -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) { + 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 { match c { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 47b644beb..799b3bc33 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 699283984..f9e80962a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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#"{state}"#); + 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 { + 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#""#))); + 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#""#))).0), 2, "checked by default"); + assert_eq!(diagonals(&draws_of(field(&check_box(r#""#))).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#""#))); + 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#""#.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 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eb375e62f..351323dc2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |