From 586f85df960179e5723317aa438d7b0c7a2e74e5 Mon Sep 17 00:00:00 2001 From: Marc <184940552+marcwgls@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:26:26 +0100 Subject: [PATCH 01/16] fix(ui): the File page scrolls across its full width, with the bar at the window edge (#5282) (#5320) * fix(ui): the File page scrolls across its full width, with the bar at the right edge (#5282) Turn off horizontal auto_shrink on the File page ScrollArea so the scroll area expands across the full CentralPanel width. This positions the vertical scrollbar at the right edge of the window and allows mouse wheel scrolling over the entire panel width. * fix(ui): the File page's scroll area reaches the window edge (#5282) The side padding moves from the panel into the pages, so the scrollbar sits at the edge and the wheel also works in the rightmost 40 points. * test(ui): assert the File page's scrollbar sits at the window edge (#5282) --- crates/ui-egui/src/backstage.rs | 31 +++++++++------- crates/ui-egui/src/backstage/tests.rs | 52 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index d3fd4a4ccc..f0e25a7cbd 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -184,23 +184,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); }); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(0, 30))).show(ui, |ui| { + // The side padding sits inside the pages, so the scroll area reaches the window's edge. + let pad = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 0)); // The Print page fills the pane and scrolls its settings and its preview itself (#1156). if app.ui.backstage_page == "print" { - crate::rtl::root(ui, |ui| export_page(app, ui)); + pad.show(ui, |ui| crate::rtl::root(ui, |ui| export_page(app, ui))); return; } - egui::ScrollArea::vertical().show(ui, |ui| { - crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() { - "home" => home_page(app, ui), - "new" => new_page(app, ui), - "open" => open_page(app, ui), - "saveAs" => save_as_page(app, ui), - "info" => info_page(app, ui), - "recover" => recovery_page(app, ui), - "print" | "export" => export_page(app, ui), - "options" => crate::options::page(app, ui), - _ => home_page(app, ui), + // Full width, so the bar sits at the window's edge and the wheel works over the whole page (#5282). + egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { + pad.show(ui, |ui| { + crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() { + "home" => home_page(app, ui), + "new" => new_page(app, ui), + "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), + "info" => info_page(app, ui), + "recover" => recovery_page(app, ui), + "print" | "export" => export_page(app, ui), + "options" => crate::options::page(app, ui), + _ => home_page(app, ui), + }) }) }); }); diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 21d7ec8990..ed64c25458 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -1054,3 +1054,55 @@ fn backstage_save_as_sidebar_opens_save_as_page() { h.run_steps(4); assert_eq!(h.state().ui.backstage_page, "saveAs"); } + +/// Scrolls the File page with the wheel at `x` and returns doc0.docx's top before and after, +/// plus the rectangle of the page's vertical scrollbar. +fn file_page_wheel_at(x: f32) -> (f32, f32, egui::Rect) { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.recent = (0..12).map(|i| format!("/docs/doc{i}.docx")).collect(); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 500.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + + let bar = h + .query_all_by_role(egui::accesskit::Role::ScrollBar) + .map(|n| n.rect()) + .filter(|r| r.height() > r.width()) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the File page's vertical scrollbar"); + let before = h.get_by_label("doc0.docx").rect(); + h.event(egui::Event::PointerMoved(egui::pos2(x, 300.0))); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + (before.min.y, h.get_by_label("doc0.docx").rect().min.y, bar) +} + +/// #5282: the File page scrolls with the wheel over its right side. +#[test] +fn the_file_page_scrolls_with_the_wheel_over_its_right_side() { + let (before, after, _) = file_page_wheel_at(1300.0); + assert!(after < before - 1.0, "expected doc0.docx to scroll up: before={before}, after={after}"); +} + +/// #5282: the scroll area reaches the window's right edge, so its bar sits there and the wheel +/// works there too. +#[test] +fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() { + // The harness keeps an 8 point margin, so the window edge is at x=1432. + let (before, after, bar) = file_page_wheel_at(1428.0); + assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}"); + assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}"); +} From b7c2fe8dff253426ee1dc2e540dba4b502516b75 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:28:39 +0100 Subject: [PATCH 02/16] DOCX: a VML text box keeps its text direction (layout-flow) (#5193) (#5346) Compatibility-mode documents store text boxes as VML only. The reader ignored v:textbox's text direction, so a header text box turned 270 degrees was drawn level. It now reads it as Word for Windows does: mso-layout-flow-alt:bottom-to-top is vert270 whatever layout-flow says; otherwise layout-flow:vertical or vertical-ideographic is vert; anything else stays horizontal. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docx/src/read/story/vml.rs | 9 +++++++++ crates/docx/tests/it/text_boxes.rs | 13 ++++++++++++- docs/file-format-parity.md | 4 +++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3e3176853..1b28d2b960 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. +- 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -834,6 +835,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. > 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged. > 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged. +> 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193). > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index b625b8d4f0..e40d38b3a7 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -224,6 +224,15 @@ impl Reader<'_> { { let id = self.read_textbox(sc, t, rels); if let Some(part) = self.doc.parts.get_mut(&id) { + // Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is + // `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also + // `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal. + let tb_style = tb.attr("style").unwrap_or(""); + if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) { + part.body.vert = wordcraft_doc::props::TextVert::Vert270; + } else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) { + part.body.vert = wordcraft_doc::props::TextVert::Vert; + } // Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left // out keeping Word's default; a bare number is in EMUs, VML's base unit. // `o:insetmode="auto"` leaves them to the application. diff --git a/crates/docx/tests/it/text_boxes.rs b/crates/docx/tests/it/text_boxes.rs index 98144fdd33..a2ac71a6e3 100644 --- a/crates/docx/tests/it/text_boxes.rs +++ b/crates/docx/tests/it/text_boxes.rs @@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() { shape("", r#"o:insetmode="auto" inset="0,0,0,0""#), // In a group, a member's margins go with that member's text, not an empty box's before it. format!(r#"{content}"#), + // Text direction (#5193), as Word for Windows reads it: `vertical` and + // `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any + // `layout-flow` or none; `horizontal-ideographic` stays horizontal. + shape("", r#"style="layout-flow:vertical""#), + shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#), + shape("", r#"style="layout-flow:vertical-ideographic""#), + shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#), + shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#), + shape("", r#"style="layout-flow:horizontal-ideographic""#), ] .concat(); let doc = format!( @@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() { let rels = br#""#; let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); let bodies: Vec = boxes(&d).iter().map(|id| d.parts[id].body).collect(); - assert_eq!(bodies.len(), 6); + assert_eq!(bodies.len(), 12); assert_eq!(bodies[0].inset, Some([0, 0, 0, 0])); assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center)); assert_eq!(bodies[2], TextBody::default()); assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom)); assert_eq!(bodies[4].inset, None); assert_eq!(bodies[5].inset, Some([0, 0, 0, 0])); + let verts: Vec = bodies[6..].iter().map(|b| b.vert).collect(); + assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]); // Saved and read again, they stay. let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); let again: Vec = boxes(&back).iter().map(|id| back.parts[id].body).collect(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 89b32269b0..4dc5e6d178 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -131,7 +131,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); 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` with their `type`, `w` and `len` kept; drawn as triangles 2, 3 or 5 times the line's width long and wide, as Word does), including custom-geometry paths (#416); fills with transparency (`a:alpha`) and gradients (`a:gradFill`: up to 10 stops each with its transparency, `a:lin` angle and `scaled`, `a:path` circle/rect/shape with `fillToRect`, `tileRect`, `rotWithShape`, `flip`) read and written as stated, also through the theme's fill styles (`a:fillRef` 1–999 `a:fillStyleLst`, 1001+ `a:bgFillStyleLst`, `phClr` in the reference's colour; written as the resolved fill), outline gradients kept (drawn in their middle stop) (#1500). Drawn: linear and circular; rectangular and shape-following paths drawn circular; `tileRect`/`flip` not drawn; pattern fills drawn and saved as their foreground colour. Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | 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, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). 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, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read | +| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Text-box text direction is read as Word reads it: `mso-layout-flow-alt:bottom-to-top` as `vert270` whatever `layout-flow` says, else `layout-flow:vertical` or `vertical-ideographic` as `vert`, else horizontal (#5193). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read | | 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`); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). 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 | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | @@ -321,6 +321,8 @@ For field results, the engine parser treats a backslash in quoted arguments as e ## Revision history +> 2026-11-07 · VML text boxes: `mso-layout-flow-alt:bottom-to-top` reads as `vert270` with any `layout-flow`, and `layout-flow:vertical` or `vertical-ideographic` otherwise as `vert`, so a compatibility-mode header's turned text box is drawn turned (#5193, `it::text_boxes::vml_text_boxes_keep_their_inset_fit_and_anchor`). + > 2026-11-03 · Section `w:chapStyle` values preserve the full unsigned-byte range, including 10, while values above 255 are rejected. | 2026-10-30 | trivial | DOCX section metadata now preserves independent printer trays, section endnote suppression, chapter numbering settings, and footnote columns through native JSON and repeated save/reopen | From cd81d5da4986b4d0176c053fadd4a00f6fbf329f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 7 Nov 2026 22:28:57 -0500 Subject: [PATCH 03/16] XLSX import: index worksheet relationships by id (#5424) * fix(formats): index XLSX worksheet relationships by id * style: rustfmt --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/formats/src/sheet.rs | 21 +++++++++++++++++++++ crates/formats/src/sheet/xlsx.rs | 15 +++++++-------- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b28d2b960..f4f1c4d6a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -234,6 +234,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged. - 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged. - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. +- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 53c7fca84f..beeaa1f142 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -427,6 +427,27 @@ mod tests { const RELS: &str = r#""#; + #[test] + fn workbook_sheets_find_their_relationships_by_index() { + // Every sheet searched every relationship; 4,096 sheets over 20,000 relationships. + let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#""#)).collect(); + let wb = format!( + r#"{sheets}"# + ); + let mut rels = String::from(r#""#); + // A chart sheet sharing a worksheet's id doesn't hide it. + rels.push_str(r#""#); + for i in 0..20_000 { + rels.push_str(&format!( + r#""# + )); + } + rels.push_str(""); + let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap(); + assert_eq!(names.len(), MAX_SHEETS); + assert_eq!(names.first().map(String::as_str), Some("S0")); + } + fn xlsx(sheet1: &str) -> Vec { let wb = r#""#; let wb_rels = r#""#; diff --git a/crates/formats/src/sheet/xlsx.rs b/crates/formats/src/sheet/xlsx.rs index dffa9be7dd..cca2472119 100644 --- a/crates/formats/src/sheet/xlsx.rs +++ b/crates/formats/src/sheet/xlsx.rs @@ -83,14 +83,13 @@ fn workbook(pkg: &mut Package<'_>) -> Result { } } }); - let sheets = listed - .into_iter() - .filter_map(|(name, id)| { - // Worksheets only: chart and dialog sheets hold no cells. - let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?; - Some(SheetRef { name, path: r.target.clone() }) - }) - .collect(); + // Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first + // relationship with an id wins), not searched for every sheet. + let mut worksheets: HashMap<&str, &str> = HashMap::new(); + for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) { + worksheets.entry(r.id.as_str()).or_insert(r.target.as_str()); + } + let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect(); let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone()); Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") }) } From 6d0bd73127c773f6353a22ad34cca0cd8d23aef5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:31:23 -0600 Subject: [PATCH 04/16] Preserve paragraph text direction in DOCX (#5392) * WC6-07: preserve paragraph text direction * Resolve gaps summary merge * WC6-07: retain paragraph direction variants * WC6-07: preserve strict paragraph direction * Update lockfile for DOCX test dependency * Close paragraph direction implementation * Format paragraph direction imports --- crates/doc/src/props.rs | 3 +- crates/doc/src/props/para_props.rs | 65 ++++++++++++++++++ crates/docx/src/read/props.rs | 5 +- crates/docx/src/write/props.rs | 3 + crates/docx/tests/it/roundtrip.rs | 5 +- .../docx/tests/it/section_text_direction.rs | 68 +++++++++++++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a801081845..38feba083c 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -27,7 +27,8 @@ pub use char_props::{ }; pub use color::{Rgb, TextColor}; pub use para_props::{ - Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame, + Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop, + TextAlignment, TextFrame, }; pub use table_props::{ CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index c34d527192..a48540248e 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -26,6 +26,67 @@ impl Align { } } +/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41). +/// +/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction +/// control. These values preserve the exact OOXML direction through JSON and DOCX saves. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParagraphTextDirection { + LrTb, + LrTbV, + Tb, + TbRl, + TbRlV, + TbLr, + TbLrV, + BtLr, + TbV, + Rl, + RlV, + Lr, + LrV, +} + +impl ParagraphTextDirection { + pub fn from_ooxml(value: &str) -> Option { + match value { + "lrTb" => Some(Self::LrTb), + "lrTbV" => Some(Self::LrTbV), + "tb" => Some(Self::Tb), + "tbRl" => Some(Self::TbRl), + "tbRlV" => Some(Self::TbRlV), + "tbLr" => Some(Self::TbLr), + "tbLrV" => Some(Self::TbLrV), + "btLr" => Some(Self::BtLr), + "tbV" => Some(Self::TbV), + "rl" => Some(Self::Rl), + "rlV" => Some(Self::RlV), + "lr" => Some(Self::Lr), + "lrV" => Some(Self::LrV), + _ => None, + } + } + + pub fn ooxml(self) -> &'static str { + match self { + Self::LrTb => "lrTb", + Self::LrTbV => "lrTbV", + Self::Tb => "tb", + Self::TbRl => "tbRl", + Self::TbRlV => "tbRlV", + Self::TbLr => "tbLr", + Self::TbLrV => "tbLrV", + Self::BtLr => "btLr", + Self::TbV => "tbV", + Self::Rl => "rl", + Self::RlV => "rlV", + Self::Lr => "lr", + Self::LrV => "lrV", + } + } +} + /// Vertical alignment of characters of different sizes on a line (`w:textAlignment`, /// ECMA-376 §17.3.1.39). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -391,6 +452,9 @@ pub struct ParaProps { pub suppress_line_numbers: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bidi: Option, + /// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_direction: Option, /// Drop cap: lines to drop (0 = none) — applies to the first character(s). #[serde(skip_serializing_if = "Option::is_none")] pub drop_cap: Option, @@ -462,6 +526,7 @@ impl ParaProps { } fn overlay_script_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 4709916b7d..a3d8558bb1 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use wordcraft_doc::para::cap_alt; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef, - ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline, - VAlign, VMerge, VertAlign, + ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, + TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, @@ -338,6 +338,7 @@ impl PropCtx { "w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)), "w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)), "w:bidi" => p.bidi = Some(on_off(k)), + "w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml), "w:kinsoku" => p.kinsoku = Some(on_off(k)), "w:wordWrap" => p.word_wrap = Some(on_off(k)), "w:overflowPunct" => p.overflow_punct = Some(on_off(k)), diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 3968377df4..d22a297a97 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha if let Some(a) = p.align { w.val("w:jc", align_val(a)); } + if let Some(direction) = p.text_direction { + w.val("w:textDirection", direction.ooxml()); + } if let Some(t) = p.text_alignment { w.val("w:textAlignment", t.ooxml()); } diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 9738fd3724..d9cdc374c7 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill; use wordcraft_doc::numbering::ListKind; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{ - Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, - TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, + Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps, + TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; @@ -195,6 +195,7 @@ fn every_para_prop_round_trips() { suppress_hyphens: Some(true), suppress_line_numbers: Some(true), bidi: Some(false), + text_direction: Some(ParagraphTextDirection::TbRl), drop_cap: None, drop_cap_margin: None, drop_cap_distance: None, diff --git a/crates/docx/tests/it/section_text_direction.rs b/crates/docx/tests/it/section_text_direction.rs index c17656f40b..da44593bd1 100644 --- a/crates/docx/tests/it/section_text_direction.rs +++ b/crates/docx/tests/it/section_text_direction.rs @@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec { zw.finish().unwrap().into_inner() } +fn docx_with_styles(body: &str, styles: &str) -> Vec { + let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let root = r#""#; + let document_rels = r#""#; + let doc = format!( + r#"{body}"# + ); + for (name, value) in [ + ("_rels/.rels", root.as_bytes()), + ("word/_rels/document.xml.rels", document_rels.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/styles.xml", styles.as_bytes()), + ] { + bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + bytes.write_all(value).unwrap(); + } + bytes.finish().unwrap().into_inner() +} + fn document_xml(bytes: &[u8]) -> String { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut s = String::new(); @@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() { let plain = wordcraft_docx::read(&docx(r#"Body"#)).unwrap(); assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection")); } + +#[test] +fn paragraph_text_direction_round_trips_through_two_saves_and_json() { + use wordcraft_doc::props::ParaProps; + use wordcraft_doc::props::ParagraphTextDirection; + + let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"]; + let mut body: String = + values.iter().map(|value| format!(r#"{value}"#)).collect(); + body.push_str(r#"Inherited"#); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect(); + for (para, value) in paras.iter().zip(values) { + assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}"); + } + assert_eq!(paras[values.len()].props.text_direction, None); + let json = serde_json::to_value(¶s[0].props).unwrap(); + assert_eq!(json["textDirection"], "lrTb"); + let decoded: ParaProps = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb)); + + let first = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&first); + for value in values { + assert!(xml.contains(&format!(r#""#)), "{xml}"); + } + assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}"); + let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap(); + let twice = document_xml(&second); + assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}"); + for value in values { + assert!(twice.contains(&format!(r#""#)), "{twice}"); + } +} + +#[test] +fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() { + let styles = r#""#; + let body = r#"StyleOverride"#; + let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert_eq!(xml.matches(r#""#).count(), 2, "both paragraphs keep their style: {xml}"); + assert!(xml.contains(r#""#), "direct horizontal is explicit: {xml}"); + assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap(); + let mut styles_xml = String::new(); + z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap(); + assert!(styles_xml.contains(r#""#), "style value survives: {styles_xml}"); +} From c40719f4312a82bbdf98585f62c3ff8836ae49d7 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:34:00 +0100 Subject: [PATCH 05/16] Layout: a turned inline picture with no recorded room for the turn keeps its line to its frame, as in Word (#5448) A file that turns an inline picture but writes no wp:effectExtent (or a zero one), as other tools do, makes Word keep the picture's line to its frame and let the turn overhang it, cut at the line's top and bottom: the frame as it is in Word 2007 layout (compatibilityMode < 15), with width and height exchanged from 45 to 135 and 225 to 315 degrees in Word 2013+ layout. WordCraft always reserved the exact rotated bounds (Float::spin_pad), which is what Word's own files encode through their effect extents, so the text after such a picture sat up to 34 pt too low (docxide-pdf standalone-picture-compat12-* / compat15-angle45/135). The docx reader now marks such objects (Float::bare_turn, cleared when the object is turned again); the inline room comes from Float::line_pad, which display uses too to place the frame in it, and the line clips the picture's overhang. The writer keeps writing no turn overhang for them, so a save round-trips. Word-saved pictures are unchanged. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 5 +- crates/doc/src/para/float.rs | 23 +++++++++ crates/docx/src/read/story/drawing.rs | 5 ++ crates/docx/src/write/embed.rs | 5 +- crates/docx/src/write/story/drawing.rs | 5 +- crates/docx/tests/it/fixtures.rs | 48 +++++++++++++++++++ crates/layout/src/display/lines.rs | 13 +++++- crates/layout/src/display/objects.rs | 9 ++-- crates/layout/src/floats.rs | 3 +- crates/layout/src/para/paragraph.rs | 2 +- crates/layout/src/tests.rs | 53 +++++++++++++++++++-- crates/pdf/src/exporter.rs | 65 +++++++++++++++++--------- crates/pdf/src/tags.rs | 5 +- crates/pdf/src/tests.rs | 39 ++++++++++++++++ docs/gaps.md | 5 +- docs/layout-parity.md | 5 +- 16 files changed, 247 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4f1c4d6a1..a1a0688e05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,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-11-06 · **Change:** trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. - 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged. - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. @@ -832,6 +833,8 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history +> 2026-11-08 · Progress log: turned inline pictures without a recorded effect extent keep their line to the frame, as in Word. +> > 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. > 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. > 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged. diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 86f81c94d1..1918a7e5a2 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -186,6 +186,11 @@ pub struct Float { /// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats). #[serde(skip_serializing_if = "is_zero_u32")] pub z: u32, + /// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as + /// files from other tools have): Word then keeps an inline one's line to its frame and lets + /// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`]. + #[serde(skip_serializing_if = "is_false")] + pub bare_turn: bool, } /// Longest alternative text kept (characters): hostile files and commands are capped. @@ -241,6 +246,7 @@ impl Float { self.rot = wordcraft_geom::normalize_degrees(s.deg); self.flip_h = s.flip_h; self.flip_v = s.flip_v; + self.bare_turn = false; } /// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a /// rotated object takes beyond its frame (zero unrotated; negative where the turned object @@ -250,6 +256,23 @@ impl Float { let (bw, bh) = self.spin().extent(w, h); ((bw - w) / 2.0, (bh - h) / 2.0) } + /// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room + /// for a shadow or glow since, it saves, and so lays out, with the turn's room again. + pub fn is_bare_turn(&self) -> bool { + self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4] + } + /// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its + /// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except + /// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135° + /// and from 225° up to 315°. + pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) { + if !self.is_bare_turn() { + return self.spin_pad(w, h); + } + let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0)); + let deg = wordcraft_geom::normalize_degrees(self.rot); + if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) } + } /// The effect extents, finite and clamped (left, top, right, bottom). pub fn effect_extent(&self) -> [f32; 4] { self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }) diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index 1481df8745..75435a22a7 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -41,6 +41,7 @@ impl Reader<'_> { } let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?; float.set_spin(xfrm_spin(content.child("w14:xfrm"))); + float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4]; let (px, py) = float.spin_pad(w, h); for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) { if *e != 0.0 { @@ -106,7 +107,11 @@ impl Reader<'_> { self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id"))); // Word's effect extent also covers a rotated object's overhang; the model keeps that // apart (it follows from the angle), so take it back out. + // Shadow or glow room the file didn't record: saving records it (and the turn's room). + let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]); if let Some(f) = obj.float_mut() { + // None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`). + f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4]; let (px, py) = f.spin_pad(w, h); for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) { // Zero adds no effects room when rotation contracts this axis. diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index cc13df58ee..19154a8790 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -499,8 +499,9 @@ impl super::Writer<'_> { } } "wp:effectExtent" => { - // Word's effect extent also covers a rotated object's overhang (its rotated bounds). - let (px, py) = float.spin_pad(w, h); + // Word's effect extent also covers a rotated object's overhang (its rotated bounds), + // unless the file it came from kept none. + let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) }; let [el, et, er, eb] = float.effect_extent(); let ext = [el + px, et + py, er + px, eb + py].map(emu); e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b")); diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 0edb1117ee..4d158ba349 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -298,8 +298,9 @@ impl Writer<'_> { } } w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]); - // Word's effect extent also covers a rotated object's overhang (its rotated bounds). - let (px, py) = float.spin_pad(iw, ih); + // Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless + // the file it came from kept none. + let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) }; let [el, et, er, eb] = float.effect_extent(); let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu); w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index cb892282f8..9f82b95308 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -4354,6 +4354,54 @@ fn zero_effects_stay_zero_rotated() { } } +/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is +/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps +/// its rotated bounds. +#[test] +fn bare_turns_are_kept_through_a_save() { + let pic = |effects: &str| { + format!( + r#"{effects}"# + ) + }; + let read = + |body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap(); + let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn; + for effects in ["", r#""#] { + let d = read(&pic(effects)); + assert!(bare(&d), "{effects:?}"); + let out = wordcraft_docx::write(&d).unwrap(); + let body = text(&unzip(&out), "word/document.xml"); + assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]); + assert!(bare(&wordcraft_docx::read(&out).unwrap())); + } + // 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side. + let d = read(&pic(r#""#)); + assert!(!bare(&d)); + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.set_spin(wordcraft_geom::Spin::new(30.0, false, false)); + assert!(!f.bare_turn, "turned here: the rotated bounds again"); + // A shadow's room added later: saved, and so laid out, with the turn's room again. + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.effect = [2.0; 4]; + assert!(!f.is_bare_turn()); + let out = wordcraft_docx::write(&d).unwrap(); + assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]); + // Made floating: saved with the turn's room, so it opens again as an ordinary turn. + let mut d = read(&pic("")); + let f = first_object(&mut d).float_mut().unwrap(); + f.wrap = wordcraft_doc::para::Wrap::Square; + assert!(!f.is_bare_turn()); + let out = wordcraft_docx::write(&d).unwrap(); + assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]); + assert!(!bare(&wordcraft_docx::read(&out).unwrap())); + // Only inline ones: a floating one keeps text clear of its rotated bounds as before. + let anchored = pic("").replace("", "").replace("", ""); + assert!(!bare(&read(&anchored))); +} + /// The math namespace, declared on the math element itself (the fixture root doesn't). const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#; diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index e888dedfbb..059358ad82 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -637,9 +637,20 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { } let cx = x + line.cl_left(k).unwrap_or(0.0); let obj = para.and_then(|p| p.objects.get(oi)); - let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k)); + let modern = doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT; + let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern); let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default(); match obj { + // A turn its line keeps no room for overhangs the line; Word cuts it at the line's top + // and bottom, not at its sides. + Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) + if o.frame().is_some_and(|(w, h, f)| f.is_bare_turn() && f.spin_pad(w, h).1 > f.line_pad(w, h, modern).1 + 0.01) => + { + let mut items = Vec::new(); + object_draws(o, rect, Spin::default(), alpha, opts, &mut items); + let band = Rect::new(rect.x - 4000.0, ctx.top, rect.w + 8000.0, (ctx.bottom - ctx.top).max(0.0)); + out.push(Draw::Clip { rect: band, items }); + } Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => { object_draws(o, rect, Spin::default(), alpha, opts, out) } diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index 5e0b04e6e1..89211cf41a 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -487,8 +487,9 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp /// `obj_h` above the baseline and `obj_d` below it): inside the room kept for its effects. /// A rotated one's unrotated frame, centred in that room. /// -/// `line` is the room a horizontal rule is placed in, from `cx`. -pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect { +/// `line` is the room a horizontal rule is placed in, from `cx`; `modern`: Word 2013+ layout +/// (see [`wordcraft_doc::para::Float::line_pad`]). +pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32, modern: bool) -> Rect { let frame = obj.and_then(InlineObject::frame); let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); // Lowered further than its height, it is drawn with its top on the baseline (Word). @@ -503,9 +504,9 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h); } let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; - // The room is the rotated bounds of the frame: undo the rotation. + // The room is what the line kept for the turned frame: undo that. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); - let (bw, _) = float.spin().extent(w, h); + let bw = w + 2.0 * float.line_pad(w, h, modern).0; let k = if bw > 0.0 { room.w / bw } else { 1.0 }; let (fw, fh) = (w * k, h * k); Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh) diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 5135278e14..8ef098a44a 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -751,7 +751,8 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec= wordcraft_doc::COMPAT_MODE_CURRENT; + display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern) }; let (mut behind, mut front) = (Vec::new(), Vec::new()); let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front }; diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 65aab68342..601e34bb91 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -397,7 +397,7 @@ impl<'a> Shaped<'a> { // The line makes room for effects (shadows) around the picture too, and // for a rotated one's bounds, as Word does. let [el, et, er, eb] = float.effect_extent(); - let (px, py) = float.spin_pad(w * s, h * s); + let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT); let tall = h * s + et + eb + 2.0 * py; // Lowered further than its height, its top stays on the baseline while the line // reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ef3e5368b9..38c145acca 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5593,7 +5593,7 @@ fn inline_picture_keeps_room_for_its_effects() { assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents"); // Drawn inside that room. let obj = picture(100.0, 50.0, shadow); - let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv); + let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true); assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0)); } @@ -5620,22 +5620,67 @@ fn an_inline_picture_moves_with_its_runs_position() { let (c, line, _) = line_of(-30.0); assert_eq!((c.obj_h, c.obj_d), (0.0, 30.0)); assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}"); - let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv); + let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true); assert_eq!((r.y, r.h), (100.0, 30.0)); // Lowered further than that: the line reaches as far down, the picture's top staying on the // baseline (Word). let (c, line, _) = line_of(-500.0); assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0)); assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}"); - let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv); + let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true); assert_eq!((r.y, r.h), (100.0, 30.0)); // Raised: it stands clear of the baseline, as tall as before. let (c, _, _) = line_of(6.0); assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0)); - let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv); + let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true); assert_eq!((r.y, r.h), (64.0, 30.0)); } +/// A turned inline picture whose file kept no room for the turn: Word keeps its line to the frame +/// (Word 2007 layout) or to the frame with width and height exchanged near a quarter turn (Word +/// 2013+), and the picture overhangs it. One with room recorded takes its rotated bounds. +#[test] +fn bare_turned_inline_picture_keeps_its_frame() { + let room = |rot: f32, bare_turn: bool, compat: u32| { + let mut d = Document::from_text(""); + d.settings.compat_mode = compat; + let float = wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() }; + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.insert_object(0, picture(120.0, 60.0, float), &Default::default()).unwrap(); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap(); + // Drawn unscaled, centred in that room. + let obj = picture(120.0, 60.0, wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() }); + let r = display::inline_rect(Some(&obj), 0.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, compat >= 15); + assert_eq!((r.w, r.h), (120.0, 60.0), "{rot}° bare {bare_turn} compat {compat}"); + assert_eq!((r.x + r.w / 2.0, r.y + r.h / 2.0), (c.adv / 2.0, 200.0 - c.obj_h / 2.0)); + // Only a bare turn that overhangs its line is cut, at the line's top and bottom. + let overhangs = + bare_turn && (c.obj_h as f64) < 120.0 * (rot as f64).to_radians().sin().abs() + 60.0 * (rot as f64).to_radians().cos().abs() - 0.1; + let clips: Vec = display::page_display(&d, &l.pages[0], &Default::default()) + .iter() + .filter_map(|i| if let display::Draw::Clip { rect, .. } = i { Some(*rect) } else { None }) + .collect(); + assert_eq!(clips.len(), usize::from(overhangs), "{rot}° bare {bare_turn} compat {compat}: {clips:?}"); + if let Some(band) = clips.first() { + assert!(band.h >= c.obj_h && band.w > 4000.0, "{band:?}"); + } + (c.adv.round(), c.obj_h.round()) + }; + assert_eq!(room(90.0, true, 12), (120.0, 60.0)); + assert_eq!(room(45.0, true, 12), (120.0, 60.0)); + assert_eq!(room(180.0, true, 12), (120.0, 60.0)); + assert_eq!(room(90.0, true, 15), (60.0, 120.0)); + assert_eq!(room(45.0, true, 15), (60.0, 120.0)); + assert_eq!(room(135.0, true, 15), (120.0, 60.0)); + assert_eq!(room(270.0, true, 15), (60.0, 120.0)); + // Room recorded (Word's own files, WordCraft's turns): the rotated bounds, in any layout. + assert_eq!(room(90.0, false, 12), (60.0, 120.0)); + assert_eq!(room(45.0, false, 15), (127.0, 127.0)); +} + /// Tops of the body text lines drawn on page one, in order. fn line_tops(l: &DocLayout) -> Vec { l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect() diff --git a/crates/pdf/src/exporter.rs b/crates/pdf/src/exporter.rs index 9ce20c8981..67737b3394 100644 --- a/crates/pdf/src/exporter.rs +++ b/crates/pdf/src/exporter.rs @@ -166,6 +166,15 @@ impl Exporter<'_> { } s.pop(); } + // A turned picture cut at its line's top and bottom. + Draw::Clip { rect, items } => { + let Some(clip) = rect_path(rect) else { return }; + s.push_clip_path(&clip, &FillRule::NonZero); + for it in items { + self.rotated_pictures(s, it); + } + s.pop(); + } Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| { @@ -177,6 +186,40 @@ impl Exporter<'_> { } } + /// One draw of a paragraph's line, tagged for what it is: text as the paragraph's content + /// (structure entry `idx`), pictures and figures on their own, anything else an artifact. + fn line_item(&mut self, s: &mut Surface, d: &Draw, idx: usize) { + match d { + Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // A picture marked decorative: an artifact screen readers skip. + Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), + // A rotated inline picture, or a rotated group holding pictures: turned as a + // whole, each picture tagged on its own (see `rotated_pictures`). + Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), + // A clip (a turned picture cut at its line, an exact-height cell's lines, a canvas's + // members): each draw in it tagged as it would be outside it. + Draw::Clip { rect, items } => { + let Some(clip) = rect_path(rect) else { return }; + s.push_clip_path(&clip, &FillRule::NonZero); + for it in items { + self.line_item(s, it, idx); + } + s.pop(); + } + // A turned text box's line: still the paragraph's text. A rotated shape + // without text falls to the artifacts below. + Draw::Rotated { items, .. } if items.iter().any(holds_text) => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. + Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { + for d in inner { + me.draw(s, d); + } + }), + _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + } + } + pub(crate) fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) { if let Some(c) = self.doc.settings.page_color && let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h)) @@ -221,27 +264,7 @@ impl Exporter<'_> { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); for d in &draws { - match d { - Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), - // A picture marked decorative: an artifact screen readers skip. - Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), - // A rotated inline picture, or a rotated group holding pictures: turned as a - // whole, each picture tagged on its own (see `rotated_pictures`). - Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), - // A turned text box's line: still the paragraph's text. A rotated shape - // without text falls to the artifacts below. - Draw::Rotated { items, .. } if items.iter().any(holds_text) => { - self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)) - } - // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. - Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { - for d in inner { - me.draw(s, d); - } - }), - _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - } + self.line_item(s, d, idx); } } Placed::Image { story, path, off, .. } => { diff --git a/crates/pdf/src/tags.rs b/crates/pdf/src/tags.rs index c8a7254fe4..b9ca50cd6c 100644 --- a/crates/pdf/src/tags.rs +++ b/crates/pdf/src/tags.rs @@ -5,11 +5,12 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } -/// A picture, or a rotation holding one at any depth (a rotated group's turned members). +/// A picture, or a rotation or clip holding one at any depth (a rotated group's turned members, a +/// turned picture cut at its line). pub(crate) fn holds_picture(d: &Draw) -> bool { match d { Draw::Image { .. } => true, - Draw::Rotated { items, .. } => items.iter().any(holds_picture), + Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture), _ => false, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5a23040367..c155cddee9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1190,6 +1190,8 @@ fn decorative_pictures_are_artifacts_not_figures() { let cases = [ ("inline", Float::default()), ("rotated inline", Float { rot: 30.0, ..Default::default() }), + // Turned with no room kept for it: drawn cut at its line (a clip), still tagged. + ("bare turned inline", Float { rot: 45.0, bare_turn: true, ..Default::default() }), ("floating", floating.clone()), ("rotated floating", Float { rot: 30.0, ..floating }), ]; @@ -1202,6 +1204,43 @@ fn decorative_pictures_are_artifacts_not_figures() { } } +/// A row of exact height cuts its cell's lines (one clip around them): text and a picture in +/// it are tagged as they are in a row that fits, the text as the paragraph's, the picture as a +/// Figure with its alt text. +#[test] +fn a_clipped_cell_tags_its_text_and_picture() { + let raw = |exact: bool| { + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let mut p = Paragraph::with_text("Cell text ", CharProps::default()); + let n = p.len(); + let obj = InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: "A red square".into(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + fx: Default::default(), + }; + p.insert_object(n, obj, &CharProps::default()).unwrap(); + let mut t = Table::new(1, 1, 300.0); + t.rows[0].cells[0].blocks = vec![para_block(p)]; + if exact { + t.rows[0].props.height = Some(30.0); + t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact; + } + d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))]; + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() + }; + let (fits, cut) = (raw(false), raw(true)); + assert!(cut.contains("/Alt(A red square)"), "the picture in the cut cell is a Figure with its alt text"); + // The same marked content either way: the cut cell's text is not dropped as an artifact. + assert_eq!(cut.matches("/MCID").count(), fits.matches("/MCID").count()); + assert_eq!(cut.matches("/S/P").count(), fits.matches("/S/P").count()); +} + /// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the /// other stays a Figure with its alt text. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 3526d9c0ca..f1f5c6ff34 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,7 +2,7 @@ > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: View windows: several open documents and Switch Windows in the engine; 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 (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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-11-08 · **Change:** trivial (#10 narrowed: a turned inline picture whose file keeps no room for the turn keeps its line to its frame, as in Word); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: View windows: several open documents and Switch Windows in the engine; 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 (gap 4: a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (Drawing Canvas members picked and moved on their own, #2637); previously trivial (gap 4: a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (#29: typed `\int`, `\sum`… + Space wait for their limits, observed in Word for Windows on #4938, #4917); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (gap 6: a print job waiting on its driver can be cancelled, #5710; no ranked gap changed); previously trivial (gap 6: the printer list no longer waits on printer drivers, #5710; no ranked gap changed); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; 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 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. @@ -91,7 +91,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | -| 10 | **Objects**: editing text in a freely rotated text box (its text is drawn turned; free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), picking several Drawing Canvas members at once and dragging one out of its canvas (the canvas landed, #344; its members are picked, moved, resized and formatted on their own, #2637) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: editing text in a freely rotated text box (its text is drawn turned; free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), picking several Drawing Canvas members at once and dragging one out of its canvas (the canvas landed, #344; its members are picked, moved, resized and formatted on their own, #2637); a turned inline picture whose file keeps no room for the turn keeps its line to its frame and is cut at the line, as in Word (landed) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); its dialogs, tables and galleries read right to left too; open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | @@ -191,6 +191,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9b61aa351f..2a5694e94e 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-11-06 · **Change:** trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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 (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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 (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (a turned inline picture whose file keeps no room for the turn keeps its line to its frame, the frame turned a quarter in Word 2013+ layout); previously trivial (a picture lowered further than its own height lowers its line as far, its top on the baseline); previously trivial (a float anchored right after a page break, before text, is drawn on the new page, #4262); previously trivial (a float anchored on a later page of a long paragraph is drawn there, #4250); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a line of only pictures is as tall as they reach, never less than its font's height; a legacy check box sizes its line as text of its size, #3984); previously trivial (spaces before a line's first character keep their width in justified and distributed lines, as in Word); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (Times and Courier take their Windows counterparts' line spacing; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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 (a click just outside a floating table, by a cell whose text is turned, goes to the text beside or after it, #4181; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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 (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; 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, @@ -76,7 +76,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | 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; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); 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); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | -| 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 turns with it (on screen, PNG and PDF; upside down when the box is flipped across, never mirrored, as Word draws it); clicks and the caret in it still go by its upright text | 1–2 | +| 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 — unless the file keeps no room for the turn (no or a zero `wp:effectExtent`): then an inline one's line keeps to its frame, exchanged in width and height from 45° to 135° and 225° to 315° in Word 2013+ layout, and the picture overhangs it, cut at the line's top and bottom, as in Word; hit testing on the turned shape. Text in a rotated text box turns with it (on screen, PNG and PDF; upside down when the box is flipped across, never mirrored, as Word draws it); clicks and the caret in it still go by its upright text | 1–2 | | Connector lookup cost | 🟡 | One temporary layout-object index per reroute replaces repeated page scans. Repeated positions retain page-hint preference, first-match order and first-page fallback; same-page glue and free/unresolved endpoints are unchanged. Synthetic lookup equivalence, geometry and 8,000-page scaling regressions; model object access and obstacle routing remain separate, no new native Word comparison | #3631 | | 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 | ✅ | | — | @@ -300,6 +300,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word | | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | From 7152143e456655c25ae827171fcd929d02393bd9 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:36:10 +0100 Subject: [PATCH 06/16] Layout: a line can break after a hyphen before a slash, like Word (#3408) (#5348) Word wraps "Dataprotection-/Secrecyviolation" as "Dataprotection-" / "/Secrecyviolation". UAX #14 (LB13) forbids a break before the slash, so the compound had no break opportunity: lines moved it whole and auto-fit table columns widened to fit it, taking width from their neighbours. Add the break after U+002D when a slash and a letter follow, next to the existing break before a digit (#2712); Word keeps "fitness-/1589" whole, so a slash before a digit gains no break. Always sort the added breaks. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/para/paragraph.rs | 24 ++++++++----- crates/layout/src/tests.rs | 55 +++++++++++++++++++++++++++++ docs/gaps.md | 2 ++ docs/layout-parity.md | 2 ++ 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a1a0688e05..5775bb57cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -239,6 +239,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. +- 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. @@ -1346,3 +1347,4 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-10-29 · Port #3915 to split layout modules: ordinary LTR tab groups follow paragraph alignment. Positional tabs, lists, RTL and display math retain their alignment paths. Source port only; no test execution or new native measurements. > 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements. +| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) | diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 601e34bb91..f844e541ab 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -837,21 +837,27 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec } // Word breaks after a hyphen even when a digit follows ("2016-" / "2022"), where UAX #14 - // keeps a hyphen and a number together (LB25): add those positions back. Only U+002D before - // ASCII digits is added here; a non-breaking hyphen (U+2011) and a minus sign (U+2212) stay - // unbreakable, and the other dashes keep their ordinary UAX #14 breaks. + // keeps a hyphen and a number together (LB25), and when a slash and a letter follow + // ("Dataprotection-" / "/Secrecyviolation", #3408), where LB13 forbids a break before the + // slash: add those positions back. Word keeps "fitness-/1589" whole, so a slash before a digit + // gains no break. Only U+002D is added here; a non-breaking hyphen (U+2011) and a minus sign + // (U+2212) stay unbreakable, and the other dashes keep their ordinary UAX #14 breaks. let hyphen_breaks: Vec = text .match_indices('-') .filter_map(|(pos, _)| { let i = pos + 1; - text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i) + let mut next = text.get(i..).unwrap_or_default().chars(); + match next.next() { + Some(n) if n.is_ascii_digit() => Some(i), + Some('/') => next.next().is_some_and(char::is_alphabetic).then_some(i), + _ => None, + } }) .collect(); - if !hyphen_breaks.is_empty() { - found.extend(hyphen_breaks); - found.sort_unstable(); - found.dedup(); - } + found.extend(hyphen_breaks); + // The breaks added above come after UAX #14's: put them back in order. + found.sort_unstable(); + found.dedup(); // Myanmar has no spaces between words: it wraps between syllables. if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) { found.extend(wordcraft_doc::cluster::myanmar_breaks(text)); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 38c145acca..cc822cb1c0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13550,6 +13550,61 @@ fn a_non_breaking_hyphen_and_a_minus_sign_stay_unbreakable() { } } +/// #3408: Word breaks a compound right after a hyphen that a slash follows ("Dataprotection-" / +/// "/Secrecyviolation"), where UAX #14 forbids a break before the slash. The first width that +/// wraps must break after the hyphen, not move the compound whole, and the slash stays with its +/// word. Table auto-fit reads the same break, so such a column no longer widens to the compound. +/// Word keeps "fitness-/1589" whole: a slash before a digit gains no break. +#[test] +fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() { + let digits = "Deed fitness-/1589"; + let mut wrapped = false; + for m in 100..=540 { + let mut d = Document::from_text(digits); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec<&str> = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, l0, l1, .. } = it { Some(¶.lines[*l0..*l1]) } else { None }) + .flatten() + .map(|line| &digits[line.start..line.stop]) + .collect(); + if lines.len() >= 2 { + assert_eq!(lines, ["Deed ", "fitness-/1589"], "the first wrap breaks at the space, not after the hyphen"); + wrapped = true; + break; + } + } + assert!(wrapped, "no width in the sweep wrapped the digit case"); + + let text = "Deed Dataprotection-/Secrecyviolation"; + for m in 100..=540 { + let mut d = Document::from_text(text); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| { + if let Placed::Lines { para, l0, l1, .. } = it { + Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::>()) + } else { + None + } + }) + .flatten() + .collect(); + if lines.len() < 2 { + continue; // still fits on one line at this width + } + assert_eq!(lines[0], "Deed Dataprotection-", "the first wrap breaks after the hyphen: {lines:?}"); + assert_eq!(lines[1], "/Secrecyviolation", "the slash starts the next line with its word: {lines:?}"); + return; + } + panic!("no width in the sweep wrapped the paragraph"); +} + #[test] fn layout_cache_invalidates_on_changed_filename() { let mut doc = Document::new(); diff --git a/docs/gaps.md b/docs/gaps.md index f1f5c6ff34..7527aa05d3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -85,6 +85,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | In-line pictures raised or lowered (`w:position`) set their line's height as in Word, past their own height too (#3984, #5796). SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). 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) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -232,6 +233,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | | 2026-11-03 | trivial | #8 (CP4.3): File › Options › Language lists Your languages and Add a language… (searchable, sorted by name) replaces the Language packs table; the Dictionaries folder and the Dictionary files list moved from Proofing | | 2026-10-28 | trivial | #4: fonts the document uses that aren't installed are flagged in the font box and status bar (metric-compatible substitutes and symbol fonts aren't); a missing font is drawn in an installed metric twin before other substitutes (Arial for Helvetica on Windows), so pages break as in the document's own font wherever such a twin is installed | +| 2026-11-03 | trivial | #4: a hyphen before a slash and a letter is a line break opportunity, a hyphen before a slash and a digit stays whole (#3408) | | 2026-11-03 | trivial | #8: language-packs-2 published and pinned, 75 packs; Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto available as Language packs | | 2026-11-02 | trivial | #8 (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available as Language packs; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded; no ranked gap changed | | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2a5694e94e..4885093b76 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -25,6 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference)) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — | @@ -320,6 +321,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded | | 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before | | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | +| 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From b9936609dc1d8a47f92f655ff54d032e9d3983e4 Mon Sep 17 00:00:00 2001 From: pakiat <49967840+pakiat@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:38:32 +0700 Subject: [PATCH 07/16] Output counts locked SEQ fields, and not tracked-deleted ones, as Word does (#5594) * Count locked SEQ fields and not tracked-deleted ones in output Exporting, Word counts a locked SEQ, its \r too, though the field keeps its result, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows). Output counted it the other way round: a locked SEQ was skipped and a deleted one counted. Co-Authored-By: Claude Opus 5.5 * Docs: locked and tracked-deleted SEQ fields in layout parity Co-Authored-By: Claude Opus 5.5 * count_seq walks the deleted ranges with one cursor The runs come in order and the objects by offset, so a forward cursor replaces the per-field scan of every deleted range. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- crates/engine/src/cmd/references/fields.rs | 17 +++++++--- crates/engine/src/cmd/references/tests.rs | 39 ++++++++++++++++++++++ docs/layout-parity.md | 3 +- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/references/fields.rs b/crates/engine/src/cmd/references/fields.rs index 238cf553b3..86d81ce49e 100644 --- a/crates/engine/src/cmd/references/fields.rs +++ b/crates/engine/src/cmd/references/fields.rs @@ -102,7 +102,9 @@ struct SeqCount { /// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there /// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after -/// the anchor in its paragraph and after those before it (measured in Word for Windows). +/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked +/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its +/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting). fn seq_results(doc: &wordcraft_doc::Document) -> Vec { let mut count = SeqCount::default(); for path in doc.para_paths(StoryRef::Body) { @@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec { /// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it. fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) { - for (k, o) in p.objects.iter().enumerate() { + // The runs come in order and the objects by offset, so one cursor walks the deleted ranges. + let mut deleted = p.deleted_ranges().into_iter().peekable(); + for (k, (off, o)) in p.objects_with_offsets().enumerate() { + while deleted.next_if(|r| r.end <= off).is_some() {} + let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off)); for id in o.text_boxes() { if count.boxes.insert(id) { let part = StoryRef::Part(id); @@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa } } } - let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let InlineObject::Field { instr, locked, .. } = o else { continue }; let f = FieldCode::parse(instr); - if f.name != "SEQ" { + if f.name != "SEQ" || in_deletion { continue; } let Some(id) = f.args.first() else { continue }; @@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa } else if !f.has('c') { n.0 = n.0.saturating_add(1); } + if *locked { + continue; + } let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default(); let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }; count.out.push((story, path.clone(), k, r)); diff --git a/crates/engine/src/cmd/references/tests.rs b/crates/engine/src/cmd/references/tests.rs index 63b624f387..b2838cd9f9 100644 --- a/crates/engine/src/cmd/references/tests.rs +++ b/crates/engine/src/cmd/references/tests.rs @@ -793,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() { assert_eq!(results, expected); } +/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ +/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows, +/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6). +#[test] +fn output_counts_locked_seq_fields_and_not_deleted_ones() { + let mut s = Session::new(wordcraft_doc::Document::from_text("")); + let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"]; + for instr in fields_in { + s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap(); + s.run("text.insert", &json!({"text": " "})).unwrap(); + } + let top = wordcraft_doc::Path::top(0); + let para = s.doc.para_mut(StoryRef::Body, &top).unwrap(); + for (k, o) in para.objects.iter_mut().enumerate() { + if let InlineObject::Field { result, locked, .. } = o { + *locked = matches!(k, 4 | 6); + *result = if *locked { "7" } else { "9" }.into(); + } + } + para.touch(); + // The second Del field is a tracked deletion. + let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1]; + let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off }; + s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap(); + let out = output_fields(&s.doc).expect("the stale results change"); + let shown: Vec = out + .para(StoryRef::Body, &top) + .unwrap() + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result.clone()), + _ => None, + }) + .collect(); + assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]); + assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results"); +} + /// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error /// (measured in Word for Windows: "Error! Reference source not found."); the editor and the /// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4885093b76..9f0561083b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -95,7 +95,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | -| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`) | — | +| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`, `cmd::references::tests::output_counts_locked_seq_fields_and_not_deleted_ones`) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Fields: `\* DollarText`, `\* Hex` and `\* ArabicDash` | 🟡 | `\* DollarText` writes lowercase cardinal text, "and", and the first two decimals over 100 (`{ = 9.20 + 5.35 \* DollarText }` shows "fourteen and 55/100"), `\* Hex` uppercase hexadecimal digits (458 shows "1CA"), and `\* ArabicDash` the number between hyphens (`{ PAGE \* ArabicDash }` shows "- 31 -"), in table formulas and in the page-number, SEQ, PAGEREF, NUMWORDS, NUMCHARS and REVNUM fields; a capitalization switch applies after them (#4226). A result past 999,999 in cardinal or ordinal text, or past 999,999.99 in dollar text, keeps its digits, and so does a negative result or a fractional Hex or ArabicDash result. `layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`, `engine::cmd::table_formula::tests::dollar_text_and_hex_switches_write_the_result`, `engine::tests::table_formula_results_take_dollar_text_and_hex`; not compared with Word | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -314,6 +314,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-31 | trivial | A floating object anchored right after a page break inside a paragraph, before text, is drawn on the new page, as in Word; only a cover page's anchors, with nothing taking room after them, stay on the page before (#4262) | | 2026-10-31 | trivial | On a later page of a long paragraph, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page; one whose own wrap pushes its line on stays with the text wrapping around it (#4250, `layout::tests::a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page` and the two after it) | | 2026-10-31 | trivial | A floating object anchored in a line of a long paragraph on a later page is drawn on that page, positioned from where the paragraph continues, as in Word; a shape dropped on such a page stays there (#4250) | +| 2026-11-04 | trivial | Output counts a locked SEQ and not one in a tracked deletion, as Word does; readiness estimates unchanged | | 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box | | 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged | | 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged | From d3fcf10bcc4ec8fc2d48258d991774eab95e1eb3 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 04:48:48 +0100 Subject: [PATCH 08/16] Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) (#5347) * Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) A paragraph with a hidden mark and no plain text was a quiet mark: laid out with no room, its drawing kept. An in-line picture adds no plain text, so a hidden-mark paragraph holding only a picture qualified, and the text after it (the next continuous section in the report) was drawn over the picture. Such a paragraph, or one holding a note reference mark, now keeps its line; one with only floating objects stays quiet. Co-Authored-By: Claude Opus 5.5 * Layout: a legacy check box in a hidden closing mark keeps its room too (#5288) A FORMCHECKBOX field has an empty result, so a hidden-mark paragraph holding only a check box still counted as quiet and the next continuous section was drawn over the box. Fields now count as visible content when the paragraph layout shows them as something other than their result: their code, a legacy check box, or a drop-down's entry (the same test as the layout's `field`). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/pass.rs | 36 ++++++++++++++----- crates/layout/src/tests.rs | 73 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5775bb57cf..7bff0e3c34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. - 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408) +- 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288) - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. - 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. @@ -907,6 +908,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) | | 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | +| 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index 2f0513019a..fa09783f3d 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use super::cache::{LayoutCache, Totals, env_hash, hash_of}; use super::ctx::{Ctx, Pass, with_pct_base}; -use super::floats::{EarlyFloats, WrapArea, early_covered}; +use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects}; use super::flow::{OutlinePass, place_outline_para, place_para}; use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side}; use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes}; @@ -10,9 +10,10 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom}; use super::placed::Placed; use super::table_flow::place_table; use super::{ - Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection, - ViewMode, balloons, para, vertical, + Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart, + StoryRef, TextDirection, ViewMode, balloons, para, vertical, }; +use wordcraft_doc::legacy_form::LegacyForm; /// How the view lays the document out: `web` for the continuous views, `reflow` for those and /// the reading view, which both ignore the sections' page geometry. @@ -127,18 +128,35 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty()) }); if !opts.show_hidden { - quiet.extend( - doc.body - .iter() - .enumerate() - .filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)), - ); + quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| { + b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i) + })); } quiet.sort_unstable(); quiet.dedup(); (stacked, quiet) } +/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other +/// than its result, is visible content with no plain text, so its line keeps its room even when +/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one +/// in hidden or deleted text). +fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool { + let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body)); + // As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry. + let shown_field = |o: &InlineObject| match o { + InlineObject::Field { code, .. } => { + *code != opts.field_codes + || o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty())) + } + _ => false, + }; + p.objects + .iter() + .enumerate() + .any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o))) +} + /// The page the continuous views lay out on: the first section's, as wide as the window (and as /// high in the reading view), with small margins and one column. fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc822cb1c0..29114735d0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -10330,6 +10330,79 @@ fn an_empty_continuous_section_mark_takes_no_room() { assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}"); } +/// A hidden mark closing a section before a continuous one, holding only an in-line picture: +/// the picture keeps its line, so the next section starts below it, not over it (#5288). A +/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden +/// text. +#[test] +fn a_hidden_mark_with_an_inline_picture_keeps_its_room() { + use wordcraft_doc::para::{Float, ShapeKind}; + use wordcraft_doc::section::{SectionProps, SectionStart}; + // Where Pb starts, and the bottom of the closing mark's picture (none: no picture). + let tops = |float: Option, run: CharProps| { + let mut d = Document::from_text("Pa\n\nPb"); + let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + closer.mark.hidden = Some(true); + closer.section = Some(Box::new(SectionProps::default())); + d.last_section.start = SectionStart::Continuous; + if let Some(float) = float { + let shape = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + effects: Default::default(), + freeform: None, + extra: Default::default(), + }; + d.insert_object(&Pos::body(1, 0), shape, &run).unwrap(); + } + let l = lay(&d); + let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom()); + (l.caret(&Pos::body(2, 0)).unwrap(), bottom) + }; + let wrapped = |wrap| Some(Float { wrap, ..Default::default() }); + let (empty, _) = tops(None, CharProps::default()); + let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default()); + let bottom = bottom.unwrap(); + assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}"); + assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}"); + assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}"); + let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default()); + assert!(drawn.is_some(), "the floating picture is drawn"); + assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}"); + let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default()); + assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}"); + let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() }); + assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}"); +} + +/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result): +/// the box is drawn with a line's room, so the next section starts below it (#5288). +#[test] +fn a_hidden_mark_with_a_check_box_keeps_its_room() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let top = |boxed: bool| { + let mut d = Document::from_text("Pa\n\nPb"); + let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + closer.mark.hidden = Some(true); + closer.section = Some(Box::new(SectionProps::default())); + d.last_section.start = SectionStart::Continuous; + if boxed { + let form = r#""#.to_string(); + let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form }; + d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap(); + } + lay(&d).caret(&Pos::body(2, 0)).unwrap() + }; + let (empty, boxed) = (top(false), top(true)); + assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}"); +} + /// Empty marks closing continuous sections one after another each keep their line while it /// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text /// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End": diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9f0561083b..f1833ce0b6 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -91,7 +91,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326); an n-ary operator, delimiter, accent or group character the math font lacks is drawn from a fallback font instead of a missing-glyph box (#1273); an equation-array row without `&` is centred among aligned rows, as in Word (#2617) | — | | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | -| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - | +| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. Not observed in Word: when such a paragraph closes a section before a continuous one and holds only an in-line picture (or a note reference, or a legacy check box), the picture keeps its room and the next section starts below it instead of over it (`a_hidden_mark_with_an_inline_picture_keeps_its_room`, #5288). A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | @@ -323,6 +323,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before | | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | +| 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From efd476cbc71365976070fb53797870fa0b066fac Mon Sep 17 00:00:00 2001 From: RatkoR Date: Sun, 8 Nov 2026 04:51:18 +0100 Subject: [PATCH 09/16] #5197: the ruler shows the spaces between text columns (#5261) In a section of several text columns the horizontal ruler was one white strip from margin to margin. The space between each two columns is now shaded like the margins, so the ruler shows where each column ends and the next starts. The edges of a space can be dragged, through layout.columns, one undo step per drag, to change the spacing. Equal columns stay equal, so every space changes with it, as in Word for Mac. Between unequal columns only the column at that edge and the space change. As in Word for Mac, the middle of a space has no grip of its own. Columns stay at least a quarter inch wide and spaces 0-300 pt, as the Columns dialog allows, snapped to 1/16 inch. Right-to-left sections are read from the right edge. Outside the main text, outside Print Layout and in a table the ruler is unchanged. A selection across sections shows no spaces, since a drag would set the columns of each section it touches; nor does a page the caret isn't on. Columns from a file that the command wouldn't take back (over 12, a column under a quarter inch, a space over 300 pt, wider than the text) are shaded but not dragged. A tab stop's marker keeps its own drag where it sits on an edge. The indent markers still follow the page rather than the caret's column; that is #2196 (PR #2215). Fixes #5197. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/column_ruler.rs | 343 ++++++++++++++++++++++ crates/ui-egui/src/canvas/rulers.rs | 10 + docs/gaps.md | 2 + docs/ui-parity.md | 4 +- 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/canvas/column_ruler.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7bff0e3c34..3e49fab3a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -365,6 +365,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | | 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) | | 2026-11-01 | Markdown export: a list inside a block quote stays in the quote (`> - item`), its continuation lines included; it was read as quoted list paragraphs but written without `>`, so the list moved out of the quote on every save | +| 2026-11-01 | Rulers: in a section of several text columns the horizontal ruler shades the space between each two columns like the margins (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge of a space changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) | | 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | @@ -944,6 +945,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. | | 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged | | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | +| 2026-11-01 | trivial | Progress log: the ruler shows the spaces between text columns (#5197) | | 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c3ed1a1db9..92aa25890b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,7 @@ use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; mod balloons; +mod column_ruler; mod context_menu; #[cfg(test)] pub(crate) use context_menu::issue_heading; diff --git a/crates/ui-egui/src/canvas/column_ruler.rs b/crates/ui-egui/src/canvas/column_ruler.rs new file mode 100644 index 0000000000..76e35a2636 --- /dev/null +++ b/crates/ui-egui/src/canvas/column_ruler.rs @@ -0,0 +1,343 @@ +//! The caret section's text columns on the horizontal ruler (#5197): the space between each two +//! columns is shaded like the margins, and its edges are dragged to change the columns' widths +//! and spacing, through `layout.columns`. As in Word for Mac, the space has no grip of its own +//! in the middle: only its edges drag. + +use serde_json::Value; +use wordcraft_doc::section::SectionProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{DocLayout, Rect, Sense, Tokens, Ui, WordApp, json, pos2}; + +/// Which edge of the space between two columns a drag grabs, in reading order: the end of the +/// column before it or the start of the column after it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum GapGrip { + End, + Start, +} + +/// The narrowest column a drag leaves: a quarter inch, as the Columns dialog allows. +const MIN_COLUMN: f32 = 18.0; + +/// The widest space a drag leaves between two columns, as the Columns dialog allows. +const MAX_SPACE: f32 = 300.0; + +/// The caret section's columns on a page, for the ruler. +pub(super) struct ColumnGaps { + /// The caret's section. + sect: SectionProps, + /// The page x of the section's text area's left edge. + origin: f32, + /// Each space between two neighbouring columns in reading order: its (left, right) page x. + gaps: Vec<(f32, f32)>, +} + +/// The spaces between the caret section's columns on page `pi`: none outside the main text or +/// Print Layout (where text runs in one column), in a table (whose own markers take the ruler), +/// in a section of one column, for a selection across sections (a drag would set the columns of +/// each) or when the caret isn't on that page (the ruler stays put while the whole document is +/// selected). +pub(super) fn column_gaps(app: &WordApp, layout: &DocLayout, pi: usize) -> Option { + let sel = &app.session.sel; + if sel.focus.story != StoryRef::Body || sel.anchor.story != StoryRef::Body { + return None; + } + if app.session.view.mode != wordcraft_layout::ViewMode::Print || app.session.view.read_mode { + return None; + } + if sel.focus.path.cell().is_some() || crate::table_ruler::text_area(app, layout, pi).is_some() { + return None; + } + let doc = &app.session.doc; + let section = |p: &Pos| doc.section_index_of(p.path.0.first().copied().unwrap_or(0) as usize); + if section(&sel.anchor) != section(&sel.focus) { + return None; + } + if layout.caret_on(&sel.focus, pi).map(|c| c.page) != Some(pi) { + return None; + } + let sect = wordcraft_engine::cmd::page::sect(&app.session); + if sect.columns.count <= 1 { + return None; + } + let page = layout.pages.get(pi)?; + // A continuous section can change the margins on the page: its columns start at its own + // margin, moved as the page's text area is (on an even page of a mirrored layout, #2914). + let sections = app.session.doc.sections(); + let (_, page_sect) = sections.get(page.section)?; + let origin = page.body.x + sect.margin_left + sect.gutter - page_sect.margin_left - page_sect.gutter; + let gaps = gaps_of(§).into_iter().map(|(l, r)| (origin + l, origin + r)).collect(); + Some(ColumnGaps { sect, origin, gaps }) +} + +/// Each space between two neighbouring columns of `s` in reading order, as (left, right) points +/// from the text area's left edge. +fn gaps_of(s: &SectionProps) -> Vec<(f32, f32)> { + s.column_boxes() + .windows(2) + .filter_map(|w| match w { + // Leftwards in a right-to-left section. + [(ax, aw), (bx, bw)] => Some(if bx >= ax { (ax + aw, *bx) } else { (bx + bw, *ax) }), + _ => None, + }) + .collect() +} + +/// Shades the spaces between the columns on the ruler `bar`, over the white text area. +pub(super) fn draw(painter: &egui::Painter, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, t: &Tokens) { + for (l, r) in &cg.gaps { + let (sl, sr) = (x0 + l * scale, x0 + r * scale); + if sr > sl { + painter.rect_filled(Rect::from_min_max(pos2(sl, bar.min.y), pos2(sr, bar.max.y)), 0.0, t.ruler_margin); + } + } +} + +/// The drag zones of the spaces between the columns: each of their edges, none over a tab +/// stop's marker (`tabs`), which keeps its own drag. Registered after the tab stops (whose +/// set-a-stop zone spans the text area) and before the indent markers, which keep the pointer +/// where they overlap. Columns the command wouldn't take back get no zones. +pub(super) fn handles(app: &mut WordApp, ui: &Ui, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, tabs: &[Rect]) { + if !draggable(&cg.sect) { + return; + } + let rtl = cg.sect.rtl; + for (gap, (l, r)) in cg.gaps.iter().enumerate() { + let (sl, sr) = (x0 + l * scale, x0 + r * scale); + // Left to right, the space's left edge ends the column before it; right to left, it + // starts the column after it. + let (left, right) = if rtl { (GapGrip::Start, GapGrip::End) } else { (GapGrip::End, GapGrip::Start) }; + // A narrow space is split at its middle, so both edges can be grabbed. + let mid = (sl + sr) / 2.0; + let zones = [ + (left, Rect::from_min_max(pos2(sl - 3.0, bar.min.y), pos2((sl + 3.0).min(mid), bar.max.y))), + (right, Rect::from_min_max(pos2((sr - 3.0).max(mid), bar.min.y), pos2(sr + 3.0, bar.max.y))), + ]; + for (grip, zone) in zones { + if tabs.iter().any(|t| t.intersects(zone)) { + continue; + } + let r = + ui.interact(zone, ui.scope_id().with(("ruler_column", gap, grip)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let r = if r.dragged() { r } else { r.on_hover_text(tl!("Column Edge")) }; + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let v = (pp.x - x0) / scale - cg.origin; + let at = if rtl { cg.sect.text_width() - v } else { v }; + let Some(params) = column_drag(&cg.sect, gap, grip, at) else { continue }; + if !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run_keep_view("layout.columns", params); + } + } + } +} + +/// The `layout.columns` parameters after dragging `grip` of the space after column `gap` (in +/// reading order) to `at` points from the text area's start edge (its right edge in a +/// right-to-left section). Equal columns stay equal, so every space changes, as in Word for Mac; +/// between unequal columns only the column at that edge and the space change. Columns stay at +/// least a quarter inch wide and the space 0–300 pt, snapped to 1/16 inch. `None` when there is +/// no such space or `at` is junk. +pub(crate) fn column_drag(s: &SectionProps, gap: usize, grip: GapGrip, at: f32) -> Option { + let n = s.columns.count as usize; + if !at.is_finite() || !draggable(s) || gap + 1 >= n { + return None; + } + let tw = s.text_width(); + let sep = s.columns.separator; + if s.columns.widths.len() != n { + // Column k is w wide and starts at k·(w + space), w = (tw − (n − 1)·space) / n: solved + // for the space that puts the edge at `at`. + let (i, nf) = (gap as f32, n as f32); + let space = match grip { + GapGrip::End => ((i + 1.0) * tw - at * nf) / (nf - 1.0 - i), + _ => at * nf / (i + 1.0) - tw, + }; + let most = ((tw - MIN_COLUMN * nf) / (nf - 1.0)).clamp(0.0, MAX_SPACE); + // Snapped, and at the limit the last 1/16 inch below it. + let space = (space / 4.5).round() * 4.5; + let space = if space > most { (most / 4.5).floor() * 4.5 } else { space.max(0.0) }; + return Some(json!({"count": n, "space": space, "separator": sep, "apply": "section"})); + } + // Unequal columns: (width, space after) each. + let mut cols = s.columns.widths.clone(); + let start: f32 = cols.iter().take(gap).map(|(w, sp)| w + sp).sum(); + let (&(w0, sp), &(w1, sp1)) = (cols.get(gap)?, cols.get(gap + 1)?); + let at = (at / 4.5).round() * 4.5; + let (end0, start1, end1) = (start + w0, start + w0 + sp, start + w0 + sp + w1); + let (w0, sp, w1) = match grip { + GapGrip::End => { + // `max` and `min` rather than `clamp`, which panics on bounds from a hostile file. + let e = at.min(start1.max(start + MIN_COLUMN)).max((start + MIN_COLUMN).max(start1 - MAX_SPACE)); + (e - start, start1 - e, w1) + } + GapGrip::Start => { + let b = at.min((end1 - MIN_COLUMN).min(end0 + MAX_SPACE)).max(end0); + (w0, b - end0, end1 - b) + } + }; + if let Some(c) = cols.get_mut(gap) { + *c = (w0, sp); + } + if let Some(c) = cols.get_mut(gap + 1) { + *c = (w1, sp1); + } + Some(json!({"widths": cols.iter().map(|(w, sp)| [*w, *sp]).collect::>(), "separator": sep, "apply": "section"})) +} + +/// Whether `layout.columns` takes the section's columns back after a drag: 2–12 columns, unequal +/// ones each at least a quarter inch wide with 0–300 pt after it and no wider together than the +/// text. A file can hold others (up to 45 columns, any widths); their spaces are shown, not +/// dragged. +fn draggable(s: &SectionProps) -> bool { + let n = s.columns.count as usize; + let widths = &s.columns.widths; + if !(2..=12).contains(&n) { + return false; + } + if widths.len() != n { + return true; + } + // The last column has no space after it (the command drops one). + let space = |i: usize, sp: f32| if i + 1 == n { 0.0 } else { sp }; + let total: f32 = widths.iter().enumerate().map(|(i, (w, sp))| w + space(i, *sp)).sum(); + total <= s.text_width() + 1.0 && widths.iter().enumerate().all(|(i, (w, sp))| *w >= MIN_COLUMN && (0.0..=MAX_SPACE).contains(&space(i, *sp))) +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::section::Columns; + + /// Letter paper with inch margins: 468 pt of text. + fn letter(columns: Columns) -> SectionProps { + SectionProps { page_w: 612.0, page_h: 792.0, margin_left: 72.0, margin_right: 72.0, gutter: 0.0, columns, ..Default::default() } + } + + fn equal(count: u32, space: f32) -> SectionProps { + letter(Columns { count, space, ..Default::default() }) + } + + fn widths(v: &Value) -> Vec<(f32, f32)> { + v["widths"].as_array().into_iter().flatten().map(|c| (c[0].as_f64().unwrap_or(0.0) as f32, c[1].as_f64().unwrap_or(0.0) as f32)).collect() + } + + /// #5197: two equal columns half an inch apart leave a space from 216 to 252 pt. + #[test] + fn the_spaces_between_columns_are_where_the_columns_are_not() { + assert_eq!(gaps_of(&equal(2, 36.0)), vec![(216.0, 252.0)]); + assert_eq!(gaps_of(&equal(3, 36.0)), vec![(132.0, 168.0), (300.0, 336.0)]); + assert!(gaps_of(&equal(1, 36.0)).is_empty(), "one column, no space"); + // Right to left, the first column is on the right: the same spaces, read leftwards. + let rtl = SectionProps { rtl: true, ..equal(3, 36.0) }; + assert_eq!(gaps_of(&rtl), vec![(300.0, 336.0), (132.0, 168.0)]); + let unequal = letter(Columns { count: 2, space: 18.0, widths: vec![(100.0, 18.0), (350.0, 0.0)], ..Default::default() }); + assert_eq!(gaps_of(&unequal), vec![(100.0, 118.0)]); + } + + /// #5197: dragging an edge of the space between equal columns keeps them equal and changes + /// the spacing, so the dragged edge lands at the pointer. + #[test] + fn dragging_an_edge_between_equal_columns_changes_the_spacing() { + let s = equal(2, 36.0); + // The first column's end, from 216 to 207: 9 pt more space each side, 18 in all. + let v = column_drag(&s, 0, GapGrip::End, 207.0).unwrap_or_default(); + assert_eq!(v, json!({"count": 2, "space": 54.0, "separator": false, "apply": "section"})); + // The second column's start, from 252 to 261: the same. + assert_eq!(column_drag(&s, 0, GapGrip::Start, 261.0).unwrap_or_default()["space"], 54.0); + // Past the middle the space closes; it never goes negative. + assert_eq!(column_drag(&s, 0, GapGrip::End, 400.0).unwrap_or_default()["space"], 0.0); + // At most 300 pt: 297, the last 1/16 inch below it. + assert_eq!(column_drag(&s, 0, GapGrip::End, 0.0).unwrap_or_default()["space"], 297.0); + // And it never leaves a column narrower than a quarter inch. + let narrow = equal(12, 0.0); + let most = (468.0 - 18.0 * 12.0) / 11.0; + let space = column_drag(&narrow, 0, GapGrip::End, 0.0).unwrap_or_default()["space"].as_f64().unwrap_or(0.0) as f32; + assert!(space <= most, "{space} leaves every column a quarter inch ({most})"); + assert_eq!(space % 4.5, 0.0, "{space} is on the 1/16 inch grid at the limit too"); + // Three columns: the second space's start edge, from 336 to 345. + let s = equal(3, 36.0); + assert_eq!(column_drag(&s, 1, GapGrip::Start, 345.0).unwrap_or_default()["space"], 49.5); + } + + /// #5197: between unequal columns only the column at the dragged edge and the space change. + #[test] + fn dragging_between_unequal_columns_changes_its_neighbours() { + let s = letter(Columns { count: 3, space: 18.0, widths: vec![(100.0, 18.0), (200.0, 18.0), (132.0, 0.0)], ..Default::default() }); + // The first column's end, 100 → 90: it narrows and the space after it widens. + assert_eq!(widths(&column_drag(&s, 0, GapGrip::End, 90.0).unwrap_or_default()), vec![(90.0, 28.0), (200.0, 18.0), (132.0, 0.0)]); + // The third column's start, 336 → 300, back into the second column: it stops at the + // second column's end (318), the space closes and the third column takes it. + assert_eq!(widths(&column_drag(&s, 1, GapGrip::Start, 300.0).unwrap_or_default()), vec![(100.0, 18.0), (200.0, 0.0), (150.0, 0.0)]); + // The second column's start, 118 → 1000: the column never gets narrower than a quarter + // inch. + assert_eq!(widths(&column_drag(&s, 0, GapGrip::Start, 1000.0).unwrap_or_default()), vec![(100.0, 200.0), (18.0, 18.0), (132.0, 0.0)]); + } + + /// Junk positions and spaces that aren't there change nothing. + #[test] + fn column_drags_ignore_junk() { + let s = equal(2, 36.0); + assert!(column_drag(&s, 0, GapGrip::End, f32::NAN).is_none()); + assert!(column_drag(&s, 1, GapGrip::End, 100.0).is_none(), "two columns have one space"); + assert!(column_drag(&equal(1, 36.0), 0, GapGrip::Start, 100.0).is_none()); + assert!(column_drag(&equal(13, 0.0), 0, GapGrip::End, 10.0).is_none(), "more columns than the command takes"); + // Unequal widths from a file that the command wouldn't take back aren't dragged. + let narrow = letter(Columns { count: 2, widths: vec![(5.0, 0.0), (463.0, 0.0)], ..Default::default() }); + assert!(column_drag(&narrow, 0, GapGrip::End, 30.0).is_none(), "a column under a quarter inch"); + let wide = letter(Columns { count: 2, widths: vec![(100.0, 400.0), (50.0, 0.0)], ..Default::default() }); + assert!(column_drag(&wide, 0, GapGrip::End, 90.0).is_none(), "a space over 300 pt"); + let over = letter(Columns { count: 2, widths: vec![(300.0, 18.0), (300.0, 0.0)], ..Default::default() }); + assert!(column_drag(&over, 0, GapGrip::End, 290.0).is_none(), "wider than the text"); + let last = letter(Columns { count: 2, widths: vec![(200.0, 18.0), (250.0, 999.0)], ..Default::default() }); + assert!(column_drag(&last, 0, GapGrip::End, 190.0).is_some(), "the last column's space after it doesn't count"); + } + + /// Through the command: a drag's result is applied to the caret's section, keeps the line + /// between columns, and is one undo step. + #[test] + fn a_column_drag_sets_the_sections_columns() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one two three")), Default::default()); + app.run("layout.columns", json!({"count": 2, "separator": true})).unwrap(); + let before = wordcraft_engine::cmd::page::sect(&app.session); + // The first column's end, 9 pt to the left: 18 pt more space, the columns still equal. + let end = before.column_boxes()[0].0 + before.column_boxes()[0].1; + let params = column_drag(&before, 0, GapGrip::End, end - 9.0).unwrap(); + app.run("layout.columns", params).unwrap(); + let after = wordcraft_engine::cmd::page::sect(&app.session); + assert_eq!(after.columns.count, 2); + assert!(after.columns.widths.is_empty(), "the columns stay equal"); + assert_eq!(after.columns.space, before.columns.space + 18.0); + assert!(after.columns.separator, "the line between the columns stays"); + let boxes = after.column_boxes(); + assert!((boxes[0].1 - (end - 9.0)).abs() < 0.01, "the edge is at the pointer: {boxes:?}"); + let laid = app.session.layout(); + let page = laid.pages.first().unwrap(); + let cg = column_gaps(&app, &laid, 0).unwrap(); + assert_eq!(cg.gaps.len(), 1); + assert!((cg.gaps[0].0 - (page.body.x + boxes[0].1)).abs() < 0.01, "the ruler's space starts where the first column ends"); + assert!(app.session.undo()); + assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).columns, before.columns); + } + + /// A drag sets the columns of every section the selection touches, so a selection across + /// sections gets no spaces on the ruler; nor does a page the caret isn't on. + #[test] + fn no_column_spaces_for_a_selection_across_sections_or_another_page() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one\ntwo")), Default::default()); + app.run("caret.docEnd", json!({})).unwrap(); + app.run("layout.break", json!({"kind": "nextPage"})).unwrap(); + app.run("layout.columns", json!({"count": 2, "apply": "section"})).unwrap(); + let laid = app.session.layout(); + assert_eq!(laid.pages.len(), 2); + assert!(column_gaps(&app, &laid, 1).is_some(), "the caret's page shows the second section's columns"); + assert!(column_gaps(&app, &laid, 0).is_none(), "the first page has one column"); + app.run("select.all", json!({})).unwrap(); + let laid = app.session.layout(); + assert!(column_gaps(&app, &laid, 1).is_none(), "the selection runs into a section of one column"); + } +} diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index 0362bbee81..86f5d89e72 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -24,6 +24,11 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[ hp.rect_filled(bar, 0.0, t.ruler_margin); let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y)); hp.rect_filled(text, 0.0, t.ruler); + // The spaces between the caret section's text columns, shaded like the margins (#5197). + let columns = super::column_ruler::column_gaps(app, layout, pi); + if let Some(cg) = &columns { + super::column_ruler::draw(&hp, cg, bar, x0, scale, &t); + } let rp = app.session.doc.para_at(&app.session.sel.focus).map(|para| app.session.doc.styles.resolve_para(¶.props)); // Graduations in the File › Options unit. let (unit, div, step) = ruler_scale(app.session.units()); @@ -101,6 +106,11 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[ // spans the text area, so a border marker keeps its drag) and before the indent markers, // which keep the pointer where they overlap. crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale); + // Outside a table, the spaces between the text columns, in the same order (#5197). + if let Some(cg) = &columns { + let tabs = crate::ruler_tabs::marker_rects(app, bar, &px, &rp.tabs); + super::column_ruler::handles(app, ui, cg, bar, x0, scale, &tabs); + } // Dragging the left-indent marker. let id = ui.scope_id().with("ruler_left"); let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0)); diff --git a/docs/gaps.md b/docs/gaps.md index 7527aa05d3..6bef0c7165 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -217,6 +217,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-31 | trivial | Pagination gap #4: a float anchored right after a page break inside a paragraph, before text, is drawn on the new page (#4262) | | 2026-10-31 | trivial | Pagination gap #4: a float anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | +| 2026-11-02 | trivial | In Print Layout the horizontal ruler shades the spaces between the caret section's text columns, and their edges drag the spacing (#5197); no ranked gap changed; readiness estimates unchanged | +| 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-11-04 | trivial | #19: ODT settings for what the standard leaves open removed: one choice for each case | | 2026-11-04 | trivial | #19: ODT element audit; files from other applications checked | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 84b19512a2..db5c197d59 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -42,9 +42,10 @@ Document title opens Rename Document; the account badge opens Options › Genera | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); Return on the Print page prints, as in Word, whose ⌘P opens on Print as the default button (not while a field or list on the page has the keyboard); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129; File › Options works there, AutoCorrect Options included, #4585), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text. Inside a Drawing Canvas a click picks the member under it (shape, picture or text box) on its own, its frame and handles shown inside the canvas frame; it is dragged, resized, turned and nudged inside the canvas, and Shape Format, Delete, Cut and Copy act on it alone; a click in a text box member's text puts the caret there (#2637, `objects::tests::clicking_and_dragging_inside_a_canvas_works_on_its_members`). Not yet: Shift+click on several members, dragging a member out of its canvas, drag-selecting a text box member's text, members of a turned canvas | ✅ | 75% | — | +| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878); in a section of several text columns the spaces between them are shaded like the margins (Print Layout, in the main text outside tables, with the selection in one section), an edge dragged changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) | ✅ | 92% | 1 | +| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332), and the arrow over each handle turns with the frame to the nearest of the four resize arrows (#3932); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | -| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a bold insertion cursor (solid, twice the caret's width) follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234, #4195) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 | @@ -118,6 +119,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | +| 2026-11-01 | trivial | Rulers: the spaces between text columns are shaded on the horizontal ruler (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge changes the spacing; equal columns stay equal, as in Word for Mac (#5197) | | 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts | | 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland | | 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. | From 33fa77a89241c88ef1135c4b6f706180bc4f3288 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:51:36 +0800 Subject: [PATCH 10/16] Track Changes: resolving away an empty paragraph with a section break keeps the next paragraph's properties (#5667) (#5734) Rejecting an inserted paragraph (or accepting a deleted one) whose mark carries a section break left it empty; remove_empty_para refused it for the break, so the next paragraph was joined into it and took its properties (style, numbering, indents). The empty paragraph now goes whole and its section joins the following one first, as deleting the break does (remove_section_break). Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/engine/src/cmd/review/changes.rs | 19 ++++++++--- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++ docs/typing-parity.md | 2 ++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e49fab3a5..16afcb20d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -351,6 +351,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | What landed | | 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. | +| 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | | 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture | |---|---| | 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland | @@ -869,6 +870,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | | 2026-11-06 | trivial | Progress log: the printer list no longer waits on printer drivers (#5710) | +| 2026-11-06 | trivial | Progress log: resolving away an empty paragraph with a section break keeps the next paragraph's properties, #5667; readiness estimates unchanged | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged | | 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/review/changes.rs b/crates/engine/src/cmd/review/changes.rs index b41cc7f01b..13990ba89c 100644 --- a/crates/engine/src/cmd/review/changes.rs +++ b/crates/engine/src/cmd/review/changes.rs @@ -54,11 +54,20 @@ fn resolve_para( // An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away: // the next paragraph joins this one (the split is undone). A paragraph left empty goes away // whole, so the next one keeps its own style and numbering, as in Word. The mark at the end - // of a story or cell can't go, so it just stops being a revision. - if ((del.is_some() && accept) || (ins.is_some() && !accept)) - && (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?) - { - return Ok(()); + // of a story or cell can't go, so it just stops being a revision. An empty paragraph whose + // mark carries a section break goes whole too: its section joins the following one, as when + // the break is deleted (#5667). Joining instead gave the next paragraph its properties. + if (del.is_some() && accept) || (ins.is_some() && !accept) { + let sectioned = story == StoryRef::Body + && path.0.len() == 1 + && s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some()) + && crate::cmd::has_next_para(s, story, path); + if sectioned { + s.doc.remove_section_break(path.last()); + } + if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? { + return Ok(()); + } } let para = s.doc.para_mut(story, path)?; para.mark.ins = None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 10591fa908..4e5c490c8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -12787,3 +12787,48 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); } + +/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a +/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is +/// tracked as an insertion by `rev`, or as a deletion when `delete`. +fn sectioned_revision(delete: bool) -> Session { + use wordcraft_doc::{Revision, RevisionKind}; + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); + run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); + run(&mut s, "para.style", json!({"style": "Heading1"})); + let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; + s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); + s.doc.last_section.margin_left = 72.0; + let mut sect = s.doc.last_section.clone(); + sect.margin_left = 144.0; + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + let len = p.len(); + p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); + if delete { + p.mark.del = Some(0) + } else { + p.mark.ins = Some(0) + } + p.props.indent_left = Some(36.0); + p.section = Some(Box::new(sect)); + s +} + +#[test] +fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { + // #5667: the resolved paragraph was left empty but kept for its section break, so the next + // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, + // as an empty paragraph without a break does, and its section joins the following one. + for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { + let mut s = sectioned_revision(delete); + assert_eq!(s.doc.sections().len(), 2); + run(&mut s, cmd, json!({})); + assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); + let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); + assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); + assert_eq!(s.doc.sections().len(), 1, "{cmd}"); + assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); + } +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index ad1f879c5b..266d9cf9a4 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -114,6 +114,7 @@ formatting of the text before the cursor. Not yet compared side by side in Word. | Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` | | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | | Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | +| Accept All / Reject All of a paragraph left empty, whose mark carries a section break (tracking on, or a Compare result) | the paragraph goes whole and its section joins the following one, as when the break is deleted; the next paragraph keeps its own style and numbering, as for an empty paragraph without a break (#5667) | `resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | | Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table (each cell as below). A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | | Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` | @@ -589,6 +590,7 @@ this correction; native Windows interaction and Microsoft Word remain untested. |---|---|---| | 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked | | 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not | +| 2026-11-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) | | 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) | From 796b39dde6b5e48a7831726054d30702f43bb71d Mon Sep 17 00:00:00 2001 From: Senura Dissanayake Date: Sun, 8 Nov 2026 04:54:06 +0100 Subject: [PATCH 11/16] docbin: open Word 97-2003 files in compatibility mode 11 (#5905) Word 97-2003 documents were laid out with the model default of mode 15. Word opens them in mode 11, which changes justification and line breaking. Test: a_doc_opens_in_word_97_2003_compatibility_mode --- contributors/people.toml | 4 ++++ crates/docbin/src/lib.rs | 3 +++ crates/docbin/src/tests.rs | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 04fc37ec2f..4e6dea3b12 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -14,6 +14,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.SenuraMalaka] +real_name = "Senura Dissanayake" +sync_github_name = true + [people.divyanshu2074] real_name = "Divyanshu Gupta" sync_github_name = true diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index a2461d3fb2..e143bd9763 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> }; let mut doc = Document::new(); + // Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default + // 15 would apply Word 2013+ justification and line-breaking rules to them. + doc.settings.compat_mode = 11; if let Some(core) = metadata::read(&mut comp) { doc.core = core; } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 22ec2fc1e4..3acc38cd1b 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() { assert_eq!(doc.body.len(), 1); } +#[test] +fn a_doc_opens_in_word_97_2003_compatibility_mode() { + let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0); + let doc = read(&f).expect("opens"); + // Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and + // line breaking. + assert_eq!(doc.settings.compat_mode, 11); +} + #[test] fn header_text_stays_out_of_body() { let f = text_doc( From fa791d29b279be732956801f6c42d014b5114033 Mon Sep 17 00:00:00 2001 From: Bardan Ghimire Date: Sun, 8 Nov 2026 09:41:38 +0545 Subject: [PATCH 12/16] test(mcp): gate the names helper to unix, where its only test runs (#5879) Co-authored-by: Bardan --- crates/mcp/tests/automation_roots.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index e870fde1f9..61b1d58462 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -208,6 +208,7 @@ fn links_out_of_a_root_are_refused() { } /// The names in `dir`, sorted. +#[cfg(unix)] fn names(dir: &Path) -> Vec { let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); names.sort(); From 625f598b64b0868c7ed4881e3e36c210433fe42a Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 00:56:55 -0300 Subject: [PATCH 13/16] Fix #5750: style-set gallery follows RTL interface direction (#5857) --- crates/ui-egui/src/previews.rs | 2 +- crates/ui-egui/src/ribbon/design.rs | 5 ++-- crates/ui-egui/src/ribbon/tests.rs | 43 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 5d02c834dd..1ff99e1073 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -694,7 +694,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { /// Style sets shown in the Design tab's gallery, in order. const STYLE_SETS: [&str; 7] = ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"]; /// Width of one gallery thumbnail, points. -const STYLE_SET_W: f32 = 56.0; +pub const STYLE_SET_W: f32 = 56.0; /// Width the whole gallery takes with the ribbon's 1 pt item spacing. pub const STYLE_SET_GALLERY_W: f32 = STYLE_SETS.len() as f32 * (STYLE_SET_W + 1.0) - 1.0; diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs index 8208c86318..9b078e2058 100644 --- a/crates/ui-egui/src/ribbon/design.rs +++ b/crates/ui-egui/src/ribbon/design.rs @@ -3,11 +3,12 @@ use super::{CONTENT_H, Rect, Sense, Target, Ui, WordApp, big, group, json, menu_ /// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped /// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by, -/// the thumbnails (#638). Returns the box and the space the thumbnails used. +/// the thumbnails (#638). In a right-to-left interface, the thumbnails lay out from the right +/// (#5750). Returns the box and the space the thumbnails used. pub(super) fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) { ui.add_space(4.0); let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover()); - let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min))); + let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(crate::rtl::row_layout(ui, egui::Align::Min))); gui.set_clip_rect(gallery.intersect(ui.clip_rect())); gui.spacing_mut().item_spacing = vec2(1.0, 1.0); crate::previews::style_set_gallery(app, &mut gui); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index 745750b5a3..444c60acb1 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -113,6 +113,49 @@ fn design_style_set_gallery_stays_in_its_box() { assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } +/// #5750: in a right-to-left interface, the Design tab's style-set gallery lays out from the +/// right: the first style set ("default") is at the right edge, the remaining thumbnails read to +/// the left, and the next controls start after a gap to the left of the gallery. +#[test] +fn design_style_set_gallery_reads_from_the_right_in_rtl() { + crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n")); + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut seen = None; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 200.0)).build_ui(|ui| { + crate::rtl::root(ui, |ui| { + crate::rtl::horizontal_top(ui, |ui| { + let (gallery, used) = style_set_box(&mut app, ui); + seen = Some((gallery, used, ui.cursor().max.x)); + }); + }); + }); + h.run_steps(2); + let tiles: Vec = h.ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| (w.rect.width() - crate::previews::STYLE_SET_W).abs() < 0.1 && (w.rect.height() - CONTENT_H).abs() < 0.1) + .map(|w| w.rect) + .collect() + }); + drop(h); + crate::i18n::set_current(crate::i18n::Lang::EN); + + let (gallery, used, next_x) = seen.unwrap(); + assert!((gallery.width() - crate::previews::STYLE_SET_GALLERY_W).abs() < 0.01); + assert!(used.left() >= gallery.left() - 0.01, "{used:?} {gallery:?}"); + assert!(used.right() <= gallery.right() + 0.01, "{used:?} {gallery:?}"); + assert!(next_x <= gallery.left() - 8.0 + 0.01, "{next_x} {gallery:?}"); + + assert_eq!(tiles.len(), 7, "7 style set tiles"); + assert!((tiles[0].right() - gallery.right()).abs() < 0.01, "default tile at the right edge: {tiles:?} {gallery:?}"); + assert!((tiles[6].left() - gallery.left()).abs() < 0.01, "minimalist tile at the left edge: {tiles:?} {gallery:?}"); + for i in 1..tiles.len() { + assert!(tiles[i].right() <= tiles[i - 1].left(), "tile {i} is to the left of tile {}", i - 1); + } +} + /// #2738: Home › Underline's drop-down: every line sample is a named entry (assistive /// technology, tooltips) that applies its own style; Words only, None and Underline Color › /// Automatic do what they say. From b016ecc26cb88323c4fc67b9a2a1d4fee9a34e6c Mon Sep 17 00:00:00 2001 From: Bardan Ghimire Date: Sun, 8 Nov 2026 09:44:16 +0545 Subject: [PATCH 14/16] fix: reject non-regular files when reading template titles (#5904) Co-authored-by: Bardan --- crates/engine/src/io.rs | 4 ++-- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5ffc345e53..583da36e89 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bo #[cfg(not(target_arch = "wasm32"))] pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { crate::file_access::check_read(&path.to_string_lossy()).ok()?; - let file = std::fs::File::open(path).ok()?; - if file.metadata().ok()?.len() > max_file { + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > max_file { return None; } wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5c490c8e..2718ef8de6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5747,6 +5747,39 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() { assert!(device.unwrap_err().contains("not a file")); } +#[cfg(unix)] +#[test] +fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() { + let dir = scratch_dir("template-title-fifo"); + let fifo = dir.join("pipe.dotx"); + + let made = std::process::Command::new("mkfifo").arg(&fifo).status(); + if !made.is_ok_and(|s| s.success()) { + let _ = std::fs::remove_dir_all(&dir); + eprintln!("mkfifo unavailable; skipping"); + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + let path = fifo.clone(); + + std::thread::spawn(move || { + let title = crate::io::package_title(&path, 64 << 20, 64 << 10); + let _ = tx.send(title); + }); + + let result = rx.recv_timeout(std::time::Duration::from_secs(10)); + + if result.is_err() { + // Unblock a stuck reader so the test thread can finish. + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); + } + + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(result.expect("reading a template title blocked on a named pipe"), None); +} + #[cfg(unix)] #[test] fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() { From 5f1db14954217431d1d17f578e73bcd64b74c995 Mon Sep 17 00:00:00 2001 From: Ashok Ram Date: Sun, 8 Nov 2026 09:31:43 +0530 Subject: [PATCH 15/16] Proofing: Check spelling as you type is one setting for every open document (#5740) (#5766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Proofing: Check spelling as you type is one setting for every open document (#5740) Each open document's view kept its own copy of the setting. Turned off with one document active, it stayed on in the others, and the settings saved for the next run took the value of whichever document was active then, so it came back on. A document made active now takes the saved preference, as Read Mode's page colour already does. * Contributors: credit ashokram-si as Ashok Ram * Proofing: every view made active follows the app-wide settings, a window's view put back too (#5740) put_view restored a window's view with the Check spelling as you type value it had when it was taken, after switch_to had applied the shared preference, so the settings saved for the next run could take the old value again. One ViewState::follow_prefs now applies the app-wide settings a view keeps a copy of (Read Mode's page colour and Check spelling as you type) wherever a view is made active: switch_to, a new document's view, set_document and put_view. * test(ui): Check spelling off in a second window stays off in every window (#5740) The whole sequence through the app: View › New Window, spelling turned off in the second window, the first window's own view put back (put_view), a new document's window, back to the second window, then the saved settings read by a new app. Fails if put_view doesn't follow the setting. --------- Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com> --- contributors/people.toml | 3 ++ crates/engine/src/session/documents.rs | 4 +-- crates/engine/src/session/snapshot.rs | 2 +- crates/engine/src/session/views.rs | 2 +- crates/engine/src/tests/documents.rs | 48 ++++++++++++++++++++++++++ crates/engine/src/view.rs | 11 ++++++ crates/ui-egui/src/tests.rs | 45 ++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4e6dea3b12..1f5825f1ad 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -201,3 +201,6 @@ display_name = "entropybeater" [people.helenkwok] real_name = "Helen Kwok" + +[people.ashokram-si] +real_name = "Ashok Ram" diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index f985d8e35d..ccbbdf4120 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -390,7 +390,7 @@ impl Session { let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; let mut d = self.others.swap_remove(at); self.swap_doc(&mut d); - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); self.others.push(d); Ok(()) } @@ -471,7 +471,7 @@ impl Session { doc.ensure_nonempty(); let mut view = self.view.clone(); view.outline.collapsed.clear(); - view.read_page_color = self.prefs.read_page_color; + view.follow_prefs(&self.prefs); let mut d = DocState::new(doc, untitled, view); let id = d.document_id; self.order.push(id); diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 504d3d805a..2a9283f3f8 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -32,7 +32,7 @@ impl Session { self.hf_return = None; self.go_back_step = 0; self.view.outline.collapsed.clear(); - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); // A copy the replaced document kept stays in the recovery list. #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 762c1d846c..9be8596052 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -135,7 +135,7 @@ impl Session { let current = document_id == self.document_id && rev == self.rev; self.sel = sel; self.view = view; - self.view.read_page_color = self.prefs.read_page_color; + self.view.follow_prefs(&self.prefs); self.pending = pending; self.goal_x = goal_x; self.page_hint = page_hint; diff --git a/crates/engine/src/tests/documents.rs b/crates/engine/src/tests/documents.rs index 36ecb471f2..7dd108b893 100644 --- a/crates/engine/src/tests/documents.rs +++ b/crates/engine/src/tests/documents.rs @@ -346,3 +346,51 @@ fn input_method_joins_stay_with_their_window() { assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); drop(a); } + +/// Check spelling as you type is one setting for the app, not one per open document (#5740): +/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off +/// in the others too, and switching to another document doesn't turn it back on, so what is saved +/// for the next run is what was chosen, whichever document is active then. +#[test] +fn check_spelling_is_one_setting_for_every_open_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + run(&mut s, "file.new", json!({})); + let c = s.document_id(); + assert!(s.view.proofing); + s.activate(a).unwrap(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + for id in [b, c, a] { + s.activate(id).unwrap(); + assert!(!s.view.proofing, "off in document {id}"); + assert!(!s.prefs.check_spelling); + } + run(&mut s, "review.proofing", json!({"value": true})); + for id in [b, c] { + s.activate(id).unwrap(); + assert!(s.view.proofing, "back on in document {id}"); + } + // A document opened now starts with the setting as it is. + run(&mut s, "review.proofing", json!({"value": false})); + run(&mut s, "file.new", json!({})); + assert!(!s.view.proofing); +} + +/// A window's view put back after Check spelling as you type changed in another window takes the +/// setting as it is now, not as it was when the window's view was taken (#5740): every view made +/// active follows the saved preference. +#[test] +fn a_window_view_put_back_follows_the_spelling_setting() { + let mut s = s(); + assert!(s.view.proofing); + let held = s.take_view(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + s.put_view(held); + assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); + let held = s.take_view(); + run(&mut s, "review.proofing", json!({"value": true})); + s.put_view(held); + assert!(s.view.proofing); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index baae9d6033..c10d6d204a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -161,6 +161,17 @@ fn default_read_zoom() -> f32 { 1.0 } +impl ViewState { + /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's + /// page colour and Check spelling as you type (#5740). A view made active goes through this + /// (a document switched to, opened or made, a window's view put back), so a change made while + /// another view was active holds in this one too. + pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { + self.read_page_color = prefs.read_page_color; + self.proofing = prefs.check_spelling; + } +} + impl Default for ViewState { fn default() -> Self { ViewState { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 7e24875be4..4c069a3755 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -5837,6 +5837,51 @@ fn check_spelling_survives_restart() { assert!(second.session.prefs.check_spelling, "the live spelling setting and its saved copy agree"); } +/// With several documents open, turning Check spelling as you type off in one keeps it off in the +/// others, and the next run starts with it off whichever document was active when the settings +/// were saved (#5740: it came back on every time the file was opened again). +#[test] +fn check_spelling_off_survives_a_switch_and_a_restart() { + let mut first = app(); + let a = first.session.document_id(); + first.run("file.new", json!({})).unwrap(); + first.run("file.options", json!({"checkSpelling": false})).unwrap(); + first.session.activate(a).unwrap(); + assert!(!first.session.view.proofing, "off in the other document too"); + let saved = serde_json::to_vec(&first.prefs()).unwrap(); + + let mut second = app(); + second.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert!(!second.session.view.proofing && !second.session.prefs.check_spelling); +} + +/// The same across windows: Check spelling as you type turned off in a second window on a document +/// stays off when the first window's own view of it comes back, in a window on another document, +/// back in the second window, and in the saved settings (#5740). +#[test] +fn check_spelling_off_in_a_second_window_stays_off_in_every_window() { + let mut a = app(); + let first = a.windows.shown(); + a.new_window(); + let second = a.windows.shown(); + assert_ne!(first, second, "View › New Window opened a window with its own view"); + a.run("file.options", json!({"checkSpelling": false})).unwrap(); + + assert!(a.enter_window(first)); + assert!(!a.session.view.proofing, "the first window's own view, put back, follows the setting"); + a.run("file.new", json!({})).unwrap(); + assert!(!a.session.view.proofing, "a new document's window"); + assert!(a.enter_window(second)); + assert!(!a.session.view.proofing, "back in the second window"); + assert!(a.enter_window(first)); + assert!(!a.session.view.proofing); + + let saved = serde_json::to_vec(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert!(!b.session.view.proofing && !b.session.prefs.check_spelling, "saved off, whichever window was drawn"); +} + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); From 855a0fe9bde7240d9edcc359e3be7d0b654236e6 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:03:54 +0100 Subject: [PATCH 16/16] Layout: a nested table's Can't Split row moves on with its outer row (#5456) A row is split across pages from its flattened items, so a row of a nested table marked w:cantSplit was cut between its lines like any other text in the outer cell. Word leaves such a row whole: the outer row moves to the next page whole when that row comes first in it (docxide-pdf nested-first-row-prefix300/330-cant1), and otherwise splits above it (inferred). table_flow now moves the cut up to just below the top of the highest nested Can't Split row it runs through (nested_keep_cut): the outer row's bottom band below that top, so the text and nested rows ending there stay on the page. A row runs through the cut when it ends below the room left for text, as in the split itself; one walk over the rows from the lowest top up moves the cut until none does. A vertically merged cell doesn't stretch its row's band, and a cell aligned to the middle or bottom is measured from its top, as the split runs it. This applies to the first part of a row and to the kept-chain and header-row checks. On a fresh page, where moving on gains nothing, a nested row taller than the room still splits at the room rather than running into the bottom margin. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/layout/src/table_flow.rs | 69 +++++- crates/layout/src/tests.rs | 1 + crates/layout/src/tests/nested_cant_split.rs | 213 +++++++++++++++++++ docs/gaps.md | 2 + docs/layout-parity.md | 3 +- 6 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 crates/layout/src/tests/nested_cant_split.rs diff --git a/ROADMAP.md b/ROADMAP.md index 16afcb20d1..249f287f99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -236,6 +236,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged. - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. +- 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. @@ -912,6 +913,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | +| 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index a05680f14a..88757e3368 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -94,7 +94,62 @@ fn row_keeps_next(row: &table::RowLayout) -> bool { .unwrap_or(false) } -fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool { +/// The cut that leaves every row of a nested table marked "can't split" whole: just below the +/// top of the highest such row the cut at `room` runs through, moved up again while it runs +/// through another one (Word moves such a row on with the rest of the outer row, observed in the +/// docxide-pdf `nested-first-row-*-cant1` cases). Like the split itself, a row "runs through" +/// the cut when it ends below the room left for text there, above the row's bottom band; the cut +/// goes that band below the row's top, so the text and rows ending at its top stay. +/// shortcut: the moved row's side borders and shading keep that band's height on the page, and +/// on the next page its cell area and shading start that band less its top margin below its +/// first line (clicks there miss the cell). Only an explicit bottom cell margin or a thick outer +/// border shows it, never Word's defaults; upgrade with a separate drawing cut in split_row that +/// moves a nested cell starting between the limit and the cut whole from its top. +fn nested_keep_cut(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> f32 { + let own = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min(); + // Each nested row's top and bottom when it can't split. A vertically merged cell runs past + // its own row, so a row ends where its shortest cell does. + let mut bands: std::collections::BTreeMap<(StoryRef, &[u32], usize), Option<(f32, f32)>> = std::collections::BTreeMap::new(); + // The split runs a cell aligned to the middle or bottom from its top: so do its bands. + let mut shift = vec![0.0f32; row.items.len()]; + for al in row.cells.iter().filter_map(|c| c.align.as_ref()) { + shift.get_mut(al.content.clone()).into_iter().flatten().for_each(|d| *d = al.dy); + } + for (it, &dy) in row.items.iter().zip(&shift) { + let Placed::Cell { rect, table, row: r, story, .. } = it else { continue }; + if Some(table.0.len()) <= own { + continue; + } + let rect = Rect { y: rect.y - dy, ..*rect }; + let band = bands + .entry((*story, table.0.as_slice(), *r)) + .or_insert_with(|| doc.table(*story, table).and_then(|t| t.rows.get(*r)).filter(|r| r.props.cant_split).map(|_| (rect.y, rect.bottom()))); + if let Some((top, bottom)) = band { + *top = top.min(rect.y); + *bottom = bottom.min(rect.bottom()); + } + } + let below = row.bottom.clamp(0.0, room.max(0.0)); + let mut bands: Vec<(f32, f32)> = bands.into_values().flatten().collect(); + // From the lowest top up: once the cut moves up to a row, the rows starting below it are + // wholly under it, and a row the old cut ran through still runs through the new one. + bands.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut cut = room; + for (top, bottom) in bands { + let limit = cut - below; + if top < limit && bottom > limit + 0.01 { + cut = top + below; + } + } + cut +} + +/// [`table::split_row_keeping_cells`] that doesn't split a nested "can't split" row either. +fn split_keeping_nested(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> Option<(table::RowLayout, table::RowLayout)> { + table::split_row_keeping_cells(row, nested_keep_cut(doc, row, room)) +} + +fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool { if laid.height <= room + 0.01 { return true; } @@ -104,7 +159,7 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) { return false; } - table::split_row_keeping_cells(laid, room).is_some() + split_keeping_nested(doc, laid, room).is_some() } pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { @@ -187,8 +242,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc { let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::(); let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split); - if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some())) - { + if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) { pb.advance(block, body_top); } } @@ -223,7 +277,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc } let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false }; let page_height = pb.bottom - body_top - repeated_height; - height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height) + height <= page_height + 0.01 && !row_can_start(ctx.doc, next, laid, pb.bottom - pb.y - height, page_height) }); // A row another row follows needs room for the border below it too (measured in Word). if pb.y + cur.height + cur.edge <= pb.bottom + 0.01 && !defer_kept { @@ -238,8 +292,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc let moves_on = first_part && short_of_min(ri, room); let split = match (splittable && !moves_on && !defer_kept, first_part) { (false, _) => None, - (true, true) => table::split_row_keeping_cells(cur, room), - (true, false) => table::split_row(cur, room), + (true, true) => split_keeping_nested(ctx.doc, cur, room), + // On a fresh page, a nested row taller than the room splits rather than overflowing. + (true, false) => table::split_row(cur, nested_keep_cut(ctx.doc, cur, room)).or_else(|| table::split_row(cur, room)), }; match split { Some((a, b)) => { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 29114735d0..2b1c4eb15b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13,6 +13,7 @@ mod grid; mod ligature; pub(crate) mod linear; mod nbsp; +mod nested_cant_split; mod numeral; mod pages; mod styleref; diff --git a/crates/layout/src/tests/nested_cant_split.rs b/crates/layout/src/tests/nested_cant_split.rs new file mode 100644 index 0000000000..aeccb9901b --- /dev/null +++ b/crates/layout/src/tests/nested_cant_split.rs @@ -0,0 +1,213 @@ +//! A row of a nested table marked "can't split" moves on with its outer row, as in Word +//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it. +use crate::*; +use wordcraft_doc::{Table, para_block}; + +fn one_line_paras(what: &str, n: usize) -> Vec> { + (0..n) + .map(|i| { + let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default()); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + para_block(p) + }) + .collect() +} + +/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold +/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns, +/// per cell, the prefix lines and the nested lines on each page. +fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec, Vec)> { + let mut d = Document::from_text("Filler"); + if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) { + p.props.space_before = Some(push); + } + let mut outer = Table::new(1, cells.len(), 440.0); + for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) { + let mut inner = Table::new(1, 1, 200.0); + inner.rows[0].props.cant_split = cant_split; + inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested); + cell.blocks = one_line_paras("Prefix", prefix); + cell.blocks.push(Arc::new(Block::Table(inner))); + cell.blocks.push(para_block(Paragraph::with_text("", Default::default()))); + } + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions::default()); + let count = |cell: u32, nested: bool| -> Vec { + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l0, l1, .. } => { + let p = &path.0; + let hit = if nested { + p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0] + } else { + p.len() == 4 && p[..3] == [1, 0, cell] + }; + hit.then_some(l1 - l0) + } + _ => None, + }) + .sum() + }) + .collect() + }; + (0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect() +} + +#[test] +fn nested_cant_split_row_moves_on_with_its_outer_row() { + let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1; + assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}"); + assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole"); +} + +#[test] +fn the_outer_row_splits_above_a_nested_cant_split_row() { + // About 22 lines of room: the nested row (lines 10 to 40) runs through it. + let cell = &lines_per_page(340.0, &[(10, 30)], true)[0]; + assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}"); + assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}"); +} + +#[test] +fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() { + // Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the + // cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too. + let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true); + assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}"); + assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}"); + assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}"); +} + +#[test] +fn nested_cant_split_row_taller_than_a_page_still_splits() { + let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1; + assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}"); + assert_eq!(pages.iter().sum::(), 60, "every line is placed once: {pages:?}"); + assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}"); +} + +#[test] +fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() { + let mut nested = Table::new(40, 1, 200.0); + nested.props.cell_margins = Some([0.0; 4]); + for (i, row) in nested.rows.iter_mut().enumerate() { + row.props.cant_split = true; + row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2); + } + let mut outer = Table::new(1, 1, 400.0); + // A bottom margin: the part left on the page keeps it below the last row there. + outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()]; + let l = lay_pushed(520.0, outer); + let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]); + assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}"); + assert_eq!(pages.iter().sum::(), 80, "every line is placed once: {pages:?}"); + for r in 0..40u32 { + let on: Vec = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]); + assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}"); + } +} + +fn empty() -> Arc { + para_block(Paragraph::with_text("", Default::default())) +} + +/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs. +fn kept_nested(lines: usize) -> Arc { + let mut t = Table::new(1, 1, 200.0); + t.rows[0].props.cant_split = true; + t.rows[0].cells[0].blocks = one_line_paras("Nested", lines); + Arc::new(Block::Table(t)) +} + +/// Lay out a filler paragraph pushed `push` points down, then `outer`. +fn lay_pushed(push: f32, outer: Table) -> DocLayout { + let mut d = Document::from_text("Filler"); + if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) { + p.props.space_before = Some(push); + } + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + let mut c = LayoutCache::new(); + layout(&d, &mut c, &LayoutOptions::default()) +} + +/// The lines on each page of the body paragraphs whose path `hit` picks. +fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec { + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0), + _ => None, + }) + .sum() + }) + .collect() +} + +#[test] +fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() { + // Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top, + // where its nested row (lines 2 to 32) runs through the room of about 22 lines. + let mut outer = Table::new(1, 2, 440.0); + outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50); + outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom); + let mut blocks = one_line_paras("Prefix", 2); + blocks.extend([kept_nested(30), empty()]); + outer.rows[0].cells[1].blocks = blocks; + let l = lay_pushed(340.0, outer); + let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]); + assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}"); +} + +#[test] +fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() { + let mut outer = Table::new(2, 1, 400.0); + outer.rows[0].props.header = true; + outer.rows[0].cells[0].blocks = one_line_paras("Header", 1); + outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()]; + let l = lay_pushed(520.0, outer); + let header = per_page(&l, |p| p == [1, 0, 0, 0]); + assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}"); + assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]); +} + +#[test] +fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() { + let mut outer = Table::new(2, 1, 400.0); + let mut kept = one_line_paras("Kept", 1); + if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) { + p.props.keep_next = Some(true); + } + outer.rows[0].cells[0].blocks = kept; + outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()]; + let l = lay_pushed(520.0, outer); + let kept = per_page(&l, |p| p == [1, 0, 0, 0]); + assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}"); +} + +#[test] +fn a_merged_cell_doesnt_stretch_its_cant_split_row() { + // Row 0 can't split; its first cell is merged down into row 1, which can and runs through + // the room. The cut stays in row 1. + let mut nested = Table::new(2, 2, 300.0); + nested.rows[0].props.cant_split = true; + nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart; + nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue; + nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1); + nested.rows[0].cells[1].blocks = one_line_paras("Short", 1); + nested.rows[1].cells[1].blocks = one_line_paras("Long", 20); + let mut outer = Table::new(1, 1, 400.0); + outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()]; + let l = lay_pushed(520.0, outer); + let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]); + assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6bef0c7165..cae60f28b0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -86,6 +86,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). 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) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -230,6 +231,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) | | 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | +| 2026-11-03 | trivial | #4 narrowed: a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456); Word shows this for a nested row first in its outer row, the split above a later one is inferred and not yet checked in Word; readiness estimates unchanged | | 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | | 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing | | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index f1833ce0b6..8f57f85b0a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -65,7 +65,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692); a cell aligned to the middle or bottom of a split row is aligned within the part left on the page when all its text fits there (as tall as the other cells' text there), and runs from the top of the cell otherwise, as measured in Word (`layout::tests::a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page`); a row whose first part on a page that already holds something would leave a cell empty there, because that cell's first line or inline picture (one line, never split) runs past the room, moves on whole instead of leaving the other cells' text beside the empty cell, so an at-least row grown taller than its minimum by a picture no longer splits off its picture (#4272; inferred from the reporter's Word result and the earlier observation that Word doesn't leave a cell empty for widow control, not yet checked in Word for rows without a minimum; at the top of a page such a row still splits; `layout::tests::an_at_least_row_grown_by_its_picture_moves_on_whole_rather_than_leave_the_picture_behind`, `…_a_picture_row_that_fits_stays_and_text_rows_beside_a_short_picture_still_split`, `…_a_picture_row_too_tall_for_any_page_splits_from_the_top_of_the_next_one`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692); a cell aligned to the middle or bottom of a split row is aligned within the part left on the page when all its text fits there (as tall as the other cells' text there), and runs from the top of the cell otherwise, as measured in Word (`layout::tests::a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page`); a row whose first part on a page that already holds something would leave a cell empty there, because that cell's first line or inline picture (one line, never split) runs past the room, moves on whole instead of leaving the other cells' text beside the empty cell, so an at-least row grown taller than its minimum by a picture no longer splits off its picture (#4272; inferred from the reporter's Word result and the earlier observation that Word doesn't leave a cell empty for widow control, not yet checked in Word for rows without a minimum; at the top of a page such a row still splits; `layout::tests::an_at_least_row_grown_by_its_picture_moves_on_whole_rather_than_leave_the_picture_behind`, `…_a_picture_row_that_fits_stays_and_text_rows_beside_a_short_picture_still_split`, `…_a_picture_row_too_tall_for_any_page_splits_from_the_top_of_the_next_one`); a row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it (docxide-pdf `nested-first-row-*-cant1`) and otherwise splits above it (inferred, not yet checked in Word), and on a fresh page a nested row taller than the room still splits; `layout::tests::nested_cant_split::nested_cant_split_row_moves_on_with_its_outer_row`, `…::the_outer_row_splits_above_a_nested_cant_split_row`, `…::the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it`, `…::the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band`, `…::a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole`, `…::header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first`, `…::a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it`, `…::a_merged_cell_doesnt_stretch_its_cant_split_row`, `…::nested_cant_split_row_taller_than_a_page_still_splits` | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | @@ -324,6 +324,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. | | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | +| 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word |