From be93f3b82da4b9e902b2044ff68033ddd9545496 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:31:21 +0200 Subject: [PATCH 01/34] Tables: a table with its own width is not squeezed to the margins (#1721) --- crates/layout/src/table.rs | 3 ++- crates/layout/src/tests.rs | 23 +++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 2a3df74c8..099747469 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -350,7 +350,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava grid.iter_mut().for_each(|w| *w *= k); total = tw; } - if total > avail * 1.5 && !t.props.fixed { + // A table with its own width keeps it, however far it runs past the margins (as in Word). + if target.is_none() && total > avail * 1.5 && !t.props.fixed { let k = avail / total.max(1.0); grid.iter_mut().for_each(|w| *w *= k); total = avail; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7baaad370..09c9c3137 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5741,3 +5741,26 @@ fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { wordcraft_doc::resolve::AUTO_SPACING, ); } + +/// A table with its own width keeps it when it is over 1.5 times the text width (#1721); one +/// without is still squeezed to the margins. +#[test] +fn table_with_its_own_width_is_not_squeezed_to_the_margins() { + let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); + let second_column_x = |own: Option| { + let mut t = Table::new(1, 3, 936.0); + t.props.width = own; + t.rows[0].cells[1].blocks = vec![text("Address")]; + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let [(x, _, _)] = para_at(&l, &[0, 0, 1, 0])[..] else { panic!("second cell missing") }; + x + }; + // 936pt (twice the 468pt of text) of its own: the columns stay 312pt wide, past the margin. + let x = second_column_x(Some(936.0)); + assert!(x > 72.0 + 300.0, "the second column starts at {x}, squeezed"); + // The same columns with an automatic table width are squeezed to 156pt each. + let x = second_column_x(None); + assert!(x < 72.0 + 200.0, "the second column starts at {x}, not squeezed"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index e02d4908d..1b6f0c7e2 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -41,7 +41,7 @@ font can't match Word's metrics. | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | 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; endnotes use the document's separator but don't continue onto a page of their own); 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; 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 (`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`) | — | -| 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); cell preferred widths and spans still differ | 3–5 | +| 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); 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 preferred widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | @@ -84,6 +84,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | +| 2026-10-14 | trivial | A table with its own preferred width keeps it past the margin instead of being squeezed to the margins when over 1.5× the text width (#1721) | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | From 9e608e85aae6b5e2382ebfb39887c0d72bf87f6b Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:53:28 +0200 Subject: [PATCH 02/34] #1596: tables nest 24 deep at most; a paste that would go deeper keeps their text as paragraphs Pasting a table into its own cell doubled the nesting each time, and about a dozen pastes built a document that overflowed the stack when it was saved as JSON, copied, closed or replaced by a new one. Tables now nest at most 24 deep (wordcraft_doc::table::MAX_TABLE_DEPTH), the depth the .docx reader and writer already keep. Document::insert_fragment turns a table that would go deeper into its paragraphs, in document order; Document::insert_block refuses it, so Insert > Table in a cell 24 tables deep fails with a message and leaves the document as it was. The depth is measured without recursion. Word has no limit. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/edit.rs | 130 ++++++++++++++++++++++++++++++++++++- crates/doc/src/table.rs | 4 ++ crates/engine/src/tests.rs | 21 ++++++ docs/target-app-parity.md | 5 +- 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 68835cacf..6b2f858b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: tables nest 24 deep at most, #1596; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-14 | Tables nest 24 deep at most, the depth the .docx reader and writer keep (#1596): pasting a table into its own cell again and again no longer builds a document that crashes when it is saved as JSON, copied, closed or replaced by a new one. A table that would go deeper arrives as its paragraphs, text kept, and Insert › Table in a cell 24 tables deep says why it can't. Word has no limit | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | @@ -317,6 +318,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: tables nest 24 deep at most (#1596) | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 6c36f9295..a68f7712b 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::para::InlineObject; use crate::props::{CharProps, ParaProps}; use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; +use crate::{Table, table::MAX_TABLE_DEPTH}; /// A copied piece of a document: blocks, where the first and last paragraphs may be partial. #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] @@ -58,6 +59,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } } +/// The blocks in `t`'s cells, row by row. +fn cell_blocks(t: &Table) -> impl DoubleEndedIterator { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b) +} + +/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without +/// recursion: a pasted fragment may be deep). +fn table_height(t: &Table) -> usize { + let mut most = 1; + let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect(); + while let Some((b, d)) = stack.pop() { + if let Block::Table(inner) = b { + let d = d.saturating_add(1); + most = most.max(d); + stack.extend(cell_blocks(inner).map(|cb| (cb, d))); + } + } + most +} + +/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by +/// its paragraphs (tables in its cells too, document order), as the DOCX reader does. +fn fit_tables(b: Block, room: usize, out: &mut Vec) { + match b { + Block::Table(t) if room == 0 => { + let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect(); + while let Some(cb) = stack.pop() { + match cb { + Block::Para(p) => out.push(Block::Para(p.clone())), + Block::Table(inner) => stack.extend(cell_blocks(inner).rev()), + } + } + } + Block::Table(mut t) if table_height(&t) > room => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut kept = Vec::new(); + for cb in std::mem::take(&mut cell.blocks) { + fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept); + } + cell.blocks = kept.into_iter().map(Arc::new).collect(); + } + out.push(Block::Table(t)); + } + b => out.push(b), + } +} + /// Nesting limit when following text boxes inside text boxes. const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). @@ -274,8 +322,14 @@ impl Document { Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) } - /// Insert a block before the block at `path` (same container). + /// Insert a block before the block at `path` (same container). A table that would nest + /// deeper than [`MAX_TABLE_DEPTH`] is refused. pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { + if let Block::Table(t) = &block + && path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH + { + return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep"))); + } let i = path.last(); let bl = self.container_mut(story, path)?; if i > bl.len() { @@ -563,7 +617,12 @@ impl Document { /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { - let mut blocks = frag.blocks.clone(); + // Tables that would nest deeper than the limit here become their paragraphs (#1596). + let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth()); + let mut blocks = Vec::with_capacity(frag.blocks.len()); + for b in frag.blocks.iter().cloned() { + fit_tables(b, room, &mut blocks); + } if blocks.is_empty() { return Ok(pos.clone()); } @@ -700,6 +759,73 @@ mod tests { assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); } + /// The first paragraph of the deepest cell, following the first table at each level. + fn deepest(d: &Document) -> Path { + let (mut path, mut blocks) = (Vec::new(), &d.body); + while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) { + path.extend([i as u32, 0, 0]); + blocks = &t.rows[0].cells[0].blocks; + } + path.push(0); + Path(path) + } + + fn body_height(d: &Document) -> usize { + d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0) + } + + /// How many times `c` is in `blocks`, the deepest cells too. + fn count(blocks: &[Arc], c: char) -> usize { + blocks + .iter() + .map(|b| match &**b { + Block::Para(p) => p.text.matches(c).count(), + Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(), + }) + .sum() + } + + #[test] + fn tables_nest_at_most_max_table_depth() { + let mut d = doc(&["x", ""]); + for _ in 0..MAX_TABLE_DEPTH { + let at = deepest(&d); + d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // One more in the deepest cell is refused and changes nothing; a paragraph is fine. + let (at, before) = (deepest(&d), d.clone()); + assert_eq!(at.depth(), MAX_TABLE_DEPTH); + assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err()); + assert_eq!(d, before); + d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap(); + // One level up, a table fits but a table holding a table doesn't. + let up = Path(at.0[..at.0.len() - 3].to_vec()); + let mut two = Table::new(1, 1, 100.0); + two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0)))); + assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err()); + d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + } + + #[test] + fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() { + // #1596: copy everything and paste it into the deepest cell, again and again: each round + // doubled the nesting, until saving, copying or closing the document overflowed the stack. + let mut d = doc(&["x", ""]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap(); + for _ in 0..8 { + let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0)); + let at = deepest(&d); + let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len); + d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // Nothing is lost: every copy's text is there, below the limit as paragraphs. + assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256)); + } + fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 { let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]); d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 104bf96b8..b412b47e1 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -58,6 +58,10 @@ pub struct Table { /// Hard limits that keep hostile input from exhausting memory. pub const MAX_ROWS: usize = 32_767; pub const MAX_COLS: usize = 63; +/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting +/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack +/// when it is saved, copied or dropped (#1596). +pub const MAX_TABLE_DEPTH: usize = 24; impl Table { /// A `rows × cols` table spanning `width` points, "Table Grid" style. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec8ffffa..c47ca1249 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1261,6 +1261,27 @@ fn select_extend_from_a_table_paragraph_selects_the_document() { assert_eq!(b, s.doc.end_of(StoryRef::Body)); } +/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table +/// in the deepest cell says so; a table pasted there brings its text as paragraphs. +#[test] +fn tables_nest_at_most_24_deep() { + use wordcraft_doc::table::MAX_TABLE_DEPTH; + let mut s = s(); + for _ in 0..MAX_TABLE_DEPTH { + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + } + assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH); + let before = s.doc.clone(); + let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err(); + assert!(e.to_string().contains("24 deep"), "{e}"); + assert_eq!(s.doc, before); + run(&mut s, "edit.paste", json!({"text": "b", "html": "
b
"})); + let cell = s.sel.focus.path.clone(); + let blocks: Vec<_> = (0..).map_while(|i| s.doc.block(StoryRef::Body, &cell.with_last(i))).collect(); + assert!(blocks.iter().all(|b| b.as_table().is_none()), "no table below the limit"); + assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there"); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index baa2d3c97..80b40b91b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (tables nest 24 deep at most, #1596; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -60,7 +60,7 @@ full parity for the area. | Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288), Enclose Characters (#297). Missing: Asian Typography, kerning/ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | -| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | +| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 24 deep at most, as the .docx reader and writer keep them (#1596; Word has no limit). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Tables: nesting stops at 24 levels, the depth .docx keeps (#1596); Tables row updated, no percentage change | | 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change | From 6931b8db2a501e713182085cfe10548951c0f647 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:50:12 +0200 Subject: [PATCH 03/34] Draw EMF text records (EXTTEXTOUTW) in metafile pictures Fixes #1764 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/canvas.rs | 117 +++++++++++++++++++++++++++++++++- crates/metafile/src/emf.rs | 58 ++++++++++++++++- crates/metafile/src/lib.rs | 81 ++++++++++++++++++++++- crates/metafile/src/place.rs | 76 +++++++++++++++++++++- crates/metafile/src/tests.rs | 2 +- crates/pdf/src/lib.rs | 64 ++++++++++++++++++- crates/pdf/src/tests.rs | 38 +++++++++++ crates/render/src/lib.rs | 69 ++++++++++++++++++++ 8 files changed, 494 insertions(+), 11 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 0ac4790f6..03a8f3453 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -21,6 +21,12 @@ const R2_NOP: u32 = 11; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; +/// SETTEXTALIGN: draw at and update the current point. +const TA_UPDATECP: u32 = 1; +/// Font height (cell height, logical units) of the default font, used for a height of 0. +const DEFAULT_FONT_HEIGHT: f64 = 16.0; +/// Face used when a font names none. +const DEFAULT_FONT_FACE: &str = "Arial"; /// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size. const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4; @@ -54,11 +60,23 @@ pub(crate) struct Brush { pub color: [u8; 4], } +/// A logical font (EMF LogFont): height in logical units (negative = character height, positive = +/// cell height, 0 = default), escapement in tenths of a degree, face name as UTF-16 (NUL-padded). +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Font { + pub height: i32, + pub escapement: i32, + pub weight: i32, + pub italic: bool, + pub face: [u16; 32], +} + #[derive(Clone, Copy, Debug)] pub(crate) enum Obj { Pen(Pen), Brush(Brush), - /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Font(Font), + /// Palettes, pattern brushes and regions: they take a table slot but draw nothing. Other, } @@ -168,6 +186,23 @@ fn ratio(num: f64, den: f64) -> f64 { pub(crate) enum Raw { Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, Bitmap { rect: [f64; 4], img: Rgba }, + Text(RawText), +} + +/// A text run in device units: the reference point, the font height along the text's up direction, +/// the baseline angle (radians, clockwise in y-down space) and per-character advances. +pub(crate) struct RawText { + pub text: String, + pub origin: P, + pub size: f64, + pub cell: bool, + pub angle: f64, + pub dx: Vec, + pub font: String, + pub bold: bool, + pub italic: bool, + pub color: [u8; 4], + pub align: u32, } #[derive(Clone)] @@ -177,6 +212,9 @@ struct Saved { brush: Brush, even_odd: bool, nop: bool, + font: Option, + text_color: [u8; 4], + text_align: u32, } pub(crate) struct Canvas { @@ -201,6 +239,11 @@ pub(crate) struct Canvas { even_odd: bool, /// The mix mode is R2_NOP: shapes draw nothing. nop: bool, + /// The selected font; None is the default font. + font: Option, + text_color: [u8; 4], + /// SETTEXTALIGN flags (TA_*). + text_align: u32, pos: P, in_path: bool, path: Vec, @@ -224,6 +267,9 @@ impl Canvas { brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, even_odd: true, nop: false, + font: None, + text_color: [0, 0, 0, 255], + text_align: 0, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -240,6 +286,7 @@ impl Canvas { let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), Raw::Bitmap { img, .. } => img.px.len(), + Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::(), }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; @@ -318,7 +365,16 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + self.saved.push(Saved { + map: self.map, + pen: self.pen, + brush: self.brush, + even_odd: self.even_odd, + nop: self.nop, + font: self.font, + text_color: self.text_color, + text_align: self.text_align, + }); } else { self.full = true; } @@ -335,6 +391,9 @@ impl Canvas { self.brush = s.brush; self.even_odd = s.even_odd; self.nop = s.nop; + self.font = s.font; + self.text_color = s.text_color; + self.text_align = s.text_align; } /// Stores an object in `slot` (EMF index), or in the lowest free slot (WMF handle table). @@ -384,6 +443,7 @@ impl Canvas { match self.objects.get(i as usize).copied().flatten() { Some(Obj::Pen(p)) => self.pen = p, Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Font(f)) => self.font = Some(f), Some(Obj::Other) | None => {} } } @@ -402,6 +462,8 @@ impl Canvas { 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + // The stock fonts (OEM, ANSI, system, device default...): the default font. + 10..=17 => self.font = None, _ => {} } } @@ -571,6 +633,57 @@ impl Canvas { } } + pub fn set_text_color(&mut self, c: [u8; 4]) { + self.text_color = c; + } + + pub fn set_text_align(&mut self, a: u32) { + self.text_align = a; + } + + /// Draws `text` at the logical point `at` (the current point with TA_UPDATECP) with the selected + /// font, text colour and alignment. `dx` holds per-character logical advances (empty for the + /// font's own); with TA_UPDATECP the current point moves past them. + pub fn text(&mut self, at: P, text: String, dx: &[f64]) { + if text.is_empty() || self.in_path { + return; + } + let font = self.font.unwrap_or(Font { height: 0, escapement: 0, weight: 400, italic: false, face: [0; 32] }); + let at = if self.text_align & TA_UPDATECP != 0 { self.pos } else { at }; + if self.text_align & TA_UPDATECP != 0 { + self.pos = (at.0 + dx.iter().sum::(), at.1); + } + let height = if font.height == 0 { DEFAULT_FONT_HEIGHT } else { f64::from(font.height) }; + let origin = self.map.device(at); + let len = |v: P| { + let d = self.map.device((at.0 + v.0, at.1 + v.1)); + (d.0 - origin.0).hypot(d.1 - origin.1) + }; + let base = self.map.device((at.0 + 1.0, at.1)); + let escapement = f64::from(font.escapement) / 10.0; + let angle = (base.1 - origin.1).atan2(base.0 - origin.0) - escapement.to_radians(); + let size = len((0.0, height.abs())); + let dx = dx.iter().map(|d| len((*d, 0.0)).copysign(*d)).collect(); + let name = String::from_utf16_lossy(&font.face); + let name = name.trim_end_matches('\0').trim(); + let raw = RawText { + text, + origin, + size, + cell: height > 0.0, + angle: if angle.is_finite() { angle } else { 0.0 }, + dx, + font: if name.is_empty() { DEFAULT_FONT_FACE.to_string() } else { name.to_string() }, + bold: font.weight >= 600, + italic: font.italic, + color: self.text_color, + align: self.text_align, + }; + if raw.origin.0.is_finite() && raw.origin.1.is_finite() && raw.size.is_finite() && raw.size > 0.0 { + self.push(Raw::Text(raw)); + } + } + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the /// bitmap, as the mapping does. pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 904d126b7..4ffe9cd1b 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -2,7 +2,7 @@ //! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::canvas::{BS_NULL, Brush, Canvas, Font, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; use crate::dib::{self, Blend}; use crate::geom::{self, ArcKind, P}; use crate::{Error, Frame, MAX_PATH_OPS}; @@ -15,6 +15,9 @@ const SRCCOPY: u32 = 0x00CC_0020; /// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide. const PS_TYPE_MASK: u32 = 0x000F_0000; const PS_COSMETIC: u32 = 0; +/// EXTTEXTOUT options: the string holds glyph indices, and the dx array holds (x, y) pairs. +const ETO_GLYPH_INDEX: u32 = 0x10; +const ETO_PDY: u32 = 0x2000; pub(crate) fn is_emf(b: Bytes) -> bool { b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE) @@ -100,6 +103,10 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 38 => create_pen(cv, r), 95 => create_ext_pen(cv, r), 39 => create_brush(cv, r), + 82 => create_font(cv, r), + 22 => r.u32(8).map(|a| cv.set_text_align(a)), + 24 => r.u32(8).map(|c| cv.set_text_color(colorref(c))), + 84 => ext_text_out_w(cv, r), 40 => r.u32(8).map(|i| cv.delete_object(i)), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), @@ -250,6 +257,55 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { Some(()) } +/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (height, width, escapement, orientation, weight, +/// italic, ..., face name of 32 UTF-16 units at 40). +fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let mut face = [0u16; 32]; + for (i, u) in face.iter_mut().enumerate() { + *u = r.u16(40 + i * 2).unwrap_or(0); + } + let font = Font { height: r.i32(12)?, escapement: r.i32(20)?, weight: r.i32(28)?, italic: *r.0.get(32)? != 0, face }; + cv.add_object(Some(slot), Obj::Font(font)); + Some(()) +} + +/// EMR_EXTTEXTOUTW: an EmrText at 36 with the reference point, the character count at 44, the string +/// offset at 48, the options at 52 and the dx array offset at 72 (offsets from the record start). +/// Glyph-index strings are skipped: they cannot be mapped back to characters. +fn ext_text_out_w(cv: &mut Canvas, r: Bytes) -> Option<()> { + let at = xy32(r, 36)?; + let n = r.u32(44)? as usize; + let (off, options, off_dx) = (r.u32(48)? as usize, r.u32(52)?, r.u32(72)? as usize); + if options & ETO_GLYPH_INDEX != 0 || n == 0 { + return None; + } + let units: Vec = r.slice(off, n.checked_mul(2)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + // dx has one entry per UTF-16 unit (two with ETO_PDY); a character takes the sum of its units. + let step = if options & ETO_PDY != 0 { 8 } else { 4 }; + let raw_dx: Vec = match r.slice(off_dx, n.checked_mul(step)?) { + Some(b) if off_dx != 0 => b.chunks_exact(step).filter_map(|c| Some(f64::from(i32::from_le_bytes(c.get(..4)?.try_into().ok()?)))).collect(), + _ => Vec::new(), + }; + let mut text = String::new(); + let mut dx = Vec::new(); + let mut i = 0; + for ch in char::decode_utf16(units.iter().copied()) { + let c = ch.unwrap_or(char::REPLACEMENT_CHARACTER); + // A replacement stands for one unpaired unit. + let w = if c == char::REPLACEMENT_CHARACTER { 1 } else { c.len_utf16() }; + if !raw_dx.is_empty() { + dx.push(raw_dx.get(i..i + w).map_or(0.0, |s| s.iter().sum())); + } + i += w; + text.push(c); + } + let text = text.trim_end_matches('\0').to_string(); + dx.truncate(text.chars().count()); + cv.text(at, text, &dx); + Some(()) +} + /// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at /// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead. fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> { diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cc2f64e5c..473d69baf 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -1,5 +1,5 @@ //! WordCraft metafiles: reads Windows Metafiles (WMF) and Enhanced Metafiles (EMF) into a neutral -//! vector model of filled and stroked paths and RGBA bitmaps, in a picture frame with y down. +//! vector model of filled and stroked paths, RGBA bitmaps and text runs, in a picture frame with y down. //! //! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is //! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], @@ -21,9 +21,9 @@ mod wmf; mod tests; use bytes::Bytes; -use canvas::Raw; +use canvas::{Raw, RawText}; use geom::{Op, P}; -pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; @@ -60,6 +60,46 @@ pub enum Item { }, /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, + /// A run of text; the renderers shape it with the named font (see [`Text::layout`]). + Text(Text), +} + +/// A text run, in picture units. +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + pub text: String, + /// The reference point that `halign` and `valign` refer to. + pub x: f32, + pub y: f32, + /// Font height: the em size, or the cell height (ascent + descent) when `cell` is set. + pub size: f32, + pub cell: bool, + pub font: String, + pub bold: bool, + pub italic: bool, + pub color: [u8; 4], + /// Baseline direction in radians, clockwise from +x (y down). + pub angle: f32, + pub halign: HAlign, + pub valign: VAlign, + /// Advance of each character along the baseline; empty when the font's own advances apply. + pub dx: Vec, +} + +/// Where the reference point sits along the run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HAlign { + Left, + Center, + Right, +} + +/// Where the reference point sits across the run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VAlign { + Top, + Baseline, + Bottom, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -137,11 +177,45 @@ fn build(raw: Vec, frame: Option) -> Picture { let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) } + Raw::Text(t) => text_item(t, tf, sx, sy), }); let items = items.collect(); Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } } +/// A device-space text run mapped into the picture: the reference point through `tf`, lengths by the +/// frame scale. TA_* flags become the alignment. +fn text_item(t: RawText, tf: impl Fn(P) -> P, sx: f64, sy: f64) -> Option { + let (x, y) = tf(t.origin); + let halign = match t.align & 6 { + 2 => HAlign::Right, + 6 => HAlign::Center, + _ => HAlign::Left, + }; + let valign = match t.align & 24 { + 8 => VAlign::Bottom, + 24 => VAlign::Baseline, + _ => VAlign::Top, + }; + let dx = t.dx.iter().map(|d| f(d * sx.abs())).collect::>>()?; + let size = f(t.size * sy.abs()).filter(|s| *s > 0.0)?; + Some(Item::Text(Text { + text: t.text, + x: f(x)?, + y: f(y)?, + size, + cell: t.cell, + font: t.font, + bold: t.bold, + italic: t.italic, + color: t.color, + angle: f(t.angle).unwrap_or(0.0), + halign, + valign, + dx, + })) +} + fn f(v: f64) -> Option { let v = v as f32; v.is_finite().then_some(v) @@ -201,6 +275,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { grow((rect[0], rect[1])); grow((rect[0] + rect[2], rect[1] + rect[3])); } + Raw::Text(t) => grow(t.origin), } } b diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f079553..73ac10cc3 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -3,7 +3,7 @@ use kurbo::{Affine, BezPath, Rect, Shape}; -use crate::{Item, Picture, Seg}; +use crate::{HAlign, Item, Picture, Seg, Text, VAlign}; /// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides. pub const MAX_BITMAP_SIDE: u32 = 30_000; @@ -19,6 +19,72 @@ pub enum PlacedItem { Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, /// `Picture::items[index]`, a bitmap, stretched into `rect`. Bitmap { index: usize, rect: Rect }, + /// `Picture::items[index]`, a text run. `tf` maps the run's own space (picture units, the + /// reference point at the origin, the baseline along +x, y down) to the target. + Text { index: usize, tf: Affine }, +} + +/// A text run laid out with a font's metrics: the em size (in the run's own units, see +/// [`PlacedItem::Text`]), the baseline's y and each glyph's (x, y), alignment included. +pub struct TextLayout { + pub em: f64, + pub baseline: f64, + pub glyphs: Vec<(f64, f64)>, +} + +impl Text { + /// Lays out shaped glyphs, given as (cluster byte offset, x advance, x offset, y offset) in font + /// units of a font with `upem` units per em, `ascent` above and `descent` below the baseline. + /// The per-character advances `dx` place each cluster when there is one per character. + pub fn layout(&self, upem: f64, ascent: f64, descent: f64, glyphs: &[(usize, f64, f64, f64)]) -> Option { + let upem = if upem.is_finite() && upem > 0.0 { upem } else { 1000.0 }; + let cell = ascent + descent; + let size = f64::from(self.size); + let em = if self.cell && cell.is_finite() && cell > 0.0 { size * upem / cell } else { size }; + if !em.is_finite() || em <= 0.0 { + return None; + } + let k = em / upem; + let starts: Vec = self.text.char_indices().map(|(i, _)| i).collect(); + let dx = (self.dx.len() == starts.len() && !starts.is_empty()).then(|| { + let mut cum = Vec::with_capacity(self.dx.len() + 1); + let mut x = 0.0; + cum.push(x); + for d in &self.dx { + x += f64::from(*d); + cum.push(x); + } + cum + }); + let mut out = Vec::with_capacity(glyphs.len()); + let (mut pen, mut cluster_pen, mut last) = (0.0, 0.0, None); + for &(cluster, adv, xo, yo) in glyphs { + let ci = starts.partition_point(|&b| b <= cluster).saturating_sub(1); + if last != Some(ci) { + cluster_pen = pen; + last = Some(ci); + } + let x = match &dx { + Some(cum) => cum.get(ci).copied().unwrap_or(0.0) + pen - cluster_pen, + None => pen, + }; + out.push((x + xo * k, -yo * k)); + pen += adv * k; + } + let width = dx.as_ref().and_then(|c| c.last().copied()).unwrap_or(pen); + let hoff = match self.halign { + HAlign::Left => 0.0, + HAlign::Center => -width / 2.0, + HAlign::Right => -width, + }; + let baseline = match self.valign { + VAlign::Top => ascent * k, + VAlign::Baseline => 0.0, + VAlign::Bottom => -descent * k, + }; + let glyphs = out.into_iter().map(|(x, y)| (x + hoff, y + baseline)).collect(); + Some(TextLayout { em, baseline, glyphs }) + } } impl Picture { @@ -63,6 +129,14 @@ impl Picture { out.push(PlacedItem::Bitmap { index, rect }); } } + Item::Text(t) => { + let (x, y) = (f64::from(t.x), f64::from(t.y)); + let at = tf * kurbo::Point::new(x, y); + if in_range(Rect::new(at.x, at.y, at.x, at.y)) { + let tf = tf * Affine::translate((x, y)) * Affine::rotate(f64::from(t.angle)); + out.push(PlacedItem::Text { index, tf }); + } + } } } out diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 8a93e1411..e6ffc8c34 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255 fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { match item { Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), - Item::Bitmap { .. } => panic!("expected a path"), + Item::Bitmap { .. } | Item::Text(_) => panic!("expected a path"), } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 5b302b13a..b3fb9d48b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1175,7 +1175,7 @@ impl Exporter<'_> { fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) { let Some(clip) = rect_path(rect) else { return }; if let Some(v) = self.vector(media) { - vector_picture(s, &v, rect, crop, &clip, alpha); + vector_picture(self, s, &v, rect, crop, &clip, alpha); return; } let Some(img) = self.image(media) else { @@ -1496,6 +1496,58 @@ impl Exporter<'_> { Some(o) } + /// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps + /// the run's own space (see [`PlacedItem::Text`]) to the page. + fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) { + let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; + let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); + let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; + let font = self.font(&face); + if font.is_none() && !self.outlined.contains(&face.id()) { + return; + } + let em = lay.em as f32; + let c = tf.as_coeffs().map(|v| v as f32); + if !c.iter().all(|v| v.is_finite()) || !em.is_finite() { + return; + } + let (paint, opacity) = paint_of(t.color); + s.push_transform(&Transform::from_row(c[0], c[1], c[2], c[3], c[4], c[5])); + s.set_stroke(None); + s.set_fill(Some(Fill { paint: paint.into(), opacity, rule: FillRule::NonZero })); + match font { + Some(font) => { + let upem = face.upem.max(1.0) as f32; + let kg: Vec = shaped + .iter() + .enumerate() + .map(|(i, g)| { + let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(t.text.len()); + let start = g.cluster.min(end); + let x = lay.glyphs.get(i).map_or(0.0, |p| p.0); + let adv = match lay.glyphs.get(i + 1) { + Some(n) => ((n.0 - x) / lay.em) as f32, + None => g.x_advance as f32 / upem, + }; + let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32); + KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None) + }) + .collect(); + let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32; + s.draw_glyphs(Point::from_xy(x0, lay.baseline as f32), &kg, font, &t.text, em, false); + } + None => { + let pen: Vec<(u32, f32, f32)> = shaped.iter().zip(&lay.glyphs).map(|(g, (x, y))| (g.gid, *x as f32, *y as f32)).collect(); + if let Some(path) = glyph_outlines(&face, em, &pen) { + s.draw_path(&path); + } + } + } + s.set_fill(None); + s.pop(); + } + fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32) { let r = wordcraft_fonts::word::resolve(&wm.font, false, false); let face = r.face; @@ -1588,8 +1640,9 @@ const HAIRLINE: f64 = 0.25; /// Draws a metafile picture into the clip (the picture frame): paths are filled and stroked in page /// space (strokes keep their width under non-uniform scaling), bitmaps stretched, all faded by `alpha`. -/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. -fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) { +/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. Text +/// runs are drawn as text. +fn vector_picture(ex: &mut Exporter, s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) { let target = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x + r.w), f64::from(r.y + r.h)); let fade = alpha < 0.999; if fade { @@ -1627,6 +1680,11 @@ fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: s.draw_image(img.clone(), size); s.pop(); } + PlacedItem::Text { index, tf } => { + if let Some(wordcraft_metafile::Item::Text(t)) = v.pic.items.get(index) { + ex.vector_text(s, t, tf); + } + } } } s.set_fill(None); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8e01a0f15..2144f2cf3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -965,6 +965,44 @@ fn cosmetic_metafile_pens_are_thin_but_visible() { assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide"); } +/// EMF text records: EXTCREATEFONTINDIRECTW (Arial, 20 units high) in slot 1, SELECTOBJECT, +/// SETTEXTCOLOR (black), then EXTTEXTOUTW `text` at (5, 40) with no dx array. +fn emf_text_records(text: &str) -> Vec> { + let mut font = [82i32.to_le_bytes(), 104u32.to_le_bytes(), 1i32.to_le_bytes(), (-20i32).to_le_bytes()].concat(); + font.extend([0i32, 0, 0, 400, 0, 0].iter().flat_map(|v| v.to_le_bytes())); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + let units: Vec = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let padded = units.len().div_ceil(4) * 4; + let mut out = [84u32.to_le_bytes(), ((76 + padded) as u32).to_le_bytes()].concat(); + out.extend([0i32, 0, 100, 100, 1, 0, 0, 5, 40, text.encode_utf16().count() as i32, 76, 0, 0, 0, 0, 0, 0].iter().flat_map(|v| v.to_le_bytes())); + out.extend(units); + out.resize(76 + padded, 0); + vec![font, emf_rec(37, &[1]), emf_rec(24, &[0]), out] +} + +/// #1764: the text of an EMF picture (EXTTEXTOUTW) is drawn, so it is in the PDF's text layer. +#[test] +fn metafile_text_records_are_drawn_as_text() { + let mut recs = emf_text_records("Cell42"); + recs.push(emf_rec(14, &[0; 3])); + let mut d = Document::new(); + let media = d.add_media(emf_file(&recs), "emf"); + let mut p = Paragraph::with_text("Figure: ", CharProps::default()); + let n = p.len(); + p.insert_object( + n, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap(); + let text = extract_text(&bytes).concat(); + assert!(text.contains("Cell42"), "the metafile's text is drawn: {text:?}"); +} + /// #455: a link to a bookmark after a page break inside its paragraph goes to the page the /// bookmark is on, not the paragraph's first page. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ac79f73ae..02a9262a9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -248,6 +248,7 @@ impl Vector { .map(|it| match it { Item::Path { segs, .. } => segs.len() * std::mem::size_of::(), Item::Bitmap { .. } => 0, + Item::Text(t) => t.text.len() + t.dx.len() * 4, }) .sum(); px + segs @@ -326,6 +327,12 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32 ctx.fill_rect(&rect); ctx.reset_paint_transform(); } + PlacedItem::Text { index, tf } => { + if let Some(Item::Text(t)) = v.pic.items.get(index) { + draw_vector_text(ctx, t, view * tf); + } + ctx.set_transform(view); + } } } ctx.set_fill_rule(peniko::Fill::NonZero); @@ -335,6 +342,22 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32 } } +/// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device. +fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) { + let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; + let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); + let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; + let k = lay.em / face.upem.max(1.0); + ctx.set_paint(rgba_color(t.color)); + let db = FontDb::global(); + for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { + let o = db.outline(&face, g.gid); + ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k)); + ctx.fill_path(&o); + } +} + /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. fn device_pixel(view: Affine) -> f64 { let c = view.as_coeffs(); @@ -1582,6 +1605,52 @@ mod tests { assert!(darkest < 140, "the hairline should be clearly visible: {darkest}"); } + /// #1764: an EMF picture's text (EXTCREATEFONTINDIRECTW, SELECTOBJECT, SETTEXTCOLOR, EXTTEXTOUTW) + /// is painted, not dropped. + #[test] + fn metafile_text_records_are_painted() { + let mut font = emf_rec(82, &le32(&[1, -20, 0, 0, 0, 700, 0, 0])); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + font[4..8].copy_from_slice(&104u32.to_le_bytes()); + // "WWWW" at (5, 40), top-aligned, no dx array. + let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0])); + text.extend("WWWW".encode_utf16().flat_map(|u| u.to_le_bytes())); + text[4..8].copy_from_slice(&84u32.to_le_bytes()); + let mut emf = red_right_half_emf()[..88].to_vec(); + emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat()); + let mut d = Document::new(); + let media = d.add_media(emf, "emf"); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let rect = page_display(&d, &l.pages[0], &opts.display) + .iter() + .find_map(|i| match i { + Draw::Image { rect, .. } => Some(*rect), + _ => None, + }) + .expect("a picture on the page"); + let scale = 2.0; + let img = render_page(&d, &l.pages[0], scale, &opts); + let (x0, y0) = ((rect.x * scale) as u32, (rect.y * scale) as u32); + let (x1, y1) = (((rect.x + rect.w) * scale) as u32, ((rect.y + rect.h) * scale) as u32); + let dark = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count(); + assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels"); + } + #[test] fn unreadable_metafile_keeps_the_grey_box() { let mut d = Document::new(); From 9371cb05362bcdc62623683f7ff032e70fb632ab Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:44:40 +0000 Subject: [PATCH 04/34] Return 0 for SIGN(0) in table formulas Found while testing crates/engine/src/cmd/table_formula.rs:721 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/table_formula.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index 165716d68..a68df8198 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -718,7 +718,10 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result { } "ABS" => Ok(one(name, args, here)?.abs()), "INT" => Ok(one(name, args, here)?.floor()), - "SIGN" => Ok(one(name, args, here)?.signum()), + "SIGN" => { + let x = one(name, args, here)?; + Ok(if x == 0.0 { 0.0 } else { x.signum() }) + } "ROUND" => match args { [a, b] => { let x = scalar(eval(a, here)?)?; @@ -763,6 +766,16 @@ mod tests { evaluate(t, r, c, f) } + #[test] + fn sign_of_zero_is_zero() { + let t = table(&[&["0", "5", "-5"]]); + assert_eq!(ev(&t, 0, 0, "=SIGN(0)"), Ok(0.0)); + assert_eq!(ev(&t, 0, 0, "=SIGN(-0)"), Ok(0.0)); + assert_eq!(ev(&t, 0, 1, "=SIGN(A1)"), Ok(0.0), "a cell holding 0"); + assert_eq!(ev(&t, 0, 0, "=SIGN(5)"), Ok(1.0)); + assert_eq!(ev(&t, 0, 0, "=SIGN(-5)"), Ok(-1.0)); + } + #[test] fn cell_references_count_from_one_and_from_a() { assert_eq!(cell_ref("A1"), Some((0, 0))); From 16e18d8492cc712522e3ab25534fcc7309bcb431 Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 02:40:25 +0530 Subject: [PATCH 05/34] fix(docx, doc): support VML wrap side and case-insensitive float anchors --- crates/doc/src/para.rs | 17 +++++++---- crates/docx/src/read/story.rs | 53 ++++++++++++++++++++--------------- crates/docx/tests/fixtures.rs | 24 ++++++++++++++++ 3 files changed, 66 insertions(+), 28 deletions(-) diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index ab81cc344..ba26bed87 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -77,12 +77,17 @@ pub enum WrapText { impl WrapText { /// From the OOXML value (`bothSides`, `left`, `right`, `largest`). pub fn from_ooxml(v: &str) -> Option { - match v { - "bothSides" => Some(WrapText::BothSides), - "left" => Some(WrapText::Left), - "right" => Some(WrapText::Right), - "largest" => Some(WrapText::Largest), - _ => None, + let v = v.trim(); + if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") { + Some(WrapText::BothSides) + } else if v.eq_ignore_ascii_case("left") { + Some(WrapText::Left) + } else if v.eq_ignore_ascii_case("right") { + Some(WrapText::Right) + } else if v.eq_ignore_ascii_case("largest") { + Some(WrapText::Largest) + } else { + None } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 9a39f6406..de4aa8de7 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1405,7 +1405,10 @@ impl Reader<'_> { let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#')); let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id))); let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name))); - let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0")); + let on = |name: &str| attr(name).is_none_or(|v| { + let t = v.trim(); + !t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0" + }); let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let fill = on("filled").then(|| attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten(); @@ -1805,37 +1808,43 @@ fn vml_float(shape: &El, style: &str) -> Float { let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0); let max = crate::units::MAX_LEN_PT; let behind = get("z-index").and_then(int).is_some_and(|z| z < 0); - let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") { - "square" => Wrap::Square, - "tight" => Wrap::Tight, - "through" => Wrap::Through, - "topAndBottom" => Wrap::TopAndBottom, + let wrap_el = shape.child("w10:wrap"); + let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none"); + let wrap = match wrap_type { + t if t.eq_ignore_ascii_case("square") => Wrap::Square, + t if t.eq_ignore_ascii_case("tight") => Wrap::Tight, + t if t.eq_ignore_ascii_case("through") => Wrap::Through, + t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom, _ if behind => Wrap::BehindText, _ => Wrap::InFrontOfText, }; - let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") { - "page" => Anchor::Page, - "margin" => Anchor::Margin, - "char" => Anchor::Character, - "left-margin-area" => Anchor::LeftMargin, - "right-margin-area" => Anchor::RightMargin, - "inner-margin-area" => Anchor::InsideMargin, - "outer-margin-area" => Anchor::OutsideMargin, + let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default(); + let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text"); + let h_rel = match h_rel_str { + s if s.eq_ignore_ascii_case("page") => Anchor::Page, + s if s.eq_ignore_ascii_case("margin") => Anchor::Margin, + s if s.eq_ignore_ascii_case("char") => Anchor::Character, + s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin, + s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin, + s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin, + s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin, _ => Anchor::Column, }; - let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") { - "page" => Anchor::Page, - "margin" => Anchor::Margin, - "line" => Anchor::Line, - "top-margin-area" => Anchor::TopMargin, - "bottom-margin-area" => Anchor::BottomMargin, - "inner-margin-area" => Anchor::InsideMargin, - "outer-margin-area" => Anchor::OutsideMargin, + let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text"); + let v_rel = match v_rel_str { + s if s.eq_ignore_ascii_case("page") => Anchor::Page, + s if s.eq_ignore_ascii_case("margin") => Anchor::Margin, + s if s.eq_ignore_ascii_case("line") => Anchor::Line, + s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin, + s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin, + s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin, + s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin, _ => Anchor::Paragraph, }; let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0); let mut f = Float { wrap, + wrap_text, h_rel, v_rel, x: (off("left") + off("margin-left")).clamp(-max, max), diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2c36a3bb4..a41914b94 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2830,3 +2830,27 @@ fn row_table_borders_round_trip() { let again = wordcraft_docx::read(&saved).unwrap(); assert_eq!(d.body, again.body); } + +#[test] +fn vml_shape_wrap_side_and_anchor_case_insensitivity() { + let body = r##" + + + + + + + + "##; + let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = doc.body.iter().find_map(|b| b.as_para()).unwrap(); + let shape = match p.objects.first() { + Some(InlineObject::Shape { fill, float, .. }) => (fill, float), + o => panic!("expected shape, got {o:?}"), + }; + assert_eq!(*shape.0, None); + assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square); + assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left); + assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page); + assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin); +} From 8711f5cfd1e6a30a79a67b91fcebcbc325108425 Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 03:05:52 +0530 Subject: [PATCH 06/34] fix(docx): inspect textbox style for v-text-anchor and handle case-insensitivity --- crates/docx/src/read/story.rs | 16 ++++++++++------ crates/docx/tests/fixtures.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 9a39f6406..39617bd36 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1437,14 +1437,18 @@ impl Reader<'_> { } part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); // Resize shape to fit text (DrawingML's `a:spAutoFit`). - let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text"); + let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text") + .or_else(|| vml_style(style, "mso-fit-shape-to-text")); part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); // Where the text sits between the box's top and bottom (DrawingML's `anchor`). - if let Some(a) = vml_style(style, "v-text-anchor") { - part.body.anchor = match a { - a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center, - a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom, - _ => wordcraft_doc::props::VAlign::Top, + if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) { + let al = a.trim().to_ascii_lowercase(); + part.body.anchor = if al.starts_with("middle") || al == "center" { + wordcraft_doc::props::VAlign::Center + } else if al.starts_with("bottom") { + wordcraft_doc::props::VAlign::Bottom + } else { + wordcraft_doc::props::VAlign::Top }; } } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2c36a3bb4..96cbcba62 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2830,3 +2830,29 @@ fn row_table_borders_round_trip() { let again = wordcraft_docx::read(&saved).unwrap(); assert_eq!(d.body, again.body); } + +#[test] +fn vml_textbox_style_anchor_and_fit() { + let body = r#" + + + + + + Centered text + + + + + + "#; + let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = doc.body.first().unwrap().as_para().unwrap(); + let story_id = match p.objects.first() { + Some(InlineObject::Shape { story: Some(id), .. }) => *id, + o => panic!("expected shape with story, got {o:?}"), + }; + let tb = doc.parts.get(&story_id).unwrap(); + assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center); + assert!(tb.body.auto_fit); +} From 193b82d5705396e4b91186361a1f021150eb8919 Mon Sep 17 00:00:00 2001 From: Nghiathan13 Date: Fri, 16 Oct 2026 10:35:52 +0700 Subject: [PATCH 07/34] #2277: preview rotated object pixels while dragging --- ROADMAP.md | 6 +- crates/ui-egui/src/objects.rs | 183 +++++++++++++++++++++++++++++++++- docs/gaps.md | 5 +- docs/ui-parity.md | 5 +- 4 files changed, 190 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b..d97b8d29a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (live rotation preview while dragging, #2277; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (live rotation preview while dragging, #2277; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-16 | Pictures and shapes without text stories turn visibly while dragging the rotation handle (#2277), with Shift snapping to 15° steps; the document changes only on release through one `arrange.rotation` command, and Escape cancels the preview | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -403,6 +404,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Progress log: live rotation preview while dragging (#2277); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 37af3f7b8..32c8b0b91 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -450,10 +450,11 @@ fn behind_drag_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, .map(|o| (o, Grab::Move)) } -/// Whether a drag shows the object's pixels (moved, or scaled for an unturned picture) rather -/// than an outline: resizing reflows a text box (and redraws a shape). +/// Whether a drag shows the object's pixels rather than an outline. Resizing reflows a text +/// box (and redraws a shape). Text-box text doesn't yet follow its frame's committed rotation, +/// so turning one keeps the outline preview rather than promising rotated text on release. fn shows_pixels(d: &ObjectDrag) -> bool { - (d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move + (d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none()) } /// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points @@ -520,6 +521,18 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.drag.moved) { let Some(to) = screen(pages, scale, d.drag.to_page, d.drag.rect) else { return }; if d.drag.grab == Grab::Rotate { + if let Some(tex) = &d.preview + && let Some(bounds) = screen(pages, scale, d.drag.page, d.object.bounds()) + { + // The cached pixels already contain the starting rotation and flips. Turn + // only by the additional angle; changing UVs would mirror them a second time. + let mut mesh = egui::Mesh::with_texture(tex.id()); + let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + mesh.add_rect_with_uv(bounds, uv, Color32::from_white_alpha(PREVIEW_OPACITY)); + let delta = wordcraft_geom::normalize_degrees(d.drag.deg - d.object.spin.deg); + mesh.rotate(egui::emath::Rot2::from_angle(delta.to_radians()), to.center()); + painter.add(egui::Shape::mesh(mesh)); + } frame::paint_turning(painter, t, to, d.drag.deg); return; } @@ -912,4 +925,168 @@ mod tests { button(&h, end, false); steps(&mut h, 3); } + + /// A settled canvas for the rotation regressions, without taking focus on the desktop. + fn rotation_window(app: WordApp) -> crate::table_resize::tests::H { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + crate::table_resize::tests::steps(&mut h, 6); + h + } + + /// #2277: the actual picture pixels follow the rotation handle before release, including + /// pictures whose cached preview already contains a rotation and either reflection. + #[test] + fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() { + use crate::table_resize::tests::{H, button, steps}; + for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] { + // Four original colour quadrants make both accidental extra rotation and reflected + // UVs observable. Sample near the long ends after a quarter turn, outside the old + // picture, so the stationary page image cannot satisfy the pixel assertions. + let colours = [[230, 20, 20, 255], [20, 200, 20, 255], [20, 20, 230, 255], [230, 200, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| { + let quadrant = match (x < 40, y < 20) { + (true, true) => 0, + (false, true) => 1, + (false, false) => 2, + (true, false) => 3, + }; + image::Rgba(colours[quadrant]) + }); + let mut bytes = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let picture = InlineObject::Image { + media: "rotation-test".into(), + w: 200.0, + h: 100.0, + alt: String::new(), + float: wordcraft_doc::para::Float { + wrap: Wrap::InFrontOfText, + x: 150.0, + y: 180.0, + rot: initial, + flip_h, + flip_v, + ..Default::default() + }, + crop: [0.0; 4], + ole: None, + }; + let (mut app, _) = app_with(picture); + app.session.doc.media.insert("rotation-test".into(), Arc::new(bytes)); + app.session.touch(); + app.run("view.commentsPane", json!({"value": false})).unwrap(); + app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap(); + let mut h: H = rotation_window(app); + let depth = h.state().session.undo_depth(); + let original = serde_json::to_value(&h.state().session.doc).unwrap(); + let o = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap(); + let (cx, cy) = (o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0); + let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap(); + let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, o.rect.y).unwrap(); + let radius = center.distance(top) + 22.0; + let handle = |degrees: f32| center + egui::emath::Rot2::from_angle(degrees.to_radians()) * egui::vec2(0.0, -radius); + let target = wordcraft_geom::normalize_degrees(initial + 90.0); + let start = handle(initial); + let end = handle(target); + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + let d = h.state().canvas.obj_drag.as_ref().expect("rotating"); + assert_eq!(d.drag.grab, Grab::Rotate); + assert_eq!(d.drag.deg, target); + let texture = d.preview.as_ref().expect("cached picture preview").id(); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "dragging must not edit the document"); + assert_eq!(h.state().session.undo_depth(), depth); + let rendered = h.render().unwrap(); + if initial == 0.0 + && let Ok(path) = std::env::var("WORDCRAFT_ROTATION_SHOT") + { + rendered.save(path).unwrap(); + } + let ppp = h.ctx.pixels_per_point(); + let spin = wordcraft_geom::Spin::new(target, flip_h, flip_v); + for ((x, y), colour) in [(-75.0, -20.0), (75.0, -20.0), (75.0, 20.0), (-75.0, 20.0)].into_iter().zip(colours) { + let (x, y) = spin.apply(cx, cy, cx + x, cy + y); + let at = crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap(); + let pixel = rendered.get_pixel((at.x * ppp).round() as u32, (at.y * ppp).round() as u32).0; + for (actual, expected) in pixel.into_iter().zip(colour).take(3) { + if expected > 150 { + assert!(actual > 170, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}"); + } else { + assert!(actual < 150, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}"); + } + } + } + // A second angle reuses the raster rather than regenerating it on pointer moves. + let next = handle(target + 15.0); + h.event(egui::Event::PointerMoved(next)); + steps(&mut h, 2); + assert_eq!(h.state().canvas.obj_drag.as_ref().unwrap().preview.as_ref().unwrap().id(), texture); + let final_angle = h.state().canvas.obj_drag.as_ref().unwrap().drag.deg; + button(&h, next, false); + steps(&mut h, 3); + assert!(h.state().canvas.obj_drag.is_none()); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one command on release"); + assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, final_angle); + h.state_mut().run("edit.undo", json!({})).unwrap(); + steps(&mut h, 3); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "one undo restores everything"); + + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + assert!(h.state().canvas.obj_drag.as_ref().unwrap().preview.is_some()); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + button(&h, end, false); + steps(&mut h, 3); + assert!(h.state().canvas.obj_drag.is_none()); + assert_eq!(h.state().session.undo_depth(), depth, "Escape adds no undo step"); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "Escape cancels without an edit"); + } + } + + #[test] + fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() { + use crate::table_resize::tests::{button, steps}; + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default()); + app.run("insert.textBox", json!({"text": "Text stays upright on release"})).unwrap(); + let bx = app.session.layout().find_object(0, |o| o.text_box.is_some()).unwrap(); + app.run("select.range", json!({"anchor": bx.pos(), "focus": Pos { off: bx.off + OBJ.len_utf8(), ..bx.pos() }})).unwrap(); + app.run("arrange.bounds", json!({"page": 0, "x": 150, "y": 180, "width": 200, "height": 100})).unwrap(); + let mut h = rotation_window(app); + let original = serde_json::to_value(&h.state().session.doc).unwrap(); + let depth = h.state().session.undo_depth(); + let bx = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).unwrap(); + let (cx, cy) = (bx.rect.x + bx.rect.w / 2.0, bx.rect.y + bx.rect.h / 2.0); + let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap(); + let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, bx.rect.y).unwrap(); + let radius = center.distance(top) + 22.0; + let (start, end) = (center - egui::vec2(0.0, radius), center + egui::vec2(radius, 0.0)); + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + let d = h.state().canvas.obj_drag.as_ref().expect("turning the text box frame"); + assert_eq!((d.drag.grab, d.drag.deg), (Grab::Rotate, 90.0)); + assert!(d.preview.is_none(), "do not preview rotated text that would snap upright on release"); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original); + assert_eq!(h.state().session.undo_depth(), depth); + button(&h, end, false); + steps(&mut h, 3); + assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0); + assert_eq!(h.state().session.undo_depth(), depth + 1); + } } diff --git a/docs/gaps.md b/docs/gaps.md index 3d1d7fdaf..4920061ca 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (live rotation preview while dragging, #2277; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -33,7 +33,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian and Czech only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, 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) and Russian (the installed `ru_RU` dictionary, 2026-10-12), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish and Russian | 20–40 | [localization-parity.md](localization-parity.md) | -| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; 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), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [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**: text in a freely rotated text box (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), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [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**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [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); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, 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), no combo/3D/radar/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) | @@ -87,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | #10 narrowed: pictures and shapes without text stories preview rotation while dragging (#2277); remaining object gaps and estimates unchanged | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1c503f5c..14cc8c05c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (live rotation preview while dragging, #2277; previously trivial: Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -33,7 +33,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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) | ✅ | 90% | 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, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — | -| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), 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 | +| 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); 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 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | @@ -59,6 +59,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Rotation handle: pictures and shapes without text stories turn with the preview while the pointer is held; text boxes retain frame-only previews; release commits one `arrange.rotation` command, Escape cancels (#2277) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | From 3e2cf5edb445111d84495d139f8e3e6957124411 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Fri, 16 Oct 2026 11:25:51 +0100 Subject: [PATCH 08/34] DOCX: preserve text boxes containing pictures (task 256) --- ROADMAP.md | 4 +++- crates/docx/src/read/story.rs | 4 ++-- crates/docx/tests/text_boxes.rs | 38 +++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- docs/gaps.md | 3 ++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b..4ce94a605 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-16 | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -403,6 +404,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 9a39f6406..2ad50d6b8 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -999,8 +999,8 @@ impl Reader<'_> { self.read_canvas(sc, c, rels, w, h, float) } else if let Some(g) = gd.child("wpg:wgp") { self.read_group(sc, g, rels, w, h, float)? - } else if gd.find("a:blip").is_some() { - self.read_pic(gd, rels, w, h, alt.clone(), float)? + } else if let Some(pic) = gd.child("pic:pic") { + self.read_pic(pic, rels, w, h, alt.clone(), float)? } else { let wsp = gd.find("wps:wsp")?; let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 7efe7b040..9fefa22eb 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -195,3 +195,41 @@ fn hidden_drawings_stay_hidden() { assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}"); assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want); } + +#[test] +fn picture_in_box_keeps_its_frame() { + use wordcraft_doc::{Paragraph, Pos, StoryRef}; + + let mut d = Document::from_text("Anchor"); + let body = TextBody { anchor: VAlign::Center, inset: Some([0; 4]), ..Default::default() }; + let outer = text_box(&mut d, "Label", body); + let id = outer.text_box().unwrap(); + let mut bytes = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let media = d.add_media(bytes.into_inner(), "png"); + let image = InlineObject::Image { + media: media.clone(), + w: 90.0, + h: 36.0, + alt: "Synthetic marker".into(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + let mut p = Paragraph::new(); + p.insert_object(0, image, &Default::default()).unwrap(); + d.parts.get_mut(&id).unwrap().blocks.push(para_block(p)); + d.insert_object(&Pos::body(0, 0), outer, &Default::default()).unwrap(); + + let read = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let ids = boxes(&read); + assert_eq!(ids.len(), 1, "a nested picture must not replace its text box"); + let id = ids[0]; + assert_eq!(read.parts[&id].body, body); + assert!(read.plain_text(StoryRef::Part(id)).starts_with("Label")); + let p = read.para(StoryRef::Part(id), &wordcraft_doc::Path::top(1)).unwrap(); + assert!(matches!(&p.objects[0], InlineObject::Image { w, h, alt, .. } + if *w == 90.0 && *h == 36.0 && alt == "Synthetic marker")); + let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 38a1638f1..f8ef73366 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (every table style part round-trips, #714); previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes; previously trivial: every table style part round-trips, #714); previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -210,6 +210,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | diff --git a/docs/gaps.md b/docs/gaps.md index 3d1d7fdaf..c9f4d22fe 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -87,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | From da99deb3aa1d8d1e10211fc1c53371baa0fec4c8 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:18:11 +0200 Subject: [PATCH 09/34] gap: single selected cell's border drags in its row only Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/caret.rs | 22 ++++++++- crates/engine/src/cmd/table_resize.rs | 70 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index c4e38a2ba..ccc9990a3 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -378,8 +378,28 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult { select_unit(s, Unit::Sentence) } +/// A triple-click on the only paragraph of a table cell selects the cell (Word takes its end +/// mark too, which shades it), so its borders then move on their own (`table.resizeDrag`). fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { - select_unit(s, Unit::Paragraph) + let r = select_unit(s, Unit::Paragraph); + let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone()); + let n = a.path.0.len(); + if a.story == b.story + && a.path.0.len() == b.path.0.len() + && let Some(tp) = a.path.0.get(..n.saturating_sub(3)) + && !tp.is_empty() + && a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1)) + && let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2))) + { + let tp = wordcraft_doc::Path(tp.to_vec()); + let (row, cell) = (row as usize, cell as usize); + let only = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).is_some_and(|c| c.blocks.len() == 1); + let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell)); + if let (true, Some(cols)) = (only, grid) { + return super::table::select_range(s, a.story, &tp, (row, row), cols); + } + } + r } /// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 70f04f0c4..8abadaffa 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -1016,4 +1016,74 @@ mod tests { assert_eq!(first_table(&s).grid.len(), 3, "no new grid line"); assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows"); } + + /// Selects the cell at (row, cell) one of the ways Word does: Select Cell, the left-edge + /// arrow, or a triple-click on its text. + fn select_one_cell(s: &mut Session, how: &str, r: usize, c: usize) { + let b = cell_rect(s, r, c); + let y = b.y + b.h / 2.0; + match how { + "arrow" => { + s.run("table.selectAt", &json!({"page": 0, "x": b.x + 4.5, "y": y})).unwrap(); + } + "triple" => { + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("select.paragraph", &json!({})).unwrap(); + } + _ => { + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("table.selectCell", &json!({})).unwrap(); + } + } + } + + /// Each way of selecting one cell makes its right and its left border move in its row + /// only; the neighbour on that side gives up or gains the room and the row keeps its width. + #[test] + fn a_single_selected_cells_borders_move_only_in_its_row() { + for how in ["cell", "arrow", "triple"] { + for left in [false, true] { + let mut s = table_session(3, 3); + let (a, b, c) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + let y = b.y + b.h / 2.0; + select_one_cell(&mut s, how, 1, 1); + let start = first_table(&s); + let x = if left { b.x } else { b.right() }; + s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": y, "toX": x + 30.0, "toY": y})).unwrap(); + let (a1, b1, c1) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + if left { + assert!((b1.w - (b.w - 30.0)).abs() < 1.0 && (a1.w - (a.w + 30.0)).abs() < 1.0, "{how} left: {a1:?} {b1:?}"); + assert!((c1.w - c.w).abs() < 0.5, "{how} left"); + } else { + assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{how} right: {b1:?} {c1:?}"); + assert!((a1.w - a.w).abs() < 0.5, "{how} right"); + } + assert!((c1.right() - c.right()).abs() < 0.5, "{how}: the row keeps its width"); + for r in [0, 2] { + assert!( + (cell_rect(&mut s, r, 1).x - b.x).abs() < 0.5 && (cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5, + "{how}: row {r} unchanged" + ); + } + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start, "{how}: one undo step"); + } + } + } + + /// A caret, a part of a cell's text, or another cell's border with a cell selected: the + /// whole column moves. + #[test] + fn without_that_cell_selected_the_whole_column_moves() { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "caret only"); + s.run("edit.undo", &json!({})).unwrap(); + select_one_cell(&mut s, "arrow", 2, 2); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected"); + } } From d9467df1c0d991ef821a23f53f283fdedd90c2f3 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:47:03 +0200 Subject: [PATCH 10/34] gap: single cell border: review round 1 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/caret.rs | 5 ++- crates/engine/src/cmd/table_resize.rs | 53 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index ccc9990a3..4ab47171f 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -378,7 +378,7 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult { select_unit(s, Unit::Sentence) } -/// A triple-click on the only paragraph of a table cell selects the cell (Word takes its end +/// A triple-click on the last paragraph of a table cell selects the cell (Word takes its end /// mark too, which shades it), so its borders then move on their own (`table.resizeDrag`). fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { let r = select_unit(s, Unit::Paragraph); @@ -393,7 +393,8 @@ fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { { let tp = wordcraft_doc::Path(tp.to_vec()); let (row, cell) = (row as usize, cell as usize); - let only = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).is_some_and(|c| c.blocks.len() == 1); + let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1)); + let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l)); let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell)); if let (true, Some(cols)) = (only, grid) { return super::table::select_range(s, a.story, &tp, (row, row), cols); diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 8abadaffa..20af69360 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -1085,5 +1085,58 @@ mod tests { select_one_cell(&mut s, "arrow", 2, 2); s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected"); + s.run("edit.undo", &json!({})).unwrap(); + // All of a cell's text, picked as a range (no cell mark): not a cell selection. + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("text.insert", &json!({"text": "Wide"})).unwrap(); + s.run("select.text", &json!({"text": "Wide"})).unwrap(); + let (anchor, focus) = (serde_json::to_value(&s.sel.anchor).unwrap(), serde_json::to_value(&s.sel.focus).unwrap()); + s.run("select.range", &json!({"anchor": anchor, "focus": focus})).unwrap(); + assert!(s.cell_range().is_none()); + let b = cell_rect(&mut s, 1, 1); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "all of a cell's text selected"); + } + + /// The jagged row a single cell's drag leaves is saved to .docx and read back the same. + #[test] + fn a_jagged_row_survives_a_docx_round_trip() { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + select_one_cell(&mut s, "arrow", 1, 1); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + let t = first_table(&s); + let bytes = crate::io::save_bytes("jagged.docx", &s.doc).unwrap(); + let doc = crate::io::open_bytes("jagged.docx", &bytes).unwrap(); + let back = doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap(); + let spans = |t: &Table| t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect::>()).collect::>(); + assert_eq!(spans(&back), spans(&t)); + let mut s2 = Session::new(doc); + for c in 0..spans(&t)[1].len() { + assert!((cell_rect(&mut s2, 1, c).w - cell_rect(&mut s, 1, c).w).abs() < 1.0, "cell {c}"); + } + } + + /// In a cell of two paragraphs, a triple-click on the last one selects the cell (its border + /// then moves in its row only); on the first one it selects text, and the whole column moves. + #[test] + fn triple_click_selects_a_cell_only_on_its_last_paragraph() { + for last in [true, false] { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap(); + s.run("text.insert", &json!({"text": "One"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Two"})).unwrap(); + s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap(); + s.run("select.paragraph", &json!({})).unwrap(); + assert_eq!(s.cell_range().is_some(), last); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + let moved = (cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0; + assert_eq!(moved, !last, "last paragraph {last}: the column moves only without a cell selection"); + } } } From 5aa87f27f3573549fdada6927411ea56f14ad49c Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:43:19 +0200 Subject: [PATCH 11/34] gap: single cell border: only a triple-click selects the cell, not the ribbon's Select Paragraph Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/caret.rs | 10 +++++++--- crates/engine/src/cmd/table_resize.rs | 5 ++++- crates/ui-egui/src/canvas.rs | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index 4ab47171f..12a65494f 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -378,10 +378,14 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult { select_unit(s, Unit::Sentence) } -/// A triple-click on the last paragraph of a table cell selects the cell (Word takes its end -/// mark too, which shades it), so its borders then move on their own (`table.resizeDrag`). -fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { +/// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word +/// takes its end mark too, which shades it), so its borders then move on their own +/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected. +fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult { let r = select_unit(s, Unit::Paragraph); + if v.get("cell").and_then(Value::as_bool) != Some(true) { + return r; + } let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone()); let n = a.path.0.len(); if a.story == b.story diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 20af69360..af51c22fd 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -1028,7 +1028,7 @@ mod tests { } "triple" => { s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); - s.run("select.paragraph", &json!({})).unwrap(); + s.run("select.paragraph", &json!({"cell": true})).unwrap(); } _ => { s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); @@ -1131,6 +1131,9 @@ mod tests { s.run("text.insert", &json!({"text": "Two"})).unwrap(); s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap(); s.run("select.paragraph", &json!({})).unwrap(); + assert!(s.cell_range().is_none(), "the ribbon's Select Paragraph never selects the cell"); + s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap(); + s.run("select.paragraph", &json!({"cell": true})).unwrap(); assert_eq!(s.cell_range().is_some(), last); let b = cell_rect(&mut s, 1, 1); let y = b.y + b.h / 2.0; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38f8d1d21..88e645c55 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2372,7 +2372,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo return; } if resp.triple_clicked() { - let _ = app.run("select.paragraph", json!({})); + let _ = app.run("select.paragraph", json!({"cell": true})); return; } let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() && !over_balloon(app, p); From 635137b76906a5f7d80e8f8fab609817372237f1 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:43:27 +0200 Subject: [PATCH 12/34] gap: single cell border: progress docs after rebase Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +++- docs/ui-parity.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2d9807f84..a7dca6f65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a single selected cell's border drags in its row only; triple-click on a cell's last paragraph selects the cell; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | What landed | | 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. | +| 2026-10-18 | Dragging a single selected cell's border (Select Cell, left-edge arrow, triple-click on its last paragraph) moves it in its row only; the neighbour takes up the difference. Checked against Word for Mac 16.113.4. | | 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 | | 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip | | 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved | @@ -481,6 +482,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only | | 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) | | 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 55801b0e7..c3200d801 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a single selected cell's border drags in its row only; triple-click on a cell's last paragraph selects the cell; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -70,6 +70,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | Tables: a single selected cell's border drags in its row only (Select Cell, left-edge arrow, triple-click); checked against Word for Mac 16.113.4 | | 2026-10-16 | trivial | Dragging the vertical scrollbar shows a tip beside the pointer with the page at the top of the window ("Page 7 of 10"); nothing said where the drag would land (#2551) | | 2026-10-15 | trivial | File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does (it quit the whole app); the window's close button still asks to save and quits (#1712) | | 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. | From 943fe2c8ae1e9b7a421176150847de6324f8c9ac Mon Sep 17 00:00:00 2001 From: SlavenDj Date: Mon, 19 Oct 2026 21:05:17 +0200 Subject: [PATCH 13/34] Crop any part of a picture: a side can take 95% of the source, not 45% (#3135) --- crates/engine/src/cmd/objects.rs | 47 ++++++++++++++++++++++++++++---- crates/ui-egui/src/crop.rs | 32 +++++++++++++++------- crates/ui-egui/src/ribbon.rs | 7 +++-- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index a750f67d2..d83b3169b 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { .when(has_sizable), CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop) .params( - r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.45; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#, + r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#, ) .when(has_picture), // Kept for scripts written before every object had alt text: `object.altText` on a picture. @@ -980,13 +980,17 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult { /// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays /// visible keeps its place on the page: a crop handle drop is one command. fn crop(s: &mut Session, v: &Value) -> CmdResult { - let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45)); + let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95)); + // The two sides of an axis keep at least 5% of the source between them, as the renderer + // draws it: the second side stops where the first one already left less than that. + c[2] = c[2].min(0.95 - c[0]); + c[3] = c[3].min(0.95 - c[1]); let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0)); let (nw, nh) = (size("width"), size("height")); with_obj(s, |o| { if let InlineObject::Image { crop, w, h, float, .. } = o { // The source's full size at the new display size (the visible part is 1-lo-hi of it). - let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.1); + let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05); // Flipped, the source's right (bottom) edge is the one on the page's left (top). let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 }); if let Some(nw) = nw { @@ -1035,7 +1039,7 @@ pub fn original_size(s: &Session, media: &str) -> Option { pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> { let InlineObject::Image { media, crop, .. } = o else { return None }; let orig = original_size(s, media)?; - let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.1); + let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05); Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3]))) } @@ -2273,14 +2277,45 @@ mod tests { assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1])); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(image(&s), (200.0, 100.0, [0.0; 4])); - // Clamps: each side at most 0.45; hostile sizes are ignored or clamped, never applied raw. + // Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw. s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap(); let (w, h, crop) = image(&s); - assert_eq!(crop, [0.45, 0.0, 0.0, 0.0]); + assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]); assert_eq!(w, 200.0); assert!(h >= 1.0, "{h}"); } + /// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to + /// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%. + #[test] + fn a_picture_can_be_cropped_to_any_part_of_itself() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data})).unwrap(); + s.run("picture.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap(); + let crop = |s: &Session| match selected(s) { + Some((_, InlineObject::Image { crop, .. })) => crop, + other => panic!("expected image, got {other:?}"), + }; + let left = |s: &Session| { + let (_, o) = selected(s).unwrap(); + super::cropped_original_size(s, &o).unwrap().0 + }; + // 90% off the left keeps the rightmost tenth: the side is not held at 45%. + s.run("picture.crop", &json!({"left": 0.9})).unwrap(); + assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]); + assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s)); + // With the left at 0.9 the right can take only 5% more, and what the crop leaves of the + // original follows the crop down to that 5%. + s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap(); + let c = crop(&s); + assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}"); + assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s)); + // A single side past the cap stops at 0.95. + s.run("picture.crop", &json!({"right": 4.0})).unwrap(); + assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]); + } + /// Cropping a floating picture's left or top with its new size keeps the visible part where /// it is on the page: the frame moves by the trimmed amount, in the same undo step. #[test] diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index b24d483a2..05dec4d29 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -19,8 +19,8 @@ use crate::WordApp; use crate::frame::{self, Handle}; use crate::theme::Tokens; -/// The most of the source one side can crop away (as `picture.crop` clamps). -const MAX_SIDE: f32 = 0.45; +/// The most of the source one side can crop away (as `picture.crop` and the renderer clamp). +const MAX_SIDE: f32 = 0.95; /// Pointer travel that turns a press on a handle into a drag (screen points). const DRAG_START: f32 = 3.0; /// Crop handles: arm length of a corner's L, length of an edge bar, thickness (screen points). @@ -51,10 +51,10 @@ const MAX_SIZE: f32 = 4000.0; /// stops there), unless it was already outside it. fn axis(len: f32, lo: f32, hi: f32, grab: i8, d: f32) -> (f32, f32, f32, f32) { // The source's full length at this scale; the visible part is 1-lo-hi of it. - let full = len / (1.0 - lo - hi).max(0.1); + let full = len / (1.0 - lo - hi).max(0.05); // How far the grabbed side's crop may go: at most MAX_SIDE, and the length within its range. let range = |cur: f32, other: f32| { - let most = (1.0 - other - MIN_SIZE / full).min(MAX_SIDE).max(cur); + let most = (1.0 - other - MIN_SIZE / full).min(MAX_SIDE - other).max(cur); let least = (1.0 - other - MAX_SIZE / full).max(0.0).min(cur); (least, most) }; @@ -316,8 +316,8 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p // The crop as it shows: a flipped picture shows its source's right on the left. let c = on_screen(c, flip_h, flip_v); // The whole source, at the picture's scale, around the visible part. - let fw = frame_pt.w / (1.0 - c[0] - c[2]).max(0.1); - let fh = frame_pt.h / (1.0 - c[1] - c[3]).max(0.1); + let fw = frame_pt.w / (1.0 - c[0] - c[2]).max(0.05); + let fh = frame_pt.h / (1.0 - c[1] - c[3]).max(0.05); let full_pt = wordcraft_geom::Rect::new(frame_pt.x - c[0] * fw, frame_pt.y - c[1] * fh, fw, fh); let (Some(f), Some(full)) = (screen(pages, scale, page, frame_pt), screen(pages, scale, page, full_pt)) else { return false }; let tex = source(app, painter.ctx(), &media); @@ -410,9 +410,9 @@ mod tests { #[test] fn crops_are_clamped_and_never_go_past_the_source() { - // At most 0.45 a side. + // At most 0.95 a side. let r = cropped(200.0, 100.0, [0.0; 4], H(-1, 0), 1000.0, 0.0); - assert!((r.crop[0] - 0.45).abs() < 1e-4 && (r.w - 110.0).abs() < 1e-3 && (r.dx - 90.0).abs() < 1e-3, "{r:?}"); + assert!((r.crop[0] - 0.95).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3 && (r.dx - 190.0).abs() < 1e-3, "{r:?}"); // Out past the original edge: back to uncropped, no further. let r = cropped(160.0, 100.0, [0.0, 0.0, 0.2, 0.0], H(1, 0), 100.0, 0.0); assert!(close(r, Cropped { crop: [0.0; 4], w: 200.0, h: 100.0, dx: 0.0, dy: 0.0 }), "{r:?}"); @@ -420,6 +420,18 @@ mod tests { assert!(close(r, Cropped { crop: [0.0; 4], w: 200.0, h: 100.0, dx: 0.0, dy: -10.0 }), "{r:?}"); } + /// #3135: a side can take almost the whole source (5% stays), so any part of the picture can + /// be kept, not only its middle; the two sides of an axis keep their 5%. + #[test] + fn either_side_can_crop_almost_all_the_source_away() { + // Drag the left handle 190 pt across a 200 pt picture: 10 pt (5%) stay. + let r = cropped(200.0, 100.0, [0.0; 4], H(-1, 0), 190.0, 0.0); + assert!((r.crop[0] - 0.95).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3 && (r.dx - 190.0).abs() < 1e-3, "{r:?}"); + // The right side already takes 0.6: the left stops at 0.35, leaving 5%. + let r = cropped(80.0, 100.0, [0.0, 0.0, 0.6, 0.0], H(-1, 0), 1000.0, 0.0); + assert!((r.crop[0] - 0.35).abs() < 1e-4 && (r.crop[2] - 0.6).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3, "{r:?}"); + } + #[test] fn dragging_back_out_restores_what_was_cropped() { let a = cropped(200.0, 100.0, [0.0; 4], H(1, 1), -30.0, -20.0); @@ -439,9 +451,9 @@ mod tests { assert_eq!(cropped(0.0, 100.0, [0.0; 4], H(-1, 0), 10.0, 0.0), same(0.0, 100.0, [0.0; 4])); assert_eq!(cropped(-5.0, 100.0, [0.0; 4], H(1, 1), 10.0, 10.0), same(-5.0, 100.0, [0.0; 4])); assert_eq!(cropped(200.0, f32::NAN, [0.0; 4], H(0, 1), 0.0, 10.0), same(200.0, 0.0, [0.0; 4])); - // A junk crop reads as uncropped / at most 0.45 a side; a zero drag leaves that be. + // A junk crop reads as uncropped / at most 0.95 a side; a zero drag leaves that be. let r = cropped(200.0, 100.0, [f32::NAN, -1.0, 0.7, 0.0], H(-1, -1), 0.0, 0.0); - assert_eq!(r, same(200.0, 100.0, [0.0, 0.0, 0.45, 0.0])); + assert_eq!(r, same(200.0, 100.0, [0.0, 0.0, 0.7, 0.0])); } /// Sizes stay within what `picture.crop` accepts (1–4000 pt) at the same scale, so the diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1c45f5f68..08e63e603 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2646,13 +2646,16 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { for (i, side) in ["L", "T", "R", "B"].iter().enumerate() { ui.label(egui::RichText::new(tl!(side)).small()); let mut v = crop0[i] * 100.0; - let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=45.0).suffix("%")); + let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=95.0).suffix("%")); if r.changed() { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); } let mut c = crop0; - c[i] = (v / 100.0).clamp(0.0, 0.45); + c[i] = (v / 100.0).clamp(0.0, 0.95); + // The two sides of an axis keep 5% of the source between them. + let other = c[if i < 2 { i + 2 } else { i - 2 }]; + c[i] = c[i].min((0.95 - other).max(0.0)); let _ = app.run("picture.crop", json!({"left": c[0], "top": c[1], "right": c[2], "bottom": c[3]})); } } From 858cc838bf3c59aa834a924845647dd01b0e45cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Weil?= Date: Sun, 18 Oct 2026 19:11:56 +0200 Subject: [PATCH 14/34] Layout: anchored objects thinner than a point keep their size A floating object's frame was clamped to at least 1 pt on each side. Word keeps the size: a 0.25 pt high rectangle (a cut mark, a rule drawn as a filled shape) is 0.25 pt high, and a `line` shape 0 pt high is a horizontal line. WordCraft drew the rectangle 1 pt high from its top edge (its middle 0.375 pt too low) and the flat line from its start down to 1 pt below its end, a slanted stroke. - layout: `float_rect` keeps sizes from 0 pt (not finite: 0); the PDF and raster shape outlines already draw a flat line and skip an empty rectangle. Checked against Word for Microsoft 365 (page-anchored, no outline unless said; ink top..bottom in pt, 2400 dpi): 0.25 pt rectangle Word 99.99..100.26 before 99.99..101.01 after 99.99..100.26 0.5 pt rectangle Word 120.00..120.51 before 120.00..120.99 after 120.00..120.51 0.75 pt rectangle Word 140.01..140.76 before 140.01..141.00 after 140.01..140.76 1 and 2 pt unchanged, as Word flat line, 0.25 pt Word 219.87..220.14 before 219.87..221.13 after 219.87..220.11 flat line, 1 pt Word 239.49..240.51 before 239.49..241.50 after 239.49..240.51 upright line, 0 wide, 0.25 pt: Word 299.88..300.12 across, before 299.88..301.14, after 299.88..300.12 Test: rectangles of 0.25, 0.5, 1 and 2 pt, a flat line and an upright line keep their sizes. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/lib.rs | 3 ++- crates/layout/src/tests.rs | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a60c5f94d..54f750b48 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2913,7 +2913,8 @@ fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> { /// frame (table cells, text boxes, notes) positions relative to the page or margins fall back /// to the box itself. fn float_rect(frame: Option, col: (f32, f32), y0: f32, w: f32, h: f32, float: &Float) -> Rect { - let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); + // Thinner than a point stays thin (a 0.25 pt rule, a flat line): Word keeps the size. + let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0)); // Each axis: the reference area's start and extent, in page coordinates. Across, as laid out // before an even page is mirrored (less the shift it then gets). let page_x = |f: &PageFrame| { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 174dde174..7d8505672 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -9423,3 +9423,47 @@ fn caret_height_ignores_line_spacing() { assert!((single - wide).abs() < 0.01, "{spacing:?}: single {single}, wide {wide}"); } } + +/// Cut marks: page-anchored rules thinner than a point keep their height (Word draws a 0.25 pt +/// rectangle 0.25 pt high), and a flat `line` stays flat instead of running down a point. +#[test] +fn anchored_objects_thinner_than_a_point_keep_their_size() { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let float = |y: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y, ..Default::default() }; + let mut d = Document::from_text("Rules"); + for (i, h) in [0.25f32, 0.5, 1.0, 2.0].into_iter().enumerate() { + d.insert_object(&Pos::body(0, 0), rect_shape(200.0, h, float(100.0 + 20.0 * i as f32)), &Default::default()).unwrap(); + } + let line = |w: f32, h: f32, y: f32| InlineObject::Shape { + kind: ShapeKind::Line, + w, + h, + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 0.25, + float: float(y), + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), line(200.0, 0.0, 220.0), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), line(0.0, 100.0, 300.0), &Default::default()).unwrap(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let mut rects: Vec<(ShapeKind, Rect)> = + draws.iter().filter_map(|x| if let display::Draw::Shape { rect, kind, .. } = x { Some((*kind, *rect)) } else { None }).collect(); + rects.sort_by(|a, b| a.1.y.total_cmp(&b.1.y)); + let got: Vec<(f32, f32, f32, f32)> = rects.iter().map(|(_, r)| (r.x, r.y, r.w, r.h)).collect(); + let want = [ + (72.0, 100.0, 200.0, 0.25), + (72.0, 120.0, 200.0, 0.5), + (72.0, 140.0, 200.0, 1.0), + (72.0, 160.0, 200.0, 2.0), + (72.0, 220.0, 200.0, 0.0), + (72.0, 300.0, 0.0, 100.0), + ]; + assert_eq!(got.len(), want.len(), "{rects:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-3 && (g.1 - w.1).abs() < 1e-3 && (g.2 - w.2).abs() < 1e-3 && (g.3 - w.3).abs() < 1e-3, "{g:?} != {w:?}"); + } +} From 7489ef9f8e25f58b92c6d60db09e8c653014a3c2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 17 Oct 2026 14:44:45 +0200 Subject: [PATCH 15/34] SmartArt Text Pane: Tab demotes, Shift+Tab promotes the focused item (#2701) Word's Text Pane demotes the focused item on Tab and promotes it on Shift+Tab; our pane had only the Demote/Promote buttons, so Tab moved the caret to the next row instead. WordApp::logic consumes Tab while a Text Pane row is focused and runs smartArt.demote / smartArt.promote on that item, keeping the row focused so the next Tab acts on it again. An item that cannot go down a level is refused by the command, as the button path already is. A new flag records that a pane row has focus, so Tab from the canvas is untouched. Regression test drives the harness: Tab on an item that cannot demote changes nothing and keeps focus; Tab on one after a deeper item demotes it and its sub-items, the same row takes the next Tab, Shift+Tab promotes them back. --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 51 +++++++++++++++++++++++++ crates/ui-egui/src/dialogs_smart_art.rs | 5 ++- crates/ui-egui/src/lib.rs | 15 ++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..15d06a63c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ae635970d..c642fd389 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5026,4 +5026,55 @@ mod tests { assert!(pages[0].footer_story.is_none(), "the title page shows no page number"); assert!(pages[1].footer_story.is_some(), "the next page keeps its number"); } + + /// #2701: in the SmartArt Text Pane, Tab demotes and Shift+Tab promotes the focused item, as + /// Word's pane does; the row keeps its focus, so the next Tab acts on the same item. An item + /// that can't go down a level (the one before it isn't deeper) is left as it is. + #[test] + fn text_pane_tab_demotes_and_shift_tab_promotes_the_focused_item() { + use egui_kittest::kittest::Queryable; + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("insert.smartArt", json!({"layout": "hierarchy"})).unwrap(); + app.run("smartArt.textPane", json!({"value": true})).unwrap(); + let levels = |app: &WordApp| { + wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).map(|g| g.items.iter().map(|i| i.level).collect::>()) + }; + assert_eq!(levels(&app), Some([0, 1, 2, 1, 2, 2].to_vec()), "Lead, Design, Sketches, Build, Code, Tests"); + let mut h = app_harness(app); + h.run_steps(2); + // Focus a pane row: the text box with this value (the graphic drawn on the page shows the + // same words as shape names, so match by role). + let focus_row = |h: &mut egui_kittest::Harness<'static, WordApp>, text: &str| { + let node = h + .query_all(egui_kittest::kittest::By::new().role(egui::accesskit::Role::TextInput).value(text)) + .next() + .unwrap_or_else(|| panic!("no text box for {text}")); + node.focus(); + h.run_steps(2); + }; + let tab = |h: &mut egui_kittest::Harness<'static, WordApp>, shift: bool| { + if shift { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + } else { + h.key_press(egui::Key::Tab); + } + h.run_steps(2); + }; + // Focus "Design" (the second top-level item, at level 1): Tab can't demote it, because + // the item before it ("Lead") is shallower — Word leaves the level alone. + focus_row(&mut h, "Design"); + assert_eq!(h.state().smart_art_item, Some(1), "the pane remembers the focused row"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 1, 2, 2].to_vec()), "Tab on an item that can't go down changes nothing"); + assert_eq!(h.state().smart_art_item, Some(1), "the row keeps focus after a refused demote"); + // Focus "Build" (level 1 after the deeper "Sketches"): Tab demotes it and its sub-items, + // and the next Tab (the row stayed focused) demotes them again; Shift+Tab promotes back. + focus_row(&mut h, "Build"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Tab demotes the focused item"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 3, 4, 4].to_vec()), "the same row takes the next Tab"); + tab(&mut h, true); + assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Shift+Tab promotes them back"); + } } diff --git a/crates/ui-egui/src/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs_smart_art.rs index d7f0e7824..977c184be 100644 --- a/crates/ui-egui/src/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs_smart_art.rs @@ -323,7 +323,8 @@ pub fn type_into(app: &mut WordApp, typed: &str) -> bool { } /// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one -/// undo step per item typed in); Enter adds an item after the current one. +/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the +/// focused item and Shift+Tab promotes it, as Word's Text Pane does. pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return }; let t = Tokens::get(ui.ctx()); @@ -332,6 +333,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { let focus_id = egui::Id::new("smart_art_focus"); let want_focus: Option = ui.data_mut(|d| d.remove_temp(focus_id)); let mut typed = false; + app.smart_art_row_focused = false; egui::ScrollArea::vertical().id_salt("smart_art_items").max_height((ui.available_height() - 40.0).max(60.0)).show(ui, |ui| { for (i, it) in g.items.iter().enumerate() { ui.horizontal(|ui| { @@ -344,6 +346,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { } if r.has_focus() || r.gained_focus() { app.smart_art_item = Some(i); + app.smart_art_row_focused = true; } if r.changed() { typed = true; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7f687a4e..665b1628d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -588,6 +588,9 @@ pub struct WordApp { /// SmartArt Text Pane: the item last focused (Promote, Demote and Add Shape act on it) and /// the item being typed in (its keystrokes join one undo step). pub smart_art_item: Option, + /// A Text Pane row has focus (`smart_art_item` remembers the last one, so Tab handling in + /// [`WordApp::logic`] only fires while a row is focused, not from the canvas). + pub smart_art_row_focused: bool, pub(crate) smart_art_typing: Option, /// The tab shown before Outline view brought up the Outlining tab (restored when it closes). pub(crate) outline_prev_tab: Option, @@ -701,6 +704,7 @@ impl WordApp { equation_prev_tab: None, smart_art_item: None, smart_art_typing: None, + smart_art_row_focused: false, outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, @@ -1950,6 +1954,17 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's + // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does + // resetting the focus direction: the item keeps focus after its level changes. + if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } + } // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later From deea76838d6a174fba126826f5ca6edaed4d327a Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Thu, 15 Oct 2026 00:48:46 +0600 Subject: [PATCH 16/34] Tests: a table that starts mid-page never leaves a repeated header row alone (#1704) --- crates/layout/src/tests.rs | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 174dde174..e6400c442 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2925,6 +2925,85 @@ fn repeated_header_moves_with_the_first_row_that_does_not_fit() { assert_eq!(rows_per_page(40.0), vec![vec![], vec![0, 1, 2]]); } +#[test] +fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { + // #1704: a table whose repeated header row fits at the bottom of a page while a body row + // does not must not leave the header row there alone. Sweeps filler counts across the page + // boundary for several table geometries (height ratios, a cantSplit body row, an exact + // height body row, two header rows) and pins, for every configuration while the table + // continues onto a next page: no page ends with only header rows, and no row is dropped. + // A table that starts at the very top of a page is exempt (#1705): there the header is the + // table's own beginning, so fillers = 0 skips that assertion. + #[derive(Debug)] + struct Geo { + header: f32, + body: f32, + body_cant_split: bool, + body_exact: bool, + header_rows: usize, + } + let geos = [ + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 1usize), + (Geo { header: 300.0, body: 100.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 50.0, body: 250.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: true, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: true, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: false, header_rows: 2 }, 4), + ]; + let pages_with = |fillers: usize, geo: &Geo| -> Vec> { + let mut d = Document::new(); + for i in 0..fillers { + let mut p = wordcraft_doc::Paragraph::with_text(&format!("Filler {i}"), Default::default()); + p.props.space_after = Some(0.0); + d.body.push(wordcraft_doc::para_block(p)); + } + let mut t = Table::new(geo.header_rows + 2, 1, 468.0); + for (ri, row) in t.rows.iter_mut().enumerate() { + row.props.header = ri < geo.header_rows; + row.props.height = Some(if ri < geo.header_rows { geo.header } else { geo.body }); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + if ri >= geo.header_rows && geo.body_cant_split { + row.props.cant_split = true; + } + if ri >= geo.header_rows && geo.body_exact { + row.props.height_rule = wordcraft_doc::props::HeightRule::Exact; + } + } + d.insert_block(StoryRef::Body, &Path::top(d.body.len()), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + l.pages + .iter() + .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) + .collect() + }; + for (geo, coarse) in &geos { + for fillers in (0..60).step_by(*coarse) { + let pages = pages_with(fillers, geo); + for w in pages.windows(2) { + // While the table continues on the next page, a page never ends with only + // header rows. (fillers = 0 starts the table at the top of a page: exempt.) + if !w[1].is_empty() && fillers > 0 { + assert!( + w[0].is_empty() || w[0].iter().any(|&r| r >= geo.header_rows), + "fillers {fillers}, geo {geo:?}: a page ends with header rows alone: {pages:?}" + ); + } + } + // No configuration drops rows: every row of the table is placed somewhere. + let placed: std::collections::HashSet = pages.iter().flatten().copied().collect(); + assert_eq!(placed.len(), geo.header_rows + 2, "fillers {fillers}, geo {geo:?}: rows placed: {pages:?}"); + } + } + // Room for the header and the first body row together keeps them on the page. + assert!( + (1..60).any(|fillers| { + let geo = &geos[0].0; + pages_with(fillers, geo).first().is_some_and(|p| p.contains(&0) && p.contains(&{ geo.header_rows })) + }), + "the header and a body row never share a page" + ); +} + /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). #[test] fn drop_cap_with_a_huge_font_lays_out() { From 862e7b212f1147e66ff5a1b234d19ff0dec439b2 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Tue, 20 Oct 2026 10:57:24 +0600 Subject: [PATCH 17/34] Tests: a body row that fits under the header stays on that page (#1704) The sweep pinned that a page never ends with header rows alone, but its last check only asked whether *some* filler count put the header and a body row together: a table that carried a body row over although the page had room for it passed. The check now measures the room left under the header on the first page and requires the body row to be placed there whenever it fits, which is the position the sweep could not see. The table is built once and read two ways (rows per page, and the cells' frames). --- crates/layout/src/tests.rs | 64 ++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e6400c442..90accd9ce 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2950,7 +2950,8 @@ fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: true, header_rows: 1 }, 4), (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: false, header_rows: 2 }, 4), ]; - let pages_with = |fillers: usize, geo: &Geo| -> Vec> { + // The table, after `fillers` paragraphs: the same table in every configuration. + let table_doc = |fillers: usize, geo: &Geo| -> Document { let mut d = Document::new(); for i in 0..fillers { let mut p = wordcraft_doc::Paragraph::with_text(&format!("Filler {i}"), Default::default()); @@ -2970,12 +2971,35 @@ fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { } } d.insert_block(StoryRef::Body, &Path::top(d.body.len()), wordcraft_doc::Block::Table(t)).unwrap(); - let l = lay(&d); + d + }; + let pages_with = |fillers: usize, geo: &Geo| -> Vec> { + let l = lay(&table_doc(fillers, geo)); l.pages .iter() .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) .collect() }; + // The same table, with the geometry: the (row, frame) of each row's first cell per page, + // and the page's body area, so the room under the header can be measured. + let pages_geom = |fillers: usize, geo: &Geo| -> Vec<(Vec<(usize, Rect)>, Rect)> { + let l = lay(&table_doc(fillers, geo)); + l.pages + .iter() + .map(|p| { + let cells = p + .items + .iter() + .filter_map(|i| match i { + Placed::Cell { rect, row, cell: 0, .. } => Some((*row, *rect)), + _ => None, + }) + .collect(); + (cells, p.body) + }) + .collect() + }; + for (geo, coarse) in &geos { for fillers in (0..60).step_by(*coarse) { let pages = pages_with(fillers, geo); @@ -2994,14 +3018,34 @@ fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { assert_eq!(placed.len(), geo.header_rows + 2, "fillers {fillers}, geo {geo:?}: rows placed: {pages:?}"); } } - // Room for the header and the first body row together keeps them on the page. - assert!( - (1..60).any(|fillers| { - let geo = &geos[0].0; - pages_with(fillers, geo).first().is_some_and(|p| p.contains(&0) && p.contains(&{ geo.header_rows })) - }), - "the header and a body row never share a page" - ); + // Room for the header and the first body row together keeps them on the page, and keeps + // the body row *there*: where the row would fit under the header, it is placed on that + // page instead of being carried over. This is the position, not only the presence -- a + // table that carried a body row over although the page had room for it is the + // unnecessary move the sweep above cannot see. + let mut paired = 0; + for fillers in 1..60 { + let geo = &geos[0].0; + let geom = pages_geom(fillers, geo); + let Some((first_cells, body)) = geom.first() else { continue }; + let Some((_, header_rect)) = first_cells.iter().find(|(r, _)| *r == 0) else { + // The header itself moved to a later page: there is nothing to pair it with here. + continue; + }; + // The room left on the first page under the header. + let room = body.bottom() - header_rect.bottom(); + // The body row's own height, wherever it was placed. + let body_h = geom.iter().flat_map(|(cells, _)| cells.iter()).find(|(r, _)| *r == geo.header_rows).map(|(_, rect)| rect.h).unwrap_or(geo.body); + if room >= body_h { + assert!( + first_cells.iter().any(|(r, _)| *r == geo.header_rows), + "fillers {fillers}: the first body row fits in the {room} pt left under the header \ + (it is {body_h} pt tall) but was carried over to the next page" + ); + paired += 1; + } + } + assert!(paired > 0, "no configuration left room for the header and a body row together"); } /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). From 0cca2f7d210c6808cb2fc0b258b6c2cdadfed155 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:29:15 +0200 Subject: [PATCH 18/34] #1899: a picture cropped to a sliver is drawn at the size the file gives A crop that kept under 5% of a picture's axis was widened to 5%, so a header bar cropped to 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it. The rasteriser, the PDF writer and the metafile placement now share wordcraft_metafile::crop_window, which keeps down to 0.1% of an axis and keeps junk crops finite. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/metafile/src/lib.rs | 2 +- crates/metafile/src/place.rs | 23 ++++++++++++++--- crates/metafile/src/tests.rs | 18 ++++++++++++++ crates/pdf/src/lib.rs | 4 +-- crates/render/src/lib.rs | 48 +++++++++++++++++++++++++++++++++--- docs/layout-parity.md | 3 ++- 7 files changed, 90 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..6a6fc157b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (progress log: a picture cropped to a sliver is drawn at the size the file gives, #1899; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899). - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. - 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cc2f64e5c..5dacfb153 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -23,7 +23,7 @@ mod tests; use bytes::Bytes; use canvas::Raw; use geom::{Op, P}; -pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem, crop_window}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f079553..aad4b7393 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -11,6 +11,23 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000; const MAX_COORD: f64 = 1e6; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; +/// The least of a source axis a crop keeps (0.1%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile, #1899); this only keeps the stretch finite. +const MIN_VISIBLE: f64 = 0.001; + +/// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) +/// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.1% of it, +/// the left (top) side stands and 0.1% shows. +pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); + let axis = |lo: f64, hi: f64| { + let lo = lo.min(1.0 - MIN_VISIBLE); + (lo, (1.0 - lo - hi).max(MIN_VISIBLE)) + }; + let ((x, w), (y, h)) = (axis(l, r), axis(t, b)); + [x, y, w, h] +} /// One item of a placed picture, in target units. pub enum PlacedItem { @@ -30,10 +47,10 @@ impl Picture { /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, /// bottom) removed first: the visible part fills `r`. None for a degenerate size. pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 }); + let [cl, ct, vw, vh] = crop_window(crop); let (w, h) = (f64::from(self.width), f64::from(self.height)); - let sx = r.width() / (w * (1.0 - cl - cr).max(0.05)); - let sy = r.height() / (h * (1.0 - ct - cb).max(0.05)); + let sx = r.width() / (w * vw); + let sy = r.height() / (h * vh); (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 8a93e1411..9295d4c1b 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -891,6 +891,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { assert_eq!(stroke.map(|s| s.1), Some(8.0)); } +/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized +/// metafile), and junk sides give a finite part of the source with some area. +#[test] +fn crop_window_keeps_a_sliver_and_stays_finite() { + let [x, y, w, h] = crate::crop_window([0.0, 0.51771, 0.0, 0.45485]); + assert!(x == 0.0 && w == 1.0 && (y - 0.51771).abs() < 1e-6 && (h - 0.02744).abs() < 1e-5, "{y} {h}"); + let pic = crate::Picture { width: 100.0, height: 1000.0, items: Vec::new() }; + let tf = pic.fit(kurbo::Rect::new(0.0, 0.0, 100.0, 27.44), [0.0, 0.51771, 0.0, 0.45485]).unwrap(); + // The kept band of the source (517.71..545.15) is the frame (0..27.44). + let top = tf * kurbo::Point::new(0.0, 517.71); + let bottom = tf * kurbo::Point::new(0.0, 545.15); + assert!(top.y.abs() < 1e-3 && (bottom.y - 27.44).abs() < 1e-3, "{top:?} {bottom:?}"); + for c in [[f32::NAN, f32::INFINITY, -1.0, 2.0], [0.9; 4], [1.0, 1.0, 0.0, 0.0], [f32::MAX, f32::MIN, f32::NEG_INFINITY, 0.5]] { + let [x, y, w, h] = crate::crop_window(c); + assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + } +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7e853242e..4cfac041d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1245,9 +1245,7 @@ impl Exporter<'_> { s.set_fill(None); return; }; - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop).map(|v| v as f32); let (fw, fh) = (rect.w / vis_w, rect.h / vis_h); let Some(size) = Size::from_wh(fw.max(0.01), fh.max(0.01)) else { return }; let fade = alpha < 0.999; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b0416c11e..d5aa6c1c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -874,9 +874,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl return; }; let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) as f64 } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop); let sx = r.width() / (pw * vis_w); let sy = r.height() / (ph * vis_h); ctx.set_transform(view); @@ -1809,6 +1807,50 @@ mod tests { assert!(left[..3].iter().all(|c| *c > 200), "left half should be paper, not the grey box: {left:?}"); } + /// #1899: a crop that keeps a sliver of the picture (3% here; a header's bar can be 2.7% of a + /// page-sized metafile) stretches that sliver over the frame, as the file says, for vector and + /// bitmap pictures alike. Both pictures are paper on the left half and red on the right. + #[test] + fn a_crop_keeping_a_sliver_fills_the_frame_with_it() { + let mut png = Vec::new(); + image::RgbaImage::from_fn(100, 100, |x, _| image::Rgba(if x >= 50 { [255, 0, 0, 255] } else { [255; 4] })) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + for (bytes, ext) in [(red_right_half_emf(), "emf"), (png, "png")] { + // (crop, where in the frame, red?): 47–50% of the source is all paper; 50–53% all red. + for (crop, at, red) in [([0.47, 0.47, 0.5, 0.5], 0.7, false), ([0.5, 0.47, 0.47, 0.5], 0.3, true)] { + let mut d = Document::new(); + let media = d.add_media(bytes.clone(), ext); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop, + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let rect = page_display(&d, &l.pages[0], &opts.display) + .iter() + .find_map(|i| match i { + Draw::Image { rect, .. } => Some(*rect), + _ => None, + }) + .expect("a picture on the page"); + let img = render_page(&d, &l.pages[0], 1.0, &opts); + let px = img.pixel((rect.x + rect.w * at) as u32, (rect.y + rect.h * at) as u32); + let is_red = px[0] > 200 && px[1] < 60 && px[2] < 60; + let is_paper = px[..3].iter().all(|c| *c > 200); + assert!(if red { is_red } else { is_paper }, "{ext} crop {crop:?} at {at}: {px:?}"); + } + } + } + #[test] fn cosmetic_metafile_pen_is_one_device_pixel_wide() { // MOVETO (0, 50), LINETO (100, 50) with the default cosmetic black pen: a hairline across the middle. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4873f4481..377e6d7ac 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (a picture cropped to a sliver of its source, under 5%, is drawn at the size the file gives, on screen and in PDF: a header bar that is 2.7% of a page-sized WMF, #1899; 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, @@ -214,6 +214,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | | 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) | | 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. | From fc5063c38dd7632cd9be592d71dfa167cb046bb6 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:10:12 +0200 Subject: [PATCH 19/34] #1899: keep at least 0.5% of a cropped axis, so nothing shown is culled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 0.1% the whole source of a 4000 pt frame spanned up to 4,000,000 pt, past the ±1,000,000 pt limit Picture::place uses to leave out items far off the page, so a metafile cropped that narrow lost what the crop shows. At 0.5% it spans at most 800,000 pt (CodeRabbit on #3247). Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/place.rs | 12 +++++++----- crates/metafile/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index aad4b7393..d1f4d1983 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -11,14 +11,16 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000; const MAX_COORD: f64 = 1e6; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; -/// The least of a source axis a crop keeps (0.1%). Files crop pictures to slivers (a header bar -/// that is 2.7% of a page-sized metafile, #1899); this only keeps the stretch finite. -const MIN_VISIBLE: f64 = 0.001; +/// The least of a source axis a crop keeps (0.5%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile, #1899). Layout makes a picture's frame at most 4000 pt +/// a side, so the whole source then spans at most 800,000 pt: what it covers stays inside +/// `MAX_COORD` and is never left out as far off the page. +const MIN_VISIBLE: f64 = 0.005; /// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) /// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each -/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.1% of it, -/// the left (top) side stands and 0.1% shows. +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.5% of it, +/// the left (top) side stands and 0.5% shows. pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); let axis = |lo: f64, hi: f64| { diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 9295d4c1b..f67840f88 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -909,6 +909,25 @@ fn crop_window_keeps_a_sliver_and_stays_finite() { } } +/// The narrowest crop on the largest frame layout makes (4000 pt, far down a 22-inch page) still +/// places the source's content: none of it is left out as far off the page. +#[test] +fn the_narrowest_crop_on_the_largest_frame_keeps_its_content() { + let pic = crate::Picture { + width: 100.0, + height: 100.0, + items: vec![ + Item::Bitmap { rect: [0.0, 0.0, 100.0, 100.0], width: 1, height: 1, rgba: vec![255; 4] }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)], fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false }, + ], + }; + let frame = kurbo::Rect::new(1584.0, 1584.0, 5584.0, 5584.0); + // The last sliver, the first sliver, and sides that overlap. + for crop in [[1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0], [0.9999, 0.6, 0.5, 0.9999]] { + assert_eq!(pic.place(frame, crop).len(), 2, "{crop:?}"); + } +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; From 8e799be97ebb9f6ef4dbb53e93ab5f47aa27e827 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:54:17 +0200 Subject: [PATCH 20/34] #1899: wrap outlines and PDF bitmaps follow the same crop window contour::picture_outline turned the crop into a pixel window on its own, so a sliver or overlapping crop gave an empty window and Tight/Through wrapping fell back to the frame while the renderers draw a 0.5% sliver. It now uses wordcraft_metafile::crop_window, with the upper pixel edges rounded up. A PDF test covers the bitmap and metafile paths of the export (CodeRabbit on #3247). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/layout/Cargo.toml | 1 + crates/layout/src/contour.rs | 23 ++++++++++---- crates/pdf/src/tests.rs | 60 ++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8154b2041..204e3e347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5312,6 +5312,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", + "wordcraft-metafile", "wordcraft-proof", ] diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index afb8f0a4a..172a91cf1 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -13,6 +13,7 @@ wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-proof = { workspace = true } +wordcraft-metafile = { workspace = true } kurbo = { workspace = true } unicode-bidi = { workspace = true } unicode-linebreak = { workspace = true } diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 1c245b885..9d6237180 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -184,12 +184,13 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> } let img = img.into_rgba8(); let (iw, ih) = img.dimensions(); - let frac = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - let [cl, ct, cr, cb] = crop.map(frac); - let x0 = ((iw as f32 * cl) as u32).min(iw); - let y0 = ((ih as f32 * ct) as u32).min(ih); - let x1 = ((iw as f32 * (1.0 - cr)) as u32).clamp(x0, iw); - let y1 = ((ih as f32 * (1.0 - cb)) as u32).clamp(y0, ih); + // The part the renderers draw (#1899): a sliver keeps its partly covered pixels. + let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop); + let (fw, fh) = (f64::from(iw), f64::from(ih)); + let x0 = ((fw * cl) as u32).min(iw); + let y0 = ((fh * ct) as u32).min(ih); + let x1 = ((fw * (cl + vw)).ceil() as u32).clamp(x0, iw); + let y1 = ((fh * (ct + vh)).ceil() as u32).clamp(y0, ih); let (cw, ch) = (x1 - x0, y1 - y0); if cw == 0 || ch == 0 { return None; @@ -264,6 +265,16 @@ mod tests { assert!(half.iter().any(|(x, _)| *x > 0.99), "{half:?}"); // No transparency: the rectangle. assert!(picture_outline(&png(image::RgbImage::new(8, 8).into()), [0.0; 4]).is_none()); + // #1899: the sliver the renderers draw (0.5% of the axis, when two sides overlap or keep + // less) has an outline too. Only the bottom-right quarter is opaque, so a column or a row + // through it is opaque in its second half. + let quarter = png(image::RgbaImage::from_fn(100, 100, |x, y| image::Rgba([0, 0, 0, if x >= 50 && y >= 50 { 255 } else { 0 }])).into()); + for (crop, column) in [([0.6, 0.0, 0.6, 0.0], true), ([0.6, 0.0, 0.398, 0.0], true), ([0.0, 0.6, 0.0, 0.6], false)] { + let pts = picture_outline(&quarter, crop).unwrap_or_default(); + let across = |p: &(f32, f32)| if column { p.1 } else { p.0 }; + // The grid's cells are 3 pixels, so the half starts a little before 0.5. + assert!(pts.len() >= 3 && pts.iter().all(|p| across(p) > 0.4), "{crop:?}: {pts:?}"); + } } #[test] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d112eeb36..1253bdc88 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -875,6 +875,66 @@ fn metafile_pictures_are_drawn_as_vector_paths() { assert!(!raw.contains("0.8156"), "the grey placeholder box is not drawn"); } +/// #1899: a picture cropped to a sliver of its source (3% across here) is drawn at that sliver's +/// size, bitmap or metafile: the source spans 1/0.03 frames, not at most 20 (the old 5% floor). +#[test] +fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + /// The widest image and the widest fill drawn, in page units. + #[derive(Default)] + struct Widest(f64, f64); + impl Device<'_> for Widest { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, _: &Paint<'_>, mode: &PathDrawMode) { + if matches!(mode, PathDrawMode::Fill(_)) { + self.1 = self.1.max(kurbo::Shape::bounding_box(&(transform * path.clone())).width()); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, transform: kurbo::Affine) { + self.0 = self.0.max(transform.as_coeffs()[0].abs()); + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let widest = |bytes: Vec, ext: &str, crop: [f32; 4]| { + let mut d = Document::new(); + let media = d.add_media(bytes, ext); + let mut p = Paragraph::new(); + p.insert_object( + 0, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop, ole: None }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let pdf = Pdf::new(export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap()).expect("parse"); + let cache = InterpreterCache::new(); + let mut w = Widest::default(); + for page in pdf.pages().iter() { + let mut ctx = + Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut w); + } + w + }; + let sliver = [0.47, 0.0, 0.5, 0.0]; + // The bitmap: the whole image drawn behind the frame's clip. + let (whole, cropped) = (widest(png(), "png", [0.0; 4]).0, widest(png(), "png", sliver).0); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "png: {whole} → {cropped}"); + // The metafile: its red right half, a filled path. + let (whole, cropped) = (widest(red_right_half_emf(), "emf", [0.0; 4]).1, widest(red_right_half_emf(), "emf", sliver).1); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}"); +} + /// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback. #[test] fn inline_picture_figure_keeps_its_alt_text() { diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 377e6d7ac..d0f06efdf 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -214,7 +214,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | Date | Change | Summary | |---|---|---| -| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | +| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile, and Tight/Through wrapping follows the part drawn; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | | 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) | | 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. | From 5af42b2748da262d155d9ebf917aac6fd139b142 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:48:14 +0000 Subject: [PATCH 21/34] Fix table formula ROUND erroring on large numbers with many digits Found while testing crates/engine/src/cmd/table_formula.rs:728-734 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/table_formula.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index e0e519801..0aa6fdfe6 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -730,7 +730,12 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result { let x = scalar(eval(a, here)?)?; let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc(); let scale = 10f64.powi(digits as i32); - finite((x * scale).round() / scale) + let scaled = x * scale; + if !scaled.is_finite() { + // Too large to have a fractional part at this precision. + return finite(x); + } + finite(scaled.round() / scale) } _ => Err(CmdError::Failed("ROUND takes two arguments".into())), }, @@ -807,6 +812,14 @@ mod tests { assert!(err.contains("MEDIAN"), "{err}"); } + #[test] + fn round_large_values_return_input() { + let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); + let big = format!("1{}", "0".repeat(300)); + assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},15)")), Ok(1e300)); + assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},2)")), Ok(1e300)); + } + #[test] fn ranges_and_references_and_operators() { let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); From e717ae4dc5477cb1225c7310de3752d24d718929 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:44:52 +0200 Subject: [PATCH 22/34] fix(doc): use decimal points for Cuban and Peruvian Spanish Add the two missing regional exceptions and regression coverage for parsing, grouped numbers, formatting, and Spain controls. --- crates/doc/src/locale_number.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index 5511ac720..569ab0a4b 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -8,7 +8,7 @@ pub fn decimal_separator(lang: Option<&str>) -> char { let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-"); // Regions that write a decimal point where the language usually writes a comma. const POINT_REGIONS: &[&str] = - &["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"]; + &["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"]; if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) { return '.'; } @@ -151,6 +151,17 @@ mod tests { assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign"); } + #[test] + fn cuban_and_peruvian_numbers_use_decimal_points() { + for lang in [Some("es-CU"), Some("es-PE")] { + assert_eq!(parse_number("1.234", lang), Some(1.234)); + assert_eq!(parse_number("1,234.50", lang), Some(1234.5)); + assert_eq!(format_number(5.75, lang), "5.75"); + } + assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5)); + assert_eq!(format_number(5.75, Some("es-ES")), "5,75"); + } + #[test] fn english_numbers() { let en = Some("en-US"); From d72b5eb2a223193aa6fec938343fc9383aa99c0c Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:48:16 +0200 Subject: [PATCH 23/34] docs: record Cuban and Peruvian number separator fix Update the progress date, preserve the current status, and add the narrow locale-number correction to the revision history. --- ROADMAP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..b06bcd213 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (Cuban and Peruvian table numbers use decimal points; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | From 642bf705946a44caa37041d8f8b750e406c78f77 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:49:28 +0200 Subject: [PATCH 24/34] docs: note corrected Cuban and Peruvian table numbers Record the narrow regional separator correction without changing localization readiness or effort estimates. --- docs/gaps.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index a65a6d80c..dbe15b937 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (Cuban and Peruvian table numbers use decimal points; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -31,6 +31,10 @@ Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existin Active footnote and endnote text stays readable while editing (#2545). This fixes a display-context defect; the broader fidelity, accessibility and readiness gates below are unchanged. +Table sorting and formulas use decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), +matching their existing region settings. Parsing and result-formatting regression tests cover +both locales and preserve Spain's decimal comma; see [localization parity](localization-parity.md). + ## Ranked | # | Gap | B | Evidence | Impact | Hours | Doc | @@ -106,6 +110,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Corrected Cuban and Peruvian Spanish table-number parsing and formula results to use decimal points; broader localization estimates unchanged | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | From 1b712863c130004711d97a6dd55a7d54c7c70656 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:02:37 +0200 Subject: [PATCH 25/34] M15: document Cuban and Peruvian decimal separator coverage Record the regression evidence, preserve broader localization readiness, and update the touched parity document status and history. --- docs/localization-parity.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5d430cf54..d87536da9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (Cuban and Peruvian table numbers use decimal points; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -148,8 +148,10 @@ Romanian, Estonian, Latvian, Lithuanian, Slovenian and Icelandic (`crates/doc/sr a hand-written per-language alphabet table; full CLDR collation is not used). Table sort and table formulas read numbers with the language's decimal and group separators (`1 250,50` in Polish, `1.250,50` in German, `1,250.50` in English) and write formula results with its decimal -separator (`crates/doc/src/locale_number.rs`). Interface lists (styles, fonts) still sort by code -point. +separator (`crates/doc/src/locale_number.rs`). Cuban and Peruvian Spanish (`es-CU`, `es-PE`) use +decimal points (`1,234.50`), matching the existing region settings and CLDR's Latin American +Spanish parent locale; `cuban_and_peruvian_numbers_use_decimal_points` covers parsing and result +formatting. Interface lists (styles, fonts) still sort by code point. ## Region conventions (#838) @@ -198,6 +200,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Cuban and Peruvian Spanish table numbers use decimal points for parsing and formula results, matching CLDR and the existing region settings; regression covers grouped values and a Spain control | | 2026-10-14 | trivial | The sentence-capital rule (English, Polish, Croatian, Italian, Turkish, Portuguese) skips the first word of a list item (#1741) | | 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged | | 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 | From 58fda7ee219a885cd89a52ab70a3bdd1c718aa8f Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:37:28 +0000 Subject: [PATCH 26/34] ODT: table column widths are read, so a resized column survives a save The importer ignored table:table-column and style:column-width and gave every table equal columns, so widths set in LibreOffice (or changed in WordCraft and exported) were lost on the next open. Co-Authored-By: Claude --- crates/formats/src/odt.rs | 86 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 2df9d4519..526655bbd 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1063,6 +1063,8 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// `style:column-width` of a table-column style's `style:table-column-properties`, in points. + column_width: Option, /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. master_page: Option, @@ -1264,6 +1266,13 @@ impl Styles { s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); } } + "table-column-properties" => { + if let Some((_, s)) = &mut cur + && let Some(w) = get(&a, "style:column-width").and_then(length) + { + s.column_width = Some(w); + } + } "table-cell-properties" => { if let Some((_, s)) = &mut cur && let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) @@ -1385,6 +1394,11 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.columns.clone()) } + /// Width in points of a table-column style (whole chain). + fn column_width(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.column_width) + } + /// Background colour of a table-cell style (whole chain). fn cell_background(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_background) @@ -1441,6 +1455,16 @@ struct TableB { row: Vec, /// Covered cells still owed to the previous cell's column span. hcover: usize, + /// Widths in points of the `table:table-column`s so far, 0 where the style gives none. + widths: Vec, +} + +impl TableB { + fn finish(self) -> FTable { + // A column without a width leaves the whole grid to the equal split. + let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() }; + FTable { rows: self.rows, widths, borderless: false } + } } /// A `text:changed-region` read from `text:tracked-changes`. @@ -1903,7 +1927,15 @@ impl Body<'_> { self.skip = 1; return; } - self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0 }); + self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new() }); + } + "table-column" => { + let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0); + let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).max(1); + if let Some(t) = self.tables.last_mut() { + let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len()); + t.widths.extend(std::iter::repeat_n(w, n.min(room))); + } } "table-row" => { if let Some(t) = self.tables.last_mut() { @@ -2197,7 +2229,7 @@ impl Body<'_> { if let Some(t) = self.tables.pop() && !t.rows.is_empty() { - self.container().push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); + self.container().push(FBlock::Table(t.finish())); } } "desc" | "title" => self.in_desc = false, @@ -2417,7 +2449,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Opti let mut blocks = body.containers.pop().unwrap_or_default(); for t in body.tables.drain(..) { if !t.rows.is_empty() { - blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); + blocks.push(FBlock::Table(t.finish())); } } // An open section with nothing in it (after a column section at the end) isn't one: the @@ -2783,6 +2815,54 @@ mod tests { } } + #[test] + fn table_column_styles_are_the_grid_widths() { + // A column's `style:column-width` (and `table:number-columns-repeated`) sets its width. + let content = format!( + r##"ABC"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert_eq!(t.widths, vec![72.0, 144.0, 144.0]); + } + + #[test] + fn table_column_widths_survive_a_round_trip() { + // A width changed in the document is written and read back; the file's own widths are + // what the first import shows. + let content = format!( + r##"AB"## + ); + let grid = |doc: &Document| match doc + .body + .iter() + .find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.grid.clone()) } else { None }) + { + Some(g) => g, + None => panic!("no table"), + }; + let mut doc = import(&package(&content, None)).unwrap(); + assert_eq!(grid(&doc), vec![72.0, 216.0]); + let widths = vec![150.0, 100.0]; + for b in doc.body.iter_mut() { + if let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b) { + t.grid = widths.clone(); + } + } + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(grid(&back), widths); + } + + #[test] + fn table_without_column_widths_keeps_the_equal_split() { + let content = format!( + r##"AB"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert!(t.widths.is_empty()); + } + #[test] fn table_cell_style_background_is_shading() { // #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading. From 7b19cd02d24548e24949b6d769f8dad261553343 Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:42:25 +0000 Subject: [PATCH 27/34] ODT: note table column widths in the progress docs Co-Authored-By: Claude --- ROADMAP.md | 3 ++- docs/file-format-parity.md | 3 ++- docs/gaps.md | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..1ffdc16bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (ODT table column widths read, so a resized column survives a save; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -506,6 +506,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-21 | trivial | Progress log: ODT table column widths are read, so widths set in LibreOffice or changed in WordCraft survive a save | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68488edf..485997d05 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (RTF import compares heading and code formatting with the file's stylesheet; 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: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** trivial (ODT table column widths read, so a resized column survives a save; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; 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: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -233,6 +233,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-21 | trivial | ODT: `table:table-column` styles (`style:column-width`, `table:number-columns-repeated`) are read as the table's grid, so column widths set in LibreOffice or changed in WordCraft survive a save; before, every opened table got equal columns and the written widths were dropped on the next open | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) | diff --git a/docs/gaps.md b/docs/gaps.md index a65a6d80c..7f5849d31 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (ODT table column widths read, so a resized column survives a save; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -52,7 +52,7 @@ Active footnote and endnote text stays readable while editing (#2545). This fixe | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF import done, #1378; RTF export left; ODT export done), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF import done, #1378; RTF export left; ODT export done), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-21 | trivial | Gap 19: ODT table column widths are read on import, so they survive a save | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | From bfb9ecf948a60f3a182adcc2a5c22589c624e374 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:11:12 +0200 Subject: [PATCH 28/34] #1899: a picture's wrap outline is placed where its crop window is drawn The renderers draw a crop window whose edges fall within pixels (crop fractions are f32 too: 0.7 is 0.69999...). The outline stretched the whole pixels it covered over the frame instead, so a narrow window came out up to a pixel too wide at each edge (a 2.5-5.5 px window put pixel 2 over a quarter of the picture, not a sixth), and a crop of 0.7 took in the neighbouring column. Grid points now map through the window itself. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/contour.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 9d6237180..2bf7a2fdf 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -207,9 +207,12 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> .any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128))) }; let pts = alpha_outline(gw as usize, gh as usize, opaque)?; - // The grid may reach a little past the picture (its last cells are partial). - let (sx, sy) = ((gw * cell) as f32 / cw as f32, (gh * cell) as f32 / ch as f32); - Some(pts.into_iter().map(|(x, y)| ((x * sx).min(1.0), (y * sy).min(1.0))).collect()) + // Grid points onto the picture as the renderers draw the window, whose edges fall within + // pixels (crop fractions are f32 too: 0.7 is 0.69999…). The grid may reach a little past the + // pixels (its last cells are partial). + let place = + |g: f32, cells: u32, p0: u32, lo: f64, len: f64| ((f64::from(p0) + f64::from(g) * f64::from(cells * cell) - lo) / len).clamp(0.0, 1.0) as f32; + Some(pts.into_iter().map(|(x, y)| (place(x, gw, x0, fw * cl, fw * vw), place(y, gh, y0, fh * ct, fh * vh))).collect()) } #[cfg(test)] @@ -275,6 +278,29 @@ mod tests { // The grid's cells are 3 pixels, so the half starts a little before 0.5. assert!(pts.len() >= 3 && pts.iter().all(|p| across(p) > 0.4), "{crop:?}: {pts:?}"); } + // Crop fractions are f32 (0.7 is 0.69999…), so an edge on a whole pixel comes out a hair + // off it: the outline neither takes in the neighbouring column nor drops the last one. Only + // the top half of the 30 columns each crop keeps (of 100) is opaque, so it spans 0–1 across. + for (crop, kept) in [([0.7, 0.0, 0.0, 0.0], 70..100), ([0.0, 0.0, 0.7, 0.0], 0..30)] { + let img = image::RgbaImage::from_fn(100, 20, |x, y| image::Rgba([0, 0, 0, if kept.contains(&x) && y < 10 { 255 } else { 0 }])); + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let xs = pts.iter().map(|p| p.0); + let (lo, hi) = (xs.clone().fold(f32::MAX, f32::min), xs.fold(f32::MIN, f32::max)); + assert!(lo < 1e-6 && hi > 1.0 - 1e-6, "{crop:?}: {pts:?}"); + } + // A window whose edges fall within pixels (2.5–5.5 of 10) is placed as the renderers draw + // it: pixel 2, the only opaque one (in half the rows), covers the first sixth of the + // picture, not a quarter (whole pixels 2–5 stretched over it). Across and down alike. + for down in [false, true] { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + let (a, b) = if down { (y, x) } else { (x, y) }; + image::Rgba([0, 0, 0, if a == 2 && b < 5 { 255 } else { 0 }]) + }); + let crop = if down { [0.0, 0.25, 0.0, 0.45] } else { [0.25, 0.0, 0.45, 0.0] }; + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let far = pts.iter().map(|p| if down { p.1 } else { p.0 }).fold(f32::MIN, f32::max); + assert!(pts.len() >= 3 && (far - 1.0 / 6.0).abs() < 1e-3, "{crop:?}: {pts:?}"); + } } #[test] From c60d9df0c32fd47bf04948073420b21f3c19fa8f Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 12:21:25 +0530 Subject: [PATCH 29/34] fix(formats): support table cell vertical alignment in rtf, html, and odt --- crates/formats/src/html.rs | 47 +++++++++++++++++++++++++- crates/formats/src/model.rs | 16 +++++++-- crates/formats/src/odt.rs | 67 ++++++++++++++++++++++++++++++------- crates/formats/src/rtf.rs | 26 ++++++++++++++ 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 91d328623..56093f471 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -13,6 +13,7 @@ use std::sync::Arc; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ @@ -1257,6 +1258,26 @@ impl<'r> Builder<'r> { } let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63)); let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background); + let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }); + if let Some(st) = attr(attrs, "style") { + for decl in st.split(';') { + if let Some((k, v)) = decl.split_once(':') { + if k.trim().eq_ignore_ascii_case("vertical-align") { + match v.trim().to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} + } + } + } + } + } self.cells.push(Cell { blocks: Vec::new(), colspan: num("colspan").unwrap_or(1), @@ -1265,6 +1286,7 @@ impl<'r> Builder<'r> { header: name == "th", shading, borders: None, + valign, }); fmt.background = None; self.containers.push(Vec::new()); @@ -1617,7 +1639,12 @@ fn table_html(t: &FTable, out: &mut String, depth: usize) { if c.rowspan > 1 { attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan)); } - let mut css = vec!["padding:4px 6px".to_string(), "vertical-align:top".to_string()]; + let v_css = match c.valign { + Some(VAlign::Center) => "vertical-align:middle", + Some(VAlign::Bottom) => "vertical-align:bottom", + _ => "vertical-align:top", + }; + let mut css = vec!["padding:4px 6px".to_string(), v_css.to_string()]; if !t.borderless { css.insert(0, "border:1px solid #999".to_string()); } @@ -2036,4 +2063,22 @@ mod tests { }; assert_eq!(p.text(), "World"); } + + #[test] + fn table_cell_vertical_alignment() { + let f = parse("
topmidbot
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Top)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() }; + assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let html = export(&doc); + assert!(html.contains("vertical-align:top")); + assert!(html.contains("vertical-align:middle")); + assert!(html.contains("vertical-align:bottom")); + } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c2502f2e4..0a6cfd59c 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for}; use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, Run}; use wordcraft_doc::props::{ - Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VMerge, VertAlign, + Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind}; @@ -242,11 +242,12 @@ pub struct Cell { pub shading: Option, /// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style. pub borders: Option, + pub valign: Option, } impl Default for Cell { fn default() -> Self { - Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None } + Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None } } } @@ -693,7 +694,15 @@ impl<'a> Builder<'a> { VMerge::None }; row.cells.push(wordcraft_doc::Cell { - props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, borders: c.borders, ..Default::default() }, + props: CellProps { + width: Some(w), + span: span as u32, + vmerge, + shading: c.shading, + borders: c.borders, + valign: c.valign, + ..Default::default() + }, blocks, controls: Default::default(), }); @@ -1048,6 +1057,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable header, shading: c.props.shading, borders: c.props.borders, + valign: c.props.valign, }); } rows.push(cells); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 86ee6acee..477da63c2 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -22,6 +22,7 @@ use std::io::{Cursor, Read, Write}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; +use wordcraft_doc::props::VAlign; use wordcraft_doc::section::{Columns, SectionStart}; use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; @@ -103,8 +104,8 @@ struct Writer { last_list: Option, /// The page the block being written starts. master: Option, - /// Cell styles by shading. - cell_styles: Vec>, + /// Cell styles by (shading, vertical-alignment). + cell_styles: Vec<(Option, Option)>, pictures: Vec<(String, std::sync::Arc>, String)>, /// `text:changed-region` elements written so far. regions: Vec, @@ -574,11 +575,12 @@ impl Writer { } } - fn cell_style(&mut self, shading: Option) -> String { - let k = match self.cell_styles.iter().position(|s| *s == shading) { + fn cell_style(&mut self, shading: Option, valign: Option) -> String { + let key = (shading, valign); + let k = match self.cell_styles.iter().position(|s| *s == key) { Some(k) => k, None => { - self.cell_styles.push(shading); + self.cell_styles.push(key); self.cell_styles.len() - 1 } }; @@ -616,7 +618,7 @@ impl Writer { } continue; } - let st = self.cell_style(c.shading); + let st = self.cell_style(c.shading, c.valign); let mut attrs = format!(" table:style-name=\"{st}\" office:value-type=\"string\""); if span > 1 { attrs.push_str(&format!(" table:number-columns-spanned=\"{span}\"")); @@ -699,10 +701,16 @@ impl Writer { )); } } - for (i, sh) in self.cell_styles.iter().enumerate() { + for (i, (sh, va)) in self.cell_styles.iter().enumerate() { let bg = sh.map(|c| format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())).unwrap_or_default(); + let va_str = match va { + Some(VAlign::Top) => " style:vertical-align=\"top\"", + Some(VAlign::Center) => " style:vertical-align=\"middle\"", + Some(VAlign::Bottom) => " style:vertical-align=\"bottom\"", + None => "", + }; s.push_str(&format!( - "", + "", i + 1 )); } @@ -1061,6 +1069,8 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// `style:vertical-align` of a table-cell style's `style:table-cell-properties`. + cell_valign: Option, /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. master_page: Option, @@ -1263,10 +1273,18 @@ impl Styles { } } "table-cell-properties" => { - if let Some((_, s)) = &mut cur - && let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) - { - s.cell_background = Some(c); + if let Some((_, s)) = &mut cur { + if let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) { + s.cell_background = Some(c); + } + if let Some(va) = get(&a, "style:vertical-align") { + s.cell_valign = match va.trim() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }; + } } } "paragraph-properties" => { @@ -1388,6 +1406,11 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.cell_background) } + /// Vertical alignment of a table-cell style (whole chain). + fn cell_valign(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.cell_valign) + } + /// Paragraph kind, alignment, page break, rule, keep-with-next and base formatting. The text formatting of /// every style in the chain applies (automatic and common, ODF 1.3 §16.2 inheritance), except /// the style that gives the paragraph its kind and that style's ancestors: their formatting @@ -1914,10 +1937,11 @@ impl Body<'_> { let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 63); let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1000); let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s)); + let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s)); if let Some(t) = self.tables.last_mut() { t.hcover = span as usize - 1; } - self.cells.push(Cell { colspan: span, rowspan: rows, shading, ..Default::default() }); + self.cells.push(Cell { colspan: span, rowspan: rows, shading, valign, ..Default::default() }); self.containers.push(Vec::new()); self.lists_saved.push((std::mem::take(&mut self.lists), std::mem::take(&mut self.item_used))); } @@ -2793,6 +2817,23 @@ mod tests { assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]); } + #[test] + fn table_cell_style_vertical_alignment() { + let content = format!( + r##"MidBot"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let bytes = export(&doc).unwrap(); + let f2 = parse(&bytes).unwrap(); + let Some(FBlock::Table(t2)) = f2.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table in export") }; + assert_eq!(t2.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t2.rows[0][1].valign, Some(VAlign::Bottom)); + } + /// #1379: three master pages (A4 upright; A4 wide with page numbering restarting at 1; A4 /// upright again with numbering continuing) and a two-column `text:section` in between. fn sections_package() -> Vec { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 3a84c6812..ff6db934d 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document, Rgb}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img}; @@ -598,6 +599,12 @@ impl Writer { } else if c.rowspan > 1 { out.push_str("\\clvmgf"); } + match c.valign { + Some(VAlign::Top) => out.push_str("\\clvertalt"), + Some(VAlign::Center) => out.push_str("\\clvertalc"), + Some(VAlign::Bottom) => out.push_str("\\clvertalb"), + None => {} + } self.cell_borders(c.borders.as_ref(), t.borderless, out); if let Some(s) = c.shading { let i = self.t.color(s); @@ -974,6 +981,7 @@ struct CellDef { /// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw` /// width and `\brdrcf` colour). borders: wordcraft_doc::props::Borders, + valign: Option, } /// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms). @@ -1631,6 +1639,7 @@ impl Reader { c.shading = d.shading; c.borders = d.borders.any_visible().then_some(d.borders); c.header = self.row_header; + c.valign = d.valign; match d.vmerge { 1 => c.rowspan = 2, 2 => c.covered = true, @@ -2042,6 +2051,9 @@ impl Reader { "clvmrg" => self.def_cur.vmerge = 2, "clmgf" => self.def_cur.hmerge = 1, "clmrg" => self.def_cur.hmerge = 2, + "clvertalt" => self.def_cur.valign = Some(VAlign::Top), + "clvertalc" => self.def_cur.valign = Some(VAlign::Center), + "clvertalb" => self.def_cur.valign = Some(VAlign::Bottom), "clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(), "sb" => self.st.sb = Some(pv), "sa" => self.st.sa = Some(pv), @@ -3793,4 +3805,18 @@ mod tests { assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into())); assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into())); } + + #[test] + fn table_cell_vertical_alignment() { + let rtf = br"{\rtf1\ansi\trowd\clvertalt\cellx1000\clvertalc\cellx2000\clvertalb\cellx3000\intbl top\cell center\cell bottom\cell\row}"; + let doc = import(rtf).unwrap(); + let wordcraft_doc::Block::Table(t) = &*doc.body[0] else { panic!() }; + assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(t.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(t.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let exported = export(&doc); + assert!(exported.contains("\\clvertalt")); + assert!(exported.contains("\\clvertalc")); + assert!(exported.contains("\\clvertalb")); + } } From 084b76d9def920f519e43c26a9eb5f59c6231df7 Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Thu, 22 Oct 2026 00:04:20 +0530 Subject: [PATCH 30/34] fix(engine): preserve existing table cell margins when side is omitted In table.cellMargins, resolve existing margins from the table, its style, or defaults and only overwrite the sides explicitly provided in the command parameters. Closes #3490 --- crates/engine/src/cmd/tools.rs | 15 ++++++++++----- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index b02408dd2..582189473 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -629,14 +629,19 @@ pub fn specs() -> Vec { .params(r#"{"header"?: pt, "footer"?: pt}"#), CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| { let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) }; + let story = s.sel.focus.story; + let current = { + let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; + let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id)); + table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4]) + }; let m = [ - p::f32(v, "top").unwrap_or(0.0), - p::f32(v, "left").unwrap_or(5.4), - p::f32(v, "bottom").unwrap_or(0.0), - p::f32(v, "right").unwrap_or(5.4), + p::f32(v, "top").unwrap_or(current[0]), + p::f32(v, "left").unwrap_or(current[1]), + p::f32(v, "bottom").unwrap_or(current[2]), + p::f32(v, "right").unwrap_or(current[3]), ] .map(|x| x.clamp(0.0, 144.0)); - let story = s.sel.focus.story; s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m); for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) { s.doc.para_mut(story, &path)?.touch(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4031e230a..b3231be92 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8491,3 +8491,36 @@ fn rtf_list_round_trip_keeps_text() { assert_eq!(d.plain_text(StoryRef::Body), "first\nsecond", "{cmd}"); } } + +/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. +#[test] +fn table_cell_margins_preserves_unpassed_sides() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + + // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"top": 20.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); + + // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"left": 10.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); + + // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); + + // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). + run(&mut s, "table.cellMargins", json!({"right": 15.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); + + // 5. Updating multiple sides at once retains unpassed sides. + run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); +} From 601af83f2c779fe1ccca403a40f28f6108f47c4b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:22:13 +0000 Subject: [PATCH 31/34] Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if) --- crates/formats/src/html.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 4c68f6e65..a78c51d35 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -1266,14 +1266,14 @@ impl<'r> Builder<'r> { }); if let Some(st) = attr(attrs, "style") { for decl in st.split(';') { - if let Some((k, v)) = decl.split_once(':') { - if k.trim().eq_ignore_ascii_case("vertical-align") { - match v.trim().to_ascii_lowercase().as_str() { - "top" => valign = Some(VAlign::Top), - "middle" | "center" => valign = Some(VAlign::Center), - "bottom" => valign = Some(VAlign::Bottom), - _ => {} - } + if let Some((k, v)) = decl.split_once(':') + && k.trim().eq_ignore_ascii_case("vertical-align") + { + match v.trim().to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} } } } From 789256d2d1270069295313e7af01c068caa45470 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:29:12 +0000 Subject: [PATCH 32/34] Integration: #2453 drawing_payload test builds the shape fill as a Fill --- crates/docx/tests/drawing_payload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/docx/tests/drawing_payload.rs b/crates/docx/tests/drawing_payload.rs index c8936990e..7b3d251f4 100644 --- a/crates/docx/tests/drawing_payload.rs +++ b/crates/docx/tests/drawing_payload.rs @@ -35,7 +35,7 @@ fn model_box() -> Document { kind: ShapeKind::TextBox, w: 180.0, h: 80.0, - fill: Some(Rgb(240, 248, 240)), + fill: Some(wordcraft_doc::fill::Fill::solid(Rgb(240, 248, 240))), stroke: Some(Rgb(192, 0, 0)), stroke_width: 1.0, float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 100.0, ..Default::default() }, From 3bf53830c5494c095e1b92b11345b8a7eef39bd2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:34:15 +0000 Subject: [PATCH 33/34] Integration: #1728 close the layout test the merge resolution left open --- crates/layout/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 52bac1339..ef57e1f9b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -9780,6 +9780,8 @@ fn early_float_survives_a_column_break_into_another_width() { assert!(!want.is_empty(), "second column first: {skip}: text after the break"); assert_eq!(got, want, "second column first: {skip}: the text after the break wraps around nothing"); } +} + /// A table with its own width keeps it when it is over 1.5 times the text width (#1721); one /// without is still squeezed to the margins. #[test] From d4debe0592c54b91d478db910983f30019ce6e5c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 01:34:25 +0000 Subject: [PATCH 34/34] Integration: cargo fmt --- crates/docx/src/read/story.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 0c03e028d..d7991f80e 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1640,10 +1640,12 @@ impl Reader<'_> { let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#')); let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id))); let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name))); - let on = |name: &str| attr(name).is_none_or(|v| { - let t = v.trim(); - !t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0" - }); + let on = |name: &str| { + attr(name).is_none_or(|v| { + let t = v.trim(); + !t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0" + }) + }; let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let fill = vml_fill(shape, shapetype); @@ -1721,8 +1723,7 @@ impl Reader<'_> { } part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); // Resize shape to fit text (DrawingML's `a:spAutoFit`). - let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text") - .or_else(|| vml_style(style, "mso-fit-shape-to-text")); + let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text")); part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); // Where the text sits between the box's top and bottom (DrawingML's `anchor`). if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) {