diff --git a/ROADMAP.md b/ROADMAP.md index b07a5b2ae..5392c9925 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-23 · **Change:** trivial (progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-23 · **Change:** trivial (group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). - 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 245dfd520..8ea70e81a 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::graphic::PathSeg; -use crate::para::{GroupChild, InlineObject, ShapeKind}; +use crate::para::{InlineObject, ShapeKind}; use crate::wordart::TextWarp; /// The rest of a shape's properties. Empty (the default) for most shapes. @@ -191,9 +191,16 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32 if at_end { (ux, uy) } else { (-ux, -uy) } } -/// Re-route the glued connectors among a group's members (they share the group's coordinate -/// space). Returns whether any moved. -pub fn reroute_group(children: &mut [GroupChild]) -> bool { +/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child +/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin. +/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped +/// fallback are unchanged. +pub fn reroute_group(group: &mut InlineObject) -> bool { + let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false }; + let fin = wordcraft_geom::finite; + // Match group_rects: invalid child extents use unit scale; invalid display extents become zero. + let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 }; + let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 }; let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children .iter() .filter_map(|c| match &c.obj { @@ -215,9 +222,35 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool { if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) { continue; } - let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); - let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0)); + let (s, e, fallback) = match (find(extra.start), find(extra.end)) { + (Some(s), Some(e)) => (s, e, false), + (s, e) => { + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + continue; + } + // Rendering scales the rectangle before spinning it. Undo only the group scale + // so route can rebuild an unrotated child frame without moving fallback ends. + let (x, y) = (fin(c.x) * sx, fin(c.y) * sy); + let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0)); + let (cx, cy) = (x + cw / 2.0, y + ch / 2.0); + let spin = float.spin(); + let s0 = spin.apply(cx, cy, x, y); + let e0 = spin.apply(cx, cy, x + cw, y + ch); + (s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true) + } + }; + if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) { + continue; + } let f = route(s, e); + if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) { + continue; + } + // group_rects caps displayed child sizes. A larger fallback span cannot be represented + // by an unrotated frame, so keep the original instead of moving its free/unresolved end. + if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) { + continue; + } let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01; if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 { continue; @@ -237,7 +270,7 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::para::Float; + use crate::para::{Float, GroupChild}; fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject { InlineObject::Shape { @@ -255,6 +288,10 @@ mod tests { } } + fn group(children: Vec) -> InlineObject { + InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None } + } + #[test] fn route_and_ends_are_inverse() { for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] { @@ -275,6 +312,174 @@ mod tests { assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0)); } + #[test] + fn rerouting_preserves_transformed_free_and_unresolved_ends() { + for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] { + for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] { + for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] { + for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (free, attached) } else { (attached, free) }; + let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = rot; + float.flip_h = flip_h; + float.flip_v = flip_v; + // Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate + // clockwise in y-down group space. Do not use the routing transform + // to compute the expectation. + let sign = if free_start { -1.0 } else { 1.0 }; + let dx = sign * if flip_h { -20.0 } else { 20.0 }; + let dy = sign * if flip_v { -10.0 } else { 10.0 }; + let (sin, cos) = rot.to_radians().sin_cos(); + let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos); + let kids = vec![ + GroupChild { + x: 100.0, + y: 200.0, + obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }), + }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]; + let mut group = group(kids); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + let (free, attached) = if free_start { (start, end) } else { (end, start) }; + assert!( + (free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001, + "{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}" + ); + assert_eq!(attached, (140.0, 210.0)); + assert_eq!(float.rot, 0.0); + assert!(!reroute_group(&mut group), "rerouting must settle"); + } + } + } + } + } + } + + #[test] + fn rerouting_preserves_both_unresolved_ends() { + let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let kids = vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]; + let mut group = group(kids); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001); + assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001); + assert_eq!(float.rot, 0.0); + assert!(!reroute_group(&mut group)); + } + + #[test] + fn rerouting_nonuniform_group_preserves_both_unresolved_ends() { + let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let mut group = group(vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]); + let InlineObject::Group { w, h, .. } = &mut group else { panic!() }; + (*w, *h) = (400.0, 150.0); + assert!(reroute_group(&mut group)); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + // Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60). + assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001); + assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001); + assert!(!reroute_group(&mut group)); + } + + #[test] + fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (None, attached) } else { (attached, None) }; + let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.flip_h = free_start; + let mut group = group(vec![ + GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 3000.0, y: 10.0, obj }, + ]); + let InlineObject::Group { w, .. } = &mut group else { panic!() }; + *w = 400.0; + let visual_free = |group: &InlineObject| { + let rects = group.group_rects(0.0, 0.0, 400.0, 300.0); + let ([x, y, w, h], obj) = rects[1]; + let spin = obj.frame().unwrap().2.spin(); + let (px, py) = if free_start { (x, y) } else { (x + w, y + h) }; + spin.apply(x + w / 2.0, y + h / 2.0, px, py) + }; + let before = visual_free(&group); + assert_eq!(before, (6080.0, 10.0)); + let moved = reroute_group(&mut group); + assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints"); + assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged"); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start)); + assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector"); + } + } + + #[test] + fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() { + for dims in [ + [0.0, 300.0, 200.0, 300.0], + [200.0, 0.0, 200.0, 300.0], + [-200.0, 300.0, 200.0, 300.0], + [f32::NAN, 300.0, 200.0, 300.0], + [200.0, f32::INFINITY, 200.0, 300.0], + [f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0], + [f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0], + [20000.0, f32::MIN_POSITIVE, 200.0, 300.0], + [200.0, 300.0, 0.0, 0.0], + [200.0, 300.0, f32::NAN, f32::INFINITY], + [200.0, 300.0, -200.0, -300.0], + ] { + let mut obj = + shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() }); + let InlineObject::Shape { float, .. } = &mut obj else { panic!() }; + float.rot = 90.0; + let mut group = group(vec![ + GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) }, + GroupChild { x: 10.0, y: 30.0, obj }, + ]); + let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() }; + (*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]); + let moved = reroute_group(&mut group); + // group_rects treats invalid child extents as unit scale; display scales that + // cannot be inverted (or whose inverse overflows) must not change the connector. + assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}"); + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}"); + assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}"); + } + assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default()))); + } + #[test] fn group_connectors_follow_their_shapes() { let a = ShapeExtra { id: 7, ..Default::default() }; @@ -285,17 +490,40 @@ mod tests { GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) }, GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) }, ]; - assert!(reroute_group(&mut kids)); + // Fully resolved endpoints must still come from their sites, regardless of the + // connector's old rotation and flips. + let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() }; + float.rot = 90.0; + float.flip_h = true; + float.flip_v = true; + let mut group = group(kids); + assert!(reroute_group(&mut group)); // From the rectangle's right side (20, 5) to the ellipse's left side (100, 55). - let c = &kids[2]; + let InlineObject::Group { children, .. } = &group else { panic!() }; + let c = &children[2]; let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false)); - assert!(!reroute_group(&mut kids), "settled"); + assert!(!reroute_group(&mut group), "settled"); + // A fully resolved connector does not need an invertible group scale. + for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] { + let mut scaled = group.clone(); + let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() }; + (*w, *h) = (width, height); + let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() }; + float.rot = 90.0; + assert!(reroute_group(&mut scaled)); + let InlineObject::Group { children, .. } = &scaled else { panic!() }; + let c = &children[2]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() }; + assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0))); + assert!(!reroute_group(&mut scaled)); + } // A connection to a missing id or site keeps that end where it was. - if let InlineObject::Shape { extra, .. } = &mut kids[2].obj { + let InlineObject::Group { children, .. } = &mut group else { panic!() }; + if let InlineObject::Shape { extra, .. } = &mut children[2].obj { extra.end = Some(ConnEnd { id: 404, site: 0 }); extra.start = Some(ConnEnd { id: 7, site: 99 }); } - assert!(!reroute_group(&mut kids)); + assert!(!reroute_group(&mut group)); } } diff --git a/crates/engine/src/cmd/connectors.rs b/crates/engine/src/cmd/connectors.rs index adadd0a7c..753ec88d8 100644 --- a/crates/engine/src/cmd/connectors.rs +++ b/crates/engine/src/cmd/connectors.rs @@ -77,8 +77,8 @@ pub fn reroute(s: &mut Session) -> bool { let mut moved = false; for pos in groups { if let Ok(para) = s.doc.para_mut(pos.story, &pos.path) - && let Some(InlineObject::Group { children, .. }) = para.object_at_mut(pos.off) - && wordcraft_doc::connector::reroute_group(children) + && let Some(group @ InlineObject::Group { .. }) = para.object_at_mut(pos.off) + && wordcraft_doc::connector::reroute_group(group) { para.touch(); moved = true; @@ -290,3 +290,103 @@ fn connect(s: &mut Session, v: &Value) -> CmdResult { let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned(); Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) } + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::connector::ShapeExtra; + use wordcraft_doc::para::{Float, GroupChild, ShapeKind}; + + fn shape(kind: ShapeKind, extra: ShapeExtra, float: Float) -> InlineObject { + InlineObject::Shape { + kind, + w: 40.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + freeform: None, + effects: Default::default(), + extra, + } + } + + fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) { + let InlineObject::Group { w, h, float, .. } = group else { panic!("group") }; + let rects = group.group_rects(0.0, 0.0, *w, *h); + let ([x, y, w, h], obj) = rects[1]; + let own = obj.frame().unwrap().2.spin(); + let outer = float.spin(); + let turn = |(x, y)| { + let (x, y) = own.apply(rects[1].0[0] + w / 2.0, rects[1].0[1] + h / 2.0, x, y); + let InlineObject::Group { w, h, .. } = group else { panic!("group") }; + outer.apply(*w / 2.0, *h / 2.0, x, y) + }; + (turn((x, y)), turn((x + w, y + h))) + } + + #[test] + fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() { + for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] { + for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] { + for rot in [30.0, 90.0] { + for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] { + for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] { + for free_start in [false, true] { + let attached = Some(ConnEnd { id: 7, site: 3 }); + let (start, end) = if free_start { (free, attached) } else { (attached, free) }; + let group = InlineObject::Group { + w, + h, + ch_w: 200.0, + ch_h: 300.0, + float: Float { rot: 25.0, flip_h: true, ..Default::default() }, + canvas: None, + children: vec![ + GroupChild { + x: 100.0, + y: 200.0, + obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()), + }, + GroupChild { + x: 10.0, + y: 30.0, + obj: shape( + kind, + ShapeExtra { start, end, ..Default::default() }, + Float { rot, flip_h, flip_v, ..Default::default() }, + ), + }, + ], + }; + // Rendering scales each child rectangle before applying its own spin. + let before = visual_ends(&group); + let expected = if free_start { before.0 } else { before.1 }; + let mut s = Session::new(wordcraft_doc::Document::new()); + let pos = s.doc.start_of(StoryRef::Body); + s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap(); + assert!(reroute(&mut s)); + let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap(); + let after = visual_ends(group); + let actual = if free_start { after.0 } else { after.1 }; + assert!( + (actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001, + "{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}" + ); + let InlineObject::Group { children, .. } = group else { panic!("group") }; + let c = &children[1]; + let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") }; + let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v); + assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates"); + assert_eq!(float.rot, 0.0); + assert!(!reroute(&mut s), "rerouting must settle"); + } + } + } + } + } + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 178cbb37b..cfd6d4bf3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,11 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). +Group connectors (gap 10): re-routing preserves displayed rotated and flipped free endpoints after unit, uniform or nonuniform group scaling, including ends whose target shape or site cannot be resolved. Glued endpoints still use their existing connection-site path. Degenerate display scales that cannot be inverted, or fallback spans exceeding the renderer's 4000 pt child-size cap, leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents retain the renderer's unit-scale fallback. Model and engine regressions cover this geometry and guard against nonfinite coordinate writes and repeated shrinkage at the display cap; routing around shapes remains missing, with no new native Word comparison. + > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. The work list. One entry per shortfall, ranked by how much it stops a Word user from switching @@ -122,6 +124,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Gap 10: group connector rerouting preserves rotated/flipped free and unresolved endpoints under nonuniform scaling, with safe degenerate-dimension and display-size-cap handling; routing around shapes and readiness estimates unchanged | | 2026-10-21 | trivial | The ranked gap list held two rows each for #19, #20 and #21; the stale copies are gone, so each gap appears once, and the #20 row carries the measured figure (#3411) | | 2026-10-21 | trivial | #20 narrowed: a document with many relationships opens in the time its part takes (#3411) | | 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. No gap row; estimates unchanged. | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7af25fcb1..3c29ab8c8 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-23 · **Change:** trivial (a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously 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, @@ -70,6 +70,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 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; 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 | +| Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | | Line numbers (restart per page/section, count by) | ✅ | | — | | Vertical page alignment | ✅ | Top, Center, Bottom and Justified (#1320): justified shares each page's spare room equally between its paragraphs and tables, first line on the top margin and last on the bottom one (above the footnotes), the last page and a page ending in a manual page break too; a page with one block stays at the top; the last paragraph's space after stays inside the justified height (Word for Mac, user-checked). A section's last page takes its own section's alignment (it took the next section's before). Multi-column justified pages stay at the top (not compared with Word) | — | @@ -238,6 +239,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | | 2026-10-22 | trivial | A table row that another row follows fits on a page only with room for the border below it, as checked in Word for Mac: a 60-row table with 3 pt borders kept one row more than Word on page 1 for bottom margins of 78.2–81.0 pt (`layout::tests::a_row_leaves_room_for_the_border_below_it`) |