mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-19 02:10:15 -05:00
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:
7 files changed
+297
-3
No files matched your search
@@ -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("<", "<").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#"<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 & 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user