From 45abb5ded3a92c0643713254971c590f58ce71d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 15:37:13 +0200 Subject: [PATCH] #743: freeforms in groups keep their shape; text boxes keep their margins and Resize to fit (#843) A Word 365 cover page lost its look. The missing fills came from theme-coloured shapes (scheme colours and wps:style fill references), which the reporter's 0.5.0 build didn't read; #611 fixed that on main. Two causes were still open on main: - A freeform (a:custGeom) inside a wpg group was drawn as a rectangle. read_wsp now reads custom geometry, so group members get it as well. - Text boxes ignored wps:bodyPr lIns/tIns/rIns/bIns and a:spAutoFit. Layout always used Word's default margins, so a title box with no margins that was just tall enough for its text lost its second line. TextBody now carries the box's own margins and auto_fit. Layout uses those margins and doesn't cut the text of a box that resizes to fit. Both are written back on save. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/doc/src/lib.rs | 16 +++++++ crates/docx/src/read/story.rs | 34 ++++++++++----- crates/docx/src/write/story.rs | 24 +++++++--- crates/docx/tests/fixtures.rs | 70 ++++++++++++++++++++++++++++++ crates/docx/tests/text_boxes.rs | 4 +- crates/layout/src/lib.rs | 5 ++- crates/layout/src/tests_textbox.rs | 45 +++++++++++++++++++ crates/layout/src/textbox.rs | 30 +++++++------ docs/file-format-parity.md | 7 +-- docs/gaps.md | 5 ++- docs/layout-parity.md | 5 ++- 12 files changed, 209 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e9e58e5..5ee3fef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Alt+Q opens command search, #819; previously trivial: Turkish interface catalog completed, #774; previously trivial: Hungarian interface landed; previously trivial: Quick Access Toolbar KeyTips and a saved enable/disable option, #620; previously trivial: new documents follow the system region, #838; previously minor: Polish interface and Polish proofing; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Word 365 cover pages, #743; previously trivial: Turkish interface catalog completed, #774; previously trivial: Hungarian interface landed; previously trivial: Quick Access Toolbar KeyTips and a saved enable/disable option, #620; previously trivial: new documents follow the system region, #838; previously minor: Polish interface and Polish proofing; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | | 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents | | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | +| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 | |---|---| | 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character | | 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s | @@ -244,6 +245,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | trivial | Progress log: German interface (#574) | +| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | | 2026-10-11 | trivial | Progress log: Animation effects setting | | 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f4b4694..2cafa12 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -279,12 +279,28 @@ pub struct TextBody { /// continues in when it doesn't fit this one. The linked box shows the rest of this box's /// story; its own blocks are unused. See [`Document::text_box_chains`]. pub next: Option, + /// Internal margins (left, top, right, bottom), EMU, when the file gives its own + /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults. + pub inset: Option<[u32; 4]>, + /// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of + /// the text is cut. + pub auto_fit: bool, } +/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05". +pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720]; +/// Widest text box margin kept, EMU (22", the largest page side). +pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800; + impl TextBody { pub fn is_default(&self) -> bool { *self == TextBody::default() } + + /// The internal margins (left, top, right, bottom) in points. + pub fn insets_pt(&self) -> [f32; 4] { + self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0) + } } /// Most boxes one chain of linked text boxes runs through (hostile files). diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 05ca4ea..c20a062 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -936,17 +936,14 @@ impl Reader<'_> { } else { let wsp = gd.find("wps:wsp")?; let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); - // A freeform: its custom geometry, and whether it's ink WordCraft wrote. - if let InlineObject::Shape { kind, freeform, .. } = &mut obj - && let Some(sppr) = wsp.child("wps:spPr") - && let Some(mut f) = super::freeform::cust_geom(sppr, w, h) + // A freeform: whether it's ink WordCraft wrote. + if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj + && let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name")) + && let Some(tool) = super::freeform::ink_tool(name) { - f.alpha = super::freeform::line_alpha(sppr.child("a:ln")); - let name = c.child("wp:docPr").and_then(|p| p.attr("name")); - f.ink = name.and_then(super::freeform::ink_tool); - f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order); - *kind = ShapeKind::Freeform; - *freeform = Some(Arc::new(f)); + let f = Arc::make_mut(f); + f.ink = Some(tool); + f.order = super::freeform::ink_order(name); } obj }; @@ -1217,6 +1214,15 @@ impl Reader<'_> { if let Some(a) = bp.attr("anchor") { part.body.anchor = wordcraft_doc::props::valign_from_anchor(a); } + // Internal margins, each defaulting to Word's (#743). + let mut inset = wordcraft_doc::TEXT_BOX_INSET; + for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) { + if let Some(v) = bp.attr(n).and_then(int) { + *slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0); + } + } + part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); + part.body.auto_fit = bp.child("a:spAutoFit").is_some(); } let mut extra = ShapeExtra::default(); // Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle. @@ -1236,7 +1242,13 @@ impl Reader<'_> { self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id)); } extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp); - InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra } + // A freeform: its custom geometry (#743: in a group too). + let freeform = sppr.and_then(|s| super::freeform::cust_geom(s, w, h)).map(|mut f| { + f.alpha = super::freeform::line_alpha(ln); + kind = ShapeKind::Freeform; + Arc::new(f) + }); + InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra } } /// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]). diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 830184a..8d87d61 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1031,13 +1031,25 @@ impl Writer<'_> { if body.anchor != wordcraft_doc::props::VAlign::Top { attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor))); } - match extra.warp.as_ref().and_then(|t| t.sanitized()) { - Some(warp) => { - w.open("wps:bodyPr", &attrs); - text_warp(w, &warp); - w.close("wps:bodyPr"); + // Internal margins, when the box has its own. + let inset = body.inset.map(|i| i.map(|v| v.min(wordcraft_doc::MAX_TEXT_BOX_INSET).to_string())); + if let Some(inset) = &inset { + for (n, v) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(inset) { + attrs.push((n, v)); } - None => w.empty("wps:bodyPr", &attrs), + } + let warp = extra.warp.as_ref().and_then(|t| t.sanitized()); + if warp.is_none() && !body.auto_fit { + w.empty("wps:bodyPr", &attrs); + } else { + w.open("wps:bodyPr", &attrs); + if let Some(warp) = &warp { + text_warp(w, warp); + } + if body.auto_fit { + w.empty("a:spAutoFit", &[]); + } + w.close("wps:bodyPr"); } w.close("wps:wsp"); } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 05619c4..fb862cc 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1514,6 +1514,76 @@ fn vml_lines_and_rectangles() { assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. })); } +/// #743: a cover page from Word: a group holding a rectangle filled through its style, a +/// freeform band and a title box, beside a box that resizes to fit its two lines and has no +/// internal margins. The fills, the band's outline, the margins and the resizing are all read +/// and survive saving. +#[test] +fn cover_page_group_fills_freeforms_and_text_box_margins() { + let style = r#""#; + let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| { + let nv = if txbox { r#""# } else { "" }; + format!( + r#"{nv}{geom}{paint}{style}{inner}"#, + off.0, off.1, ext.0, ext.1 + ) + }; + let rect = r#""#; + let band = r#""#; + let title = r#"Cover Title"#; + let members = [ + member(11, false, (0, 0), (5486400, 5486400), rect, r#""#, ""), + member( + 12, + false, + (914400, 4572000), + (4572000, 914400), + band, + r#""#, + "", + ), + member(13, true, (457200, 457200), (4572000, 1828800), rect, r#""#, title), + ] + .concat(); + let group = format!( + r##"centertop{members}"## + ); + let subtitle = anchored_wsp( + "Subtitle", + 5753100, + 420000, + r#""#, + "", + r#"PlanAuthor"#, + ) + // `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit. + .replace("", r#""#); + let check = |d: &Document| { + let theme = &d.settings.theme_colors; + let objs = ¶s(d)[0].objects; + let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) }; + assert_eq!(children.len(), 3); + let shape = |i: usize| match &children[i].obj { + InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story), + o => panic!("{o:?}"), + }; + let (kind, fill, free, _) = shape(0); + assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill"); + let (kind, fill, free, _) = shape(1); + assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline"); + let (kind, _, _, story) = shape(2); + assert_eq!(kind, ShapeKind::TextBox); + let body = d.parts[&story.unwrap()].body; + assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins"); + let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) }; + let body = d.parts[sub].body; + assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text"); + }; + let d = read_body(&format!("{group}{subtitle}")); + check(&d); + check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + #[test] fn embedded_fonts_survive_a_round_trip() { let table = r#""#; diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index df18ff7..2831c9c 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject { #[test] fn direction_alignment_and_links_round_trip() { let mut d = Document::from_text("a\nb\nc"); - let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None }); + let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() }); let b = text_box(&mut d, "", TextBody::default()); - let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None }); + let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() }); for (i, o) in [a, b, c].into_iter().enumerate() { d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap(); } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0178337..80b078f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1026,8 +1026,11 @@ fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f3 r } -/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom). +/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's +/// own come from its [`wordcraft_doc::TextBody`]. +#[cfg(test)] const BOX_INSET_X: f32 = 7.2; +#[cfg(test)] const BOX_INSET_Y: f32 = 3.6; /// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are diff --git a/crates/layout/src/tests_textbox.rs b/crates/layout/src/tests_textbox.rs index 34c8263..5147133 100644 --- a/crates/layout/src/tests_textbox.rs +++ b/crates/layout/src/tests_textbox.rs @@ -133,3 +133,48 @@ fn hostile_link_chains_stay_bounded() { assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text"); } } + +#[test] +fn own_margins_and_resize_to_fit_keep_the_text_in_view() { + // #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for + // its text; with Word's default margins its second line would be cut. + let two = |h: f32, body: TextBody| { + let mut d = Document::from_text("x"); + let id = text_box(&mut d, 0, "One", 144.0, h, body); + d.parts.get_mut(&id).unwrap().blocks = + vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))]; + let l = lay(&d); + let shown: usize = lines(&l, id).iter().map(|(.., l0, l1)| l1 - l0).sum(); + (shown, lines(&l, id).first().map(|(x, y, ..)| (*x, *y)), frame_of(&l, id)) + }; + // How deep the two lines reach without margins, from a box with room to spare. + let zero = TextBody { inset: Some([0; 4]), ..Default::default() }; + let mut d = Document::from_text("x"); + let id = text_box(&mut d, 0, "One", 144.0, 400.0, zero); + d.parts.get_mut(&id).unwrap().blocks = + vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))]; + let l = lay(&d); + let r = frame_of(&l, id); + let need = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, l0, l1, y, .. } if *s == id => { + let (first, last) = (para.lines.get(*l0)?, para.lines.get(l1.checked_sub(1)?)?); + Some(y + last.top + last.height - first.top - r.y) + } + _ => None, + }) + .fold(0.0f32, f32::max); + assert!(need > 10.0, "{need}"); + let (shown, at, r) = two(need + 1.0, zero); + assert_eq!(shown, 2, "both lines fit without margins"); + let (x, y) = at.unwrap(); + assert!((x - r.x).abs() < 0.5 && (y - r.y).abs() < 0.5, "text starts at the box's corner: {x},{y} in {r:?}"); + assert_eq!(two(need + 1.0, TextBody::default()).0, 1, "Word's default margins cut the second line"); + let (shown, ..) = two(need / 2.0, TextBody { auto_fit: true, ..Default::default() }); + assert_eq!(shown, 2, "a box that resizes to fit its text shows it all"); + // Hostile margins wider than the box still lay out. + let (shown, ..) = two(20.0, TextBody { inset: Some([u32::MAX; 4]), ..Default::default() }); + assert!(shown >= 1); +} diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index fdc2823..2221c3b 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -14,7 +14,7 @@ use wordcraft_doc::props::{TextDirection, TextVert, VAlign}; use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody}; use wordcraft_geom::Rect; -use crate::{BOX_INSET_X, BOX_INSET_Y, Ctx, Placed, fit_box, layout_box}; +use crate::{Ctx, Placed, fit_box, layout_box}; /// Linked text boxes for one layout, worked out when a linked box is first laid out. #[derive(Default)] @@ -54,12 +54,14 @@ pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 { } } -/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs `vert`. -fn frame(vert: TextVert, w: f32, h: f32) -> (f32, f32) { +/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs as `body` +/// says, inside its internal margins. +fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) { let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h)); - let (along, across) = match vert.turn() { - TextDirection::Horizontal => (w - 2.0 * BOX_INSET_X, h - 2.0 * BOX_INSET_Y), - _ => (h - 2.0 * BOX_INSET_Y, w - 2.0 * BOX_INSET_X), + let [l, t, r, b] = body.insets_pt(); + let (along, across) = match body.vert.turn() { + TextDirection::Horizontal => (w - l - r, h - t - b), + _ => (h - t - b, w - l - r), }; (along.max(12.0), across) } @@ -97,7 +99,7 @@ fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth: /// share of a linked chain's, cut to what fits, aligned and turned as the box says. pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec { let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default(); - let (len, across) = frame(body.vert, rect.w, rect.h); + let (len, across) = frame(&body, rect.w, rect.h); let items = match chain_share(ctx, id, depth) { Some(items) => items, None => { @@ -107,8 +109,9 @@ pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default(); let inner = lay(ctx, id, &blocks, len, body.vert, depth); ctx.boxes.leave(); - // Text that doesn't fit inside the margins is hidden, as in Word. - fit_box(inner, across) + // Text that doesn't fit inside the margins is hidden, as in Word; a box that resizes + // to fit its text shows it all (#743). + if body.auto_fit { inner } else { fit_box(inner, across) } } }; place(items, &body, rect, across) @@ -124,11 +127,12 @@ fn place(mut items: Vec, body: &TextBody, rect: Rect, across: f32) -> Ve VAlign::Bottom => (across - used).max(0.0), }; let turn = body.vert.turn(); + let [l, t, r, b] = body.insets_pt(); let (ox, oy) = match turn { - TextDirection::Horizontal => (rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y + off), + TextDirection::Horizontal => (rect.x + l, rect.y + t + off), // Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left. - TextDirection::Down => (rect.right() - BOX_INSET_X - off, rect.y + BOX_INSET_Y), - TextDirection::Up => (rect.x + BOX_INSET_X + off, rect.bottom() - BOX_INSET_Y), + TextDirection::Down => (rect.right() - r - off, rect.y + t), + TextDirection::Up => (rect.x + l + off, rect.bottom() - b), }; for it in &mut items { it.turn(turn, ox, oy); @@ -204,7 +208,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { }; let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default(); let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); - let (len, across) = frame(body.vert, w, h); + let (len, across) = frame(&body, w, h); let key = (len.to_bits(), body.vert); let items = match layouts.iter().find(|(k, _)| *k == key) { Some((_, items)) => items.clone(), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1502ce..4824008 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (font table and embedded fonts kept on save, #770; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -60,9 +60,9 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | 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 | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | -| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | +| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | @@ -179,6 +179,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) | | 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) | | 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) | +| 2026-10-12 | trivial | Word 365 cover page (#743): text-box internal margins and `a:spAutoFit` read and written; freeforms inside groups keep their custom geometry | | 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML | | 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) | | 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) | diff --git a/docs/gaps.md b/docs/gaps.md index d6b35ed..70e64c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** trivial (32-bit Windows no longer exits at launch on DirectX 12; previously trivial: Word XML Document and Word 2003 XML landed, #548) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (#1: a field report from Word 365 fixed, #743; previously trivial: formatted paste from other programs and Wayland clipboard pictures, #687, #833; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -13,6 +13,8 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| +| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | +| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | @@ -72,6 +74,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut | | 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL | | 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) | | 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1dd4064..86e04d7 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -44,7 +44,7 @@ font can't match Word's metrics. | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom | — | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | | Line numbers (restart per page/section, count by) | ✅ | | — | @@ -85,6 +85,7 @@ font can't match Word's metrics. | 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) | | 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) | | 2026-10-11 | trivial | Document grid: line and character pitch (#391) | +| 2026-10-12 | trivial | Text boxes use their own internal margins; Resize shape to fit text never cuts text (#743) | | 2026-10-11 | trivial | Linked text boxes, Align Text and text-box text direction (#369) | | 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) | | 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) |