From 8922ec6ff7da3838392dfd61b456b9ed0b48243e Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Thu, 15 Oct 2026 06:42:25 +0200 Subject: [PATCH 01/33] i18n(sv): translate 32 new strings Brings the Swedish catalog up to date with upstream main (0aab569). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sv.tsv | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fe6f14673..c7e067c08 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2636,3 +2636,16 @@ preview Title Titel # Reading Mode column widths (#1040) Narrow Smal Medium Medel +template Letter Brev + Style: APA Stil: APA + ➕ New comment ➕ Ny kommentar + 📂 Browse… 📂 Bläddra… + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Envelope #10 4.12" × 9.50" Kuvert #10 4,12" × 9,50" + Envelope DL 4.33" × 8.66" Kuvert DL 4,33" × 8,66" + Executive 7.25" × 10.50" Executive 7,25" × 10,50" + Legal 8.50" × 14.00" Legal 8,50" × 14,00" + Letter 8.50" × 11.00" Letter 8,50" × 11,00" + Statement 5.50" × 8.50" Statement 5,50" × 8,50" + Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" From bdc7226fc7e001d834b8a83e839d8e0a0e9d1695 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 15 Oct 2026 11:20:32 +0200 Subject: [PATCH 02/33] i18n(it): translate the 15 strings other catalogs added since #1244 Paper and envelope sizes in the page-size list (decimal comma), the Go To and bookmark prompts, the print-job notice and the Letter template name the Croatian, Swedish and Vietnamese catalogs added. Italian again has every key the other catalogs have. --- crates/ui-egui/src/i18n/it.tsv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0edbd298a..298f317f8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2641,3 +2641,19 @@ preview Title Esempio # Reading Mode column widths (#1040) Narrow Stretta Medium Media +# Paper sizes, Go To, bookmarks and print labels the Croatian, Swedish and Vietnamese catalogs added + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Executive 7.25" × 10.50" Executive 7,25" × 10,50" + Legal 8.50" × 14.00" Legal 8,50" × 14,00" + Letter 8.50" × 11.00" Letter 8,50" × 11,00" + Statement 5.50" × 8.50" Statement 5,50" × 8,50" + Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" + Envelope #10 4.12" × 9.50" Busta n. 10 4,12" × 9,50" + Envelope DL 4.33" × 8.66" Busta DL 4,33" × 8,66" + Enter bookmark name: Inserisci il nome del segnalibro: + This document has no bookmarks. Questo documento non ha segnalibri. + Find “{text}” in the document Trova “{text}” nel documento + Type a page number, or +2 / -1. Digita un numero di pagina, oppure +2 / -1. + Wait for the print job to finish submitting before closing. Attendi che l'invio del lavoro di stampa sia terminato prima di chiudere. +template Letter Lettera From e4d9eb61b99c4185d8048f635487c3d58d307b91 Mon Sep 17 00:00:00 2001 From: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:21:24 +0300 Subject: [PATCH 03/33] Tables: rows with gridBefore/gridAfter keep their grid columns (part of #1174) A row that skips grid columns before its first cell (w:gridBefore) was laid out from column 0, so its cells sat one or more columns to the left and wrapped at the wrong width (a text cell landing on a 5 pt column sets one letter per line). Row::first_grid() gives the column the row's first cell starts at; Table::grid_col, cell_at_grid and cols(), the layout passes, vertical-merge matching, cell selection and the insert-column spot count from it, and cols() also counts gridAfter. The skipped columns stay empty and borderless, as in Word: a row's own first and last cell carry the table's outer side borders, and AutoFit gives the skipped columns the row's wBefore/wAfter width. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/table.rs | 29 +++++- crates/engine/src/cmd/table_mouse.rs | 2 +- crates/engine/src/session.rs | 2 +- crates/layout/src/table.rs | 27 +++-- crates/layout/src/tests.rs | 141 +++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 104bf96b8..a6097f6cb 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -43,6 +43,13 @@ pub struct Row { pub controls: ControlWrap, } +impl Row { + /// Grid column where the row's first cell starts: the columns `w:gridBefore` skips. + pub fn first_grid(&self) -> usize { + (self.props.grid_before as usize).min(MAX_COLS) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct Table { #[serde(default)] @@ -86,18 +93,22 @@ impl Table { } pub fn cols(&self) -> usize { - self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::()).max().unwrap_or(0)) + let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::() + r.props.grid_after as usize; + self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0)) } /// Grid column where cell `c` of row `r` starts. pub fn grid_col(&self, r: usize, c: usize) -> usize { - self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0) + self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::()).unwrap_or(0) } /// Cell index in row `r` that covers grid column `g`. pub fn cell_at_grid(&self, r: usize, g: usize) -> Option { let row = self.rows.get(r)?; - let mut x = 0; + let mut x = row.first_grid(); + if g < x { + return None; + } for (i, c) in row.cells.iter().enumerate() { if g < x + c.span() { return Some(i); @@ -374,6 +385,18 @@ impl Table { mod tests { use super::*; + #[test] + fn grid_before_shifts_the_row() { + let mut t = Table::new(2, 3, 300.0); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + assert_eq!(t.grid_col(1, 0), 1); + assert_eq!(t.grid_col(1, 1), 2); + assert_eq!(t.cell_at_grid(1, 0), None); + assert_eq!(t.cell_at_grid(1, 2), Some(1)); + assert_eq!(t.cols(), 3); + } + #[test] fn new_table_shape() { let t = Table::new(3, 4, 468.0); diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 38ac1409d..f4bd68054 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -374,7 +374,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult { /// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after) fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> { t.rows.iter().enumerate().find_map(|(r, row)| { - let mut g = 0usize; + let mut g = row.first_grid(); for (c, cell) in row.cells.iter().enumerate() { if g == line { return Some((r, c, false)); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4f16aea9a..26302d11c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -81,7 +81,7 @@ impl CellRange { let ((r0, r1), (g0, g1)) = (self.rows, self.cols); let mut out = Vec::new(); for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { - let mut g = 0usize; + let mut g = row.first_grid(); for (c, cell) in row.cells.iter().enumerate() { let end = g.saturating_add(cell.span()); if g <= g1 && end > g0 { diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 577bd0000..bb5d8ef77 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -5,7 +5,7 @@ use wordcraft_doc::numbering::Counters; use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge}; use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps}; -use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table}; +use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table}; use wordcraft_geom::Rect; use crate::para::{CellText, ClKind, ParaLayout}; @@ -178,7 +178,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new(); let mut budget = MEASURE_BUDGET; 'rows: for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); for cell in &row.cells { let span = cell.span(); let margins = cell.props.margins.unwrap_or(margins_def); @@ -235,6 +235,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f } g += span; } + spanned.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, w, w))); } for (g, span, min, max) in spanned { let Some(cs) = cols.get_mut(g..(g + span).min(ncols)) else { continue }; @@ -455,7 +456,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut heights: Vec = Vec::with_capacity(nrows); let mut bottoms: Vec = Vec::with_capacity(nrows); for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); let mut cells = Vec::with_capacity(row.cells.len()); let mut rh = 0.0f32; // The tallest cell margins plus border bands in the row. @@ -502,7 +503,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let cb = cell.props.borders.unwrap_or_default(); // A row's table property exceptions (`w:tblPrEx`) override the table's borders for its cells. let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); - let (first_g, last_g) = (g == 0, g + span >= ncols); + // A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own + // first and last cell. + let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize)); let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), @@ -636,7 +639,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava .rows .iter() .map(|r| { - let mut gx = 0; + let mut gx = r.first_grid(); r.cells .iter() .filter_map(|x| { @@ -1045,7 +1048,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep let mut spanning = Vec::new(); let mut any = false; for (ri, row) in t.rows.iter().enumerate() { - let mut g = 0usize; + let mut g = row.first_grid(); for cell in &row.cells { let span = cell.span(); if cell.props.vmerge != VMerge::Continue { @@ -1061,6 +1064,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep } g += span; } + spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue }; @@ -1074,6 +1078,17 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep any.then_some(cols) } +/// The grid columns row `row` leaves empty before its first cell and after its last one (which +/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column, +/// columns, width in points). Column widths make room for them like for an empty cell. +fn row_gaps(row: &Row, end: usize) -> impl Iterator { + let p = &row.props; + [(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)] + .into_iter() + .filter(|&(_, n, _)| n > 0) + .filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0)))) +} + /// The narrowest and widest a block list can be laid out. fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth: usize) -> (f32, f32) { let (mut lo, mut hi) = (0.0f32, 0.0f32); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d6865b0d9..c8dda44a9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1270,6 +1270,147 @@ fn table_border_corners_close() { assert!((h_left - (v_left - 1.5)).abs() < 0.01, "horizontal starts at {h_left}, vertical centre line at {v_left}"); } +/// A row with `w:gridBefore` starts at the grid column after the skipped ones, not at the first. +#[test] +fn grid_before_row_starts_after_skipped_columns() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 3, 468.0); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let x_of = |r: usize, c: usize| { + l.pages[0].items.iter().find_map(|i| match i { + Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(rect.x), + _ => None, + }) + }; + let (shifted, second) = (x_of(1, 0).unwrap(), x_of(0, 1).unwrap()); + assert!((shifted - second).abs() < 0.01, "row 1 starts at {shifted}, grid column 1 at {second}"); +} + +/// The area of cell `c` of row `r` of the table on page `page`. +fn cell_rect(l: &DocLayout, page: usize, r: usize, c: usize) -> Option { + l.pages.get(page)?.items.iter().find_map(|i| match i { + Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(*rect), + _ => None, + }) +} + +/// Rows that skip grid columns at the start and at the end (`w:gridBefore`, `w:gridAfter`, as in +/// #1174): the cells sit on their own grid columns, the middle one keeps its width, and a merge +/// down the last column matches by the shifted column. +#[test] +fn grid_before_and_after_rows_keep_their_columns_and_merges() { + let mut d = Document::from_text("before\nafter"); + let grid = [239.0f32, 679.0, 101.0, 3790.0, 2312.0, 1189.0].map(|w| w / 20.0); + let mut t = Table::new(3, 4, 468.0); + t.props.fixed = true; + t.grid = grid.to_vec(); + for (ri, row) in t.rows.iter_mut().enumerate() { + row.cells.truncate(3); + row.props.grid_before = 1; + row.props.w_before = Some(grid[0]); + for (c, span) in row.cells.iter_mut().zip([2, 1, 2]) { + c.props.span = span; + c.props.width = None; + } + row.cells[1] = wordcraft_doc::Cell::with_text("Middle cell, wide enough for a few words on a line."); + row.cells[2].props.span = 2; + row.cells[2].props.vmerge = if ri == 0 { wordcraft_doc::props::VMerge::Restart } else { wordcraft_doc::props::VMerge::Continue }; + } + // A row without the gap, and one skipping the last column instead. + t.rows[1].props = Default::default(); + t.rows[1].cells.insert(0, wordcraft_doc::Cell::empty()); + t.rows[2].props.grid_before = 0; + t.rows[2].props.grid_after = 1; + t.rows[2].cells[0].props.span = 3; + t.rows[2].cells[2].props.span = 1; + let colx = |g: usize| grid[..g].iter().sum::(); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let rect = |r, c| cell_rect(&l, 0, r, c).unwrap(); + let x0 = rect(1, 0).x; + let middle = rect(0, 1); + assert!((rect(0, 0).x - x0 - colx(1)).abs() < 0.01, "row 0 starts at {}", rect(0, 0).x - x0); + assert!((middle.x - x0 - colx(3)).abs() < 0.01 && (middle.w - grid[3]).abs() < 0.01, "middle cell {middle:?}"); + assert!((rect(2, 2).x - x0 - colx(4)).abs() < 0.01 && (rect(2, 2).w - grid[4]).abs() < 0.01, "row 2's last cell {:?}", rect(2, 2)); + // The merge runs down all three rows. + let merged = rect(0, 2); + let rows_h = rect(0, 0).h + rect(1, 0).h + rect(2, 0).h; + assert!((merged.h - rows_h).abs() < 0.01, "merged cell {} tall, rows {rows_h}", merged.h); +} + +/// A row's own first and last cell carry the table's outer side borders when it skips the grid +/// columns beyond them. +#[test] +fn skipped_columns_put_the_side_borders_on_the_rows_cells() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(3, 3, 468.0); + t.props.style = None; + let thick = Some(Border::single(3.0)); + t.props.borders = Some(Borders { left: thick, right: thick, ..Borders::default() }); + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + t.rows[2].cells.pop(); + t.rows[2].props.grid_after = 1; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let rect = |r, c| cell_rect(&l, 0, r, c).unwrap(); + let vertical_at = |x: f32, y: f32| { + l.pages[0].items.iter().any(|i| matches!(i, Placed::Rule { x0, y0, x1, y1, .. } if x0 == x1 && (x0 - x).abs() < 0.01 && *y0 <= y && *y1 >= y)) + }; + let (r1, r2) = (rect(1, 0), rect(2, 1)); + assert!(vertical_at(r1.x, r1.y + r1.h / 2.0), "no left border at row 1's first cell {r1:?}"); + assert!(vertical_at(r2.x + r2.w, r2.y + r2.h / 2.0), "no right border at row 2's last cell {r2:?}"); +} + +/// AutoFit makes the skipped columns as wide as the row says (`w:wBefore`). +#[test] +fn autofit_gives_skipped_columns_their_width() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 2, 468.0); + t.props.style = None; + for row in &mut t.rows { + for c in &mut row.cells { + *c = wordcraft_doc::Cell::with_text("A"); + } + } + t.rows[1].cells.remove(0); + t.rows[1].props.grid_before = 1; + t.rows[1].props.w_before = Some(150.0); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let gap = cell_rect(&l, 0, 1, 0).unwrap().x - cell_rect(&l, 0, 0, 0).unwrap().x; + assert!((gap - 150.0).abs() < 0.01, "skipped column {gap} wide"); +} + +/// Rows skipping a grid column keep it skipped on every page, under the repeated header row. +#[test] +fn grid_before_rows_stay_shifted_across_pages() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(80, 3, 468.0); + t.rows[0].props.header = true; + for row in t.rows.iter_mut().skip(1) { + row.cells.remove(0); + row.props.grid_before = 1; + } + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 2, "pages {}", l.pages.len()); + let column1 = cell_rect(&l, 0, 0, 1).unwrap().x; + for (pi, p) in l.pages.iter().enumerate() { + assert!(cell_rect(&l, pi, 0, 1).is_some_and(|r| (r.x - column1).abs() < 0.01), "no header on page {pi}"); + for i in &p.items { + if let Placed::Cell { rect, row, cell: 0, .. } = i + && *row > 0 + { + assert!((rect.x - column1).abs() < 0.01, "row {row} on page {pi} starts at {}", rect.x); + } + } + } +} + #[test] fn table_own_border_overrides_only_that_side() { let mut d = Document::from_text("before\nafter"); From 4818b7b8f244cec9b9ec0d591c7d6448012c72b2 Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 03:44:21 +0530 Subject: [PATCH 04/33] fix(formats): parse case-insensitive and pica units in ODT lengths --- crates/formats/src/odt.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index da438604b..e8609117b 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -947,7 +947,7 @@ fn column_sep(a: &Attrs) -> bool { /// Length → points. fn length(v: &str) -> Option { - let v = v.trim(); + let v = v.trim().to_ascii_lowercase(); let num = |s: &str| s.trim().parse::().ok().filter(|x| x.is_finite()); if let Some(n) = v.strip_suffix("pt") { num(n) @@ -957,12 +957,12 @@ fn length(v: &str) -> Option { num(n).map(|x| x * 72.0 / 2.54) } else if let Some(n) = v.strip_suffix("mm") { num(n).map(|x| x * 72.0 / 25.4) - } else if let Some(n) = v.strip_suffix("pc") { + } else if let Some(n) = v.strip_suffix("pc").or_else(|| v.strip_suffix("pi")) { num(n).map(|x| x * 12.0) } else if let Some(n) = v.strip_suffix("px") { num(n).map(|x| x * 0.75) } else { - num(v) + num(&v) } } @@ -2362,6 +2362,13 @@ mod tests { assert_eq!(length("1in"), Some(72.0)); assert_eq!(length("12pt"), Some(12.0)); assert!((length("2.54cm").unwrap() - 72.0).abs() < 0.01); + assert_eq!(length("1IN"), Some(72.0)); + assert_eq!(length("12PT"), Some(12.0)); + assert!((length("2.54CM").unwrap() - 72.0).abs() < 0.01); + assert!((length("25.4MM").unwrap() - 72.0).abs() < 0.01); + assert_eq!(length("1pc"), Some(12.0)); + assert_eq!(length("1PI"), Some(12.0)); + assert_eq!(length("10PX"), Some(7.5)); assert_eq!(length("x"), None); } From 5817874b6dd65795fe3c956b748a173cf21dfffb Mon Sep 17 00:00:00 2001 From: Divyanshu Gupta Date: Fri, 16 Oct 2026 03:47:04 +0530 Subject: [PATCH 05/33] fix(formats): parse leading plus and case-insensitive units in LaTeX lengths --- crates/formats/src/latex.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index c7c555b85..26a1f6eed 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -1669,11 +1669,12 @@ fn parse_color(model: Option<&str>, spec: &str) -> Option { /// A TeX length in points (`3cm`, `0.4\linewidth`), if it is one. fn length_pt(s: &str) -> Option { - let s = s.trim(); + let s = s.trim().strip_prefix('+').unwrap_or(s.trim()); let split = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len()); let (n, unit) = s.split_at(split); let n: f32 = if n.is_empty() { 1.0 } else { n.parse().ok()? }; - let k = match unit.trim() { + let unit = unit.trim().to_ascii_lowercase(); + let k = match unit.as_str() { "pt" => 1.0, "bp" => 72.27 / 72.0, "in" => 72.27, @@ -2804,6 +2805,16 @@ mod tests { assert!(widths.len() <= wordcraft_doc::table::MAX_COLS); } + #[test] + fn latex_length_pt_case_and_plus() { + assert_eq!(length_pt("12pt"), Some(12.0)); + assert_eq!(length_pt("12PT"), Some(12.0)); + assert_eq!(length_pt("+12pt"), Some(12.0)); + assert_eq!(length_pt("1IN"), Some(72.27)); + assert_eq!(length_pt("+2cm"), Some(56.9)); + assert_eq!(length_pt("0.5\\TEXTWIDTH"), Some(LINE_WIDTH * 0.5)); + } + const ARTICLE: &str = r#"\documentclass[11pt]{article} \usepackage[utf8]{inputenc} \usepackage{amsmath} % maths From 2ffdf353a18e6db5676b5fdc17f8720075103f0f Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:56:17 +0200 Subject: [PATCH 06/33] #2310: PDF: lam-alef drawn as two glyphs at the same x extracts in reading order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some fonts (the static Noto Naskh Arabic builds) draw لا as a lam and a zero-advance alef with their own clusters at one x. visual_order kept the tie in logical order, so the right-to-left run's ranges didn't decrease, krilla dropped /ReversedChars and السلام extracted as السالم. Ties between clusters now go in reverse text order when right to left (text order otherwise). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/pdf/src/lib.rs | 11 +++++++++-- crates/pdf/src/tests.rs | 15 +++++++++++++++ docs/file-format-parity.md | 5 +++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b..75c4f568a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,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-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 (progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; 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 @@ -200,6 +200,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening | | 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | +| 2026-10-16 | Arabic words with lam-alef copy and search whole in exported PDFs with fonts that draw لا as a lam and a zero-width alef at one spot, such as the static Noto Naskh Arabic builds (#2310): السلام no longer reads back as السالم. Clusters drawn at the same place now go in reverse text order, so viewers reverse the run as a whole again. The page looks the same | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | @@ -457,6 +458,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | +| 2026-10-16 | trivial | Progress log: lam-alef drawn as two glyphs at one x extracts whole from exported PDFs (#2310) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | | 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 38a5f46da..2a6e720a7 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -335,8 +335,15 @@ fn visual_order(text: &str, glyphs: &[(u32, f32, f32)], clusters: &[std::ops::Ra _ => groups.push((*x, vec![i])), } } - // Stable: clusters at the same x keep their order, as do glyphs sharing their text. - groups.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Clusters at the same x go in text order, reversed when right to left: some fonts draw + // lam-alef as a lam and a zero-width alef at one x, and a right-to-left run whose ranges + // don't decrease loses its reading order (#2310). Stable for glyphs sharing their text. + let tie = |idx: &[usize]| -> i64 { + let Some(r) = idx.first().and_then(|&g| clusters.get(g)) else { return 0 }; + let start = i64::try_from(r.start).unwrap_or(i64::MAX); + if text.get(r.clone()).is_some_and(|t| t.chars().any(wordcraft_fonts::is_rtl)) { -start } else { start } + }; + groups.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| tie(&a.1).cmp(&tie(&b.1)))); groups .into_iter() .flat_map(|(_, mut idx)| { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9a01541e6..7b15fd756 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1394,6 +1394,21 @@ fn left_to_right_glyphs_keep_their_order() { assert!(left_to_right(&glyphs)); } +/// A lam-alef some fonts draw as a lam and a zero-width alef at the same x goes in reverse +/// text order, so the run's ranges still decrease and viewers read لا, not ال (#2310). +/// Left-to-right clusters at one x keep their text order. +#[test] +fn clusters_at_the_same_x_go_in_reading_order() { + let text = "سلا"; + let glyphs = [(3, 20.0, 10.0), (1, 10.0, 10.0), (2, 10.0, 0.0)]; + let ranges = [0..2, 2..4, 4..6]; + assert_eq!(visual_order(text, &glyphs, &ranges, &ranges), [2, 1, 0]); + let text = "ab"; + let glyphs = [(1, 10.0, 0.0), (2, 10.0, 5.0)]; + let ranges = [0..1, 1..2]; + assert_eq!(visual_order(text, &glyphs, &ranges, &ranges), [0, 1]); +} + /// Right-to-left glyphs (reading order, drawn right to left) are written left to right a /// cluster at a time. A letter's vowel mark takes its own character and goes before the letter, /// so a viewer reversing the word reads letter then mark; a lam-alef drawn as two glyphs keeps diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 38a1638f1..847dc7ff2 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 (PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310); 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; @@ -39,7 +39,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | -| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | +| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | @@ -238,6 +238,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-14 | trivial | RTF reads and writes tracked insertions and deletions with their authors and dates (gap 19); deleted text no longer opens as normal text | | 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | | 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions | +| 2026-10-16 | trivial | PDF export: right-to-left clusters drawn at the same x (lam-alef as a lam and a zero-width alef, as in the static Noto Naskh Arabic builds) are written in reverse text order, so the run keeps `/ReversedChars` and السلام no longer extracts as السالم (#2310) | | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | From 8664afe58fd6db60aa526c1b1a8eb76bda4d95a2 Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Thu, 15 Oct 2026 11:26:00 +0200 Subject: [PATCH 07/33] fix(export): pictures marked decorative are skipped by screen readers A picture marked decorative (Alt Text > Mark as decorative, `adec:decorative` in DOCX) was still exported as content: - tagged PDF: a Figure with its alt text, or with "picture" when it had none, inline and floating alike. PDF/UA expects decorative content to be an artifact, so screen readers skip it. Draw::Image now carries the mark, and the exporter tags a decorative picture (inline, rotated or floating) as an artifact instead of a Figure. - HTML and Markdown (and ODT, which reads the same model): the description, which the document keeps when a picture is marked decorative, was written as its alt text. A decorative picture now exports with an empty alt text (alt="" in HTML), the WCAG way to say "skip this". Pictures not marked decorative are unchanged. Charts and diagrams are left as figures. Co-Authored-By: Claude Opus 5.5 --- crates/formats/src/model.rs | 15 +++++++++++---- crates/formats/src/tests.rs | 25 +++++++++++++++++++++++++ crates/layout/src/display.rs | 18 +++++++++++++----- crates/pdf/src/lib.rs | 11 +++++++++-- crates/pdf/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c2502f2e4..928d3b3da 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -945,14 +945,21 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { continue; } match obj { - Some(InlineObject::Image { media, w, h, alt, .. }) => { + Some(o @ InlineObject::Image { media, w, h, alt, .. }) => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); } + // A picture marked decorative keeps its description in the document, but + // exports with none (`alt=""`), so screen readers skip it. + let alt = if o.is_decorative() { "" } else { alt.as_str() }; match doc.media.get(media) { - Some(data) => { - out.inlines.push(Inline::Image(Img { data: data.clone(), ext: media_ext(media, data), w: *w, h: *h, alt: alt.clone() })) - } + Some(data) => out.inlines.push(Inline::Image(Img { + data: data.clone(), + ext: media_ext(media, data), + w: *w, + h: *h, + alt: alt.to_string(), + })), None if !alt.is_empty() => buf.push_str(alt), None => {} } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 4562b8c72..a011bf81a 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -598,6 +598,31 @@ fn chart_alt_text_is_an_image_alt_never_body_text() { assert_eq!(para_texts(&d), ["See below"]); } +/// A picture marked decorative exports with an empty alt text, though it keeps its description, +/// so screen readers skip it; the same picture not marked decorative keeps its alt text. +#[test] +fn decorative_picture_exports_with_empty_alt_text() { + use wordcraft_doc::para::{AltText, Float}; + let doc = |decorative: bool| { + let mut d = Document::new(); + let media = d.add_media(png(), "png"); + let float = Float { alt: AltText { decorative, ..Default::default() }, ..Default::default() }; + let pic = InlineObject::Image { media, w: 40.0, h: 20.0, alt: "Blue bar".into(), float, crop: [0.0; 4], ole: None }; + let mut p = Paragraph::with_text("Logo: ", CharProps::default()); + let n = p.len(); + p.insert_object(n, pic, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + d + }; + let text = |d: &Document, ext: &str| String::from_utf8(export(ext, d).unwrap().unwrap()).unwrap(); + let (plain, decorative) = (doc(false), doc(true)); + assert!(text(&plain, "html").contains(r#"alt="Blue bar""#), "{}", text(&plain, "html")); + assert!(text(&plain, "md").contains("![Blue bar]("), "{}", text(&plain, "md")); + let (html, md) = (text(&decorative, "html"), text(&decorative, "md")); + assert!(html.contains(r#"alt="""#) && !html.contains("Blue bar"), "{html}"); + assert!(md.contains("![](") && !md.contains("Blue bar"), "{md}"); +} + /// Hostile Markdown and HTML that rescanned the rest of the text from every `[`, `![`, `<` or `&` /// took quadratic time: a few hundred KB froze the importer for tens of seconds. #[test] diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 10fc9faa2..c2ebcd533 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -70,6 +70,9 @@ pub enum Draw { alpha: f32, /// The picture's alt text (empty for a floating or diagram picture, which tag themselves). alt: String, + /// Marked decorative (Alt Text › Mark as decorative): an artifact in tagged PDF, which + /// screen readers skip. Always `false` for a floating or diagram picture, as `alt`. + decorative: bool, }, Shape { rect: Rect, @@ -649,9 +652,12 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer match it { Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), - Placed::Image { rect, media, crop, spin, .. } => { - spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new() }], out) - } + Placed::Image { rect, media, crop, spin, .. } => spun( + *spin, + *rect, + vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new(), decorative: false }], + out, + ), Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra, .. } => { let mut items = Vec::new(); let fx = effects_in(*effects, spin.within(outer)); @@ -1399,7 +1405,7 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec out.extend(text_glyphs(doc, inside(r), it, alpha, k)), GraphicItem::Image { rect: r, media } => { - out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new() }) + out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new(), decorative: false }) } } } @@ -1714,7 +1720,8 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(); match o { InlineObject::Image { media, crop, alt, .. } => { - spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone() }], out) + let decorative = o.is_decorative(); + spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone(), decorative }], out) } InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => { let effects = effects_in(*effects, own.within(outer)); @@ -1750,6 +1757,7 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di crop: [0.0; 4], alpha, alt: String::new(), + decorative: false, }), _ => None, }) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7e853242e..e65c2b44b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -835,6 +835,11 @@ impl Exporter<'_> { for d in &draws { match d { Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // A picture marked decorative, straight or rotated: an artifact screen readers skip. + Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { decorative: true, .. })) => { + self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)) + } Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), // A rotated inline picture. Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { .. })) => { @@ -852,11 +857,13 @@ impl Exporter<'_> { } } Placed::Image { story, path, off, .. } => { - let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) { + let obj = self.doc.para(*story, path).and_then(|p| p.object_at(*off)); + let role = if obj.is_some_and(InlineObject::is_decorative) { Role::Artifact(ArtifactType::Other) } else { Role::Figure }; + let alt = match obj { Some(InlineObject::Image { alt, .. }) => alt.clone(), _ => String::new(), }; - self.tagged(s, Role::Figure, Some(&alt), |me, s| { + self.tagged(s, role, Some(&alt), |me, s| { for d in &draws { me.draw(s, d); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d112eeb36..6357bd367 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -894,6 +894,40 @@ fn inline_picture_figure_keeps_its_alt_text() { assert!(raw("").contains("/Alt(picture)"), "no alt text keeps the fallback"); } +/// A tagged PDF of one picture in a paragraph, placed by `float`, with alt text "A red square". +fn tagged_picture_pdf(float: wordcraft_doc::para::Float) -> String { + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let mut p = Paragraph::with_text("Figure: ", CharProps::default()); + let n = p.len(); + let obj = InlineObject::Image { media, w: 70.0, h: 70.0, alt: "A red square".into(), float, crop: [0.0; 4], ole: None }; + p.insert_object(n, obj, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() +} + +/// A picture marked decorative is an artifact in tagged PDF, inline, rotated or floating, so +/// screen readers skip it; the same picture not marked decorative stays a Figure with its alt text. +#[test] +fn decorative_pictures_are_artifacts_not_figures() { + use wordcraft_doc::para::{AltText, Anchor, Float, Wrap}; + let decorative = AltText { decorative: true, ..Default::default() }; + let floating = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() }; + let cases = [ + ("inline", Float::default()), + ("rotated inline", Float { rot: 30.0, ..Default::default() }), + ("floating", floating.clone()), + ("rotated floating", Float { rot: 30.0, ..floating }), + ]; + for (name, float) in cases { + let plain = tagged_picture_pdf(float.clone()); + assert!(plain.contains("/Alt(A red square)"), "{name}: a described picture is a Figure with its alt text"); + let raw = tagged_picture_pdf(Float { alt: decorative.clone(), ..float }); + assert!(!raw.contains("/Alt("), "{name}: a decorative picture has no Figure and no alt text"); + assert!(raw.contains("1 0 0 rg"), "{name}: the decorative picture is still drawn"); + } +} + /// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm). fn emf_file(records: &[Vec]) -> Vec { let le32 = |vals: &[i32]| -> Vec { vals.iter().flat_map(|v| v.to_le_bytes()).collect() }; From 73d159b6c966715825c1499dae2d4da29e6f5f93 Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Thu, 15 Oct 2026 15:56:43 +0200 Subject: [PATCH 08/33] fix(pdf): shapes with a description are figures in tagged PDF A shape with a description (Alt Text) was exported as an artifact like any other shape, so screen readers skipped it and the description was lost: an arrow or an icon drawn with shapes that means something was invisible to them. A shape that has a description, is not marked decorative and shows no text is now a Figure with its description, inline (rotated too) and floating. Shapes without a description and decorative shapes stay artifacts. A text box keeps its frame as an artifact and its text read as paragraphs: its description would replace the text, so it isn't used. The rule lives in `shape_figure_alt`. Draw::Figure's `kind` becomes an Option (None for a shape), and Placed::Shape carries the description for floating shapes. Not covered: a group's own description (its members are placed one by one), and shapes inside a Drawing Canvas. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/display.rs | 17 ++++++++--- crates/layout/src/lib.rs | 15 ++++++++++ crates/layout/src/outline.rs | 1 + crates/pdf/src/lib.rs | 18 ++++++++++-- crates/pdf/src/tests.rs | 55 ++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 10fc9faa2..f62f0e3e0 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -89,8 +89,9 @@ pub enum Draw { Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 }, /// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked. Path { segs: Vec, fill: Option, stroke: Option, stroke_width: f32, dash: Vec }, - /// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF). - Figure { alt: String, kind: GraphicKind, draws: Vec }, + /// An inline chart, diagram or described shape's draws, as one figure with its alt text (for + /// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]). + Figure { alt: String, kind: Option, draws: Vec }, /// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked /// paragraph mark in its reviser's colour). Mark { x: f32, baseline: f32, size: f32, ch: char, color: Option }, @@ -1135,7 +1136,7 @@ fn lines( Some(InlineObject::Graphic { graphic, alt, .. }) => { let mut draws = Vec::new(); spun(spin, rect, graphic_draws(doc, graphic, rect, alpha), &mut draws); - out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws }) + out.push(Draw::Figure { alt: alt.clone(), kind: Some(graphic.kind), draws }) } Some(InlineObject::Equation { .. }) => { if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) { @@ -1720,7 +1721,15 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di let effects = effects_in(*effects, own.within(outer)); let mut items = Vec::new(); shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), extra, opts, &mut items); - spun(own, rect, items, out) + match crate::shape_figure_alt(o) { + // A described shape is one figure with its description (tagged PDF). + Some(alt) => { + let mut draws = Vec::new(); + spun(own, rect, items, &mut draws); + out.push(Draw::Figure { alt: alt.to_string(), kind: None, draws }) + } + None => spun(own, rect, items, out), + } } InlineObject::Group { .. } => { let mut members = Vec::new(); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1bc00495a..22bdcbeff 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -279,6 +279,9 @@ pub enum Placed { /// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving /// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]). object: Option, + /// The description it is tagged with as a figure in tagged PDF (see + /// [`shape_figure_alt`]); empty for a shape that is an artifact. + alt: String, }, /// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at /// byte `off` of paragraph `path` (its alt text). @@ -3019,6 +3022,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR spin, extra: extra.clone(), object: Some(object), + alt: shape_figure_alt(o).unwrap_or_default().to_string(), }] } InlineObject::Graphic { graphic, .. } => { @@ -3047,6 +3051,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR spin, extra: Default::default(), object: Some(wordcraft_doc::Pos { story, path: Path(path.to_vec()), off }), + alt: String::new(), }, Placed::Clip { rect, spin, items: members }, ] @@ -3058,6 +3063,16 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR } } +/// The description a shape is tagged with as a figure in tagged PDF: its own, when it has one, +/// isn't marked decorative and shows no text. A shape without one is an artifact screen readers +/// skip, and a text box's text is read as text, so its description isn't used. +pub fn shape_figure_alt(o: &InlineObject) -> Option<&str> { + match o { + InlineObject::Shape { story: None, float, .. } if !float.alt.decorative && !float.alt.text.trim().is_empty() => Some(&float.alt.text), + _ => None, + } +} + /// A group's members, each with its rectangle when the group is at `rect`. /// `rect` moved (not turned) to where `spin` about `centre` takes its centre: a group member's /// frame inside its turned group. diff --git a/crates/layout/src/outline.rs b/crates/layout/src/outline.rs index bbbd463cc..9e48566bb 100644 --- a/crates/layout/src/outline.rs +++ b/crates/layout/src/outline.rs @@ -177,6 +177,7 @@ pub(crate) fn symbol_items(pl: &Arc, x: f32, y: f32, level: u8, chil extra: Default::default(), // A heading's symbol, not an object of its own: never left out of a page. object: None, + alt: String::new(), }; let white = Border { style: BorderStyle::Single, width: 1.1, color: Some(Rgb(0xFF, 0xFF, 0xFF)), space: 0.0 }; if level >= BODY { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7e853242e..87c92134d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -379,6 +379,14 @@ fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } +/// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's. +fn figure_alt(alt: &str, kind: Option) -> &str { + match kind { + Some(k) => graphic_alt(alt, k), + None => alt, + } +} + /// Add the segments of a kurbo path to a krilla path. fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) { let f = |v: f64| v as f32; @@ -841,8 +849,8 @@ impl Exporter<'_> { let alt = items.iter().find_map(|i| if let Draw::Image { alt, .. } = i { Some(alt.as_str()) } else { None }); self.tagged(s, Role::Figure, alt, |me, s| me.draw(s, d)) } - // An inline chart or diagram: its text is part of the figure, not the paragraph. - Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| { + // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. + Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { for d in inner { me.draw(s, d); } @@ -862,6 +870,12 @@ impl Exporter<'_> { } }); } + // A floating shape with a description: a figure (see `shape_figure_alt`). + Placed::Shape { alt, .. } if !alt.is_empty() => self.tagged(s, Role::Figure, Some(alt), |me, s| { + for d in &draws { + me.draw(s, d); + } + }), Placed::Graphic { story, path, off, graphic, .. } => { let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) { Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d112eeb36..da61e7699 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1860,3 +1860,58 @@ fn pages_print_scaled_to_other_paper() { assert_eq!(size(&bytes), size(&plain)); } } + +/// A tagged PDF of a paragraph with one blue rectangle placed by `float`: a text box showing +/// "Inside the box" when `text_box`. +fn tagged_shape_pdf(float: wordcraft_doc::para::Float, text_box: bool) -> String { + use wordcraft_doc::para::ShapeKind; + let mut d = Document::from_text("Body"); + let story = text_box + .then(|| d.add_part(wordcraft_doc::PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside the box", CharProps::default()))])); + let kind = if text_box { ShapeKind::TextBox } else { ShapeKind::Rectangle }; + let shape = InlineObject::Shape { + kind, + w: 120.0, + h: 60.0, + fill: Some(wordcraft_doc::props::Rgb(0, 0, 0xFF)), + stroke: None, + stroke_width: 0.0, + float, + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&wordcraft_doc::Pos::body(0, 4), shape, &Default::default()).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() +} + +/// A shape with a description is a Figure with it in tagged PDF, inline, rotated or floating; one +/// without, or marked decorative, stays an artifact screen readers skip, and so does a text box's +/// frame: its text is read as text, not replaced by its description. +#[test] +fn described_shapes_are_figures_with_their_description() { + use wordcraft_doc::para::{AltText, Anchor, Float, Wrap}; + let described = AltText { text: "Arrow pointing to the exit".into(), ..Default::default() }; + let floating = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() }; + let cases = [ + ("inline", Float::default()), + ("rotated inline", Float { rot: 30.0, ..Default::default() }), + ("floating", floating.clone()), + ("rotated floating", Float { rot: 30.0, ..floating }), + ]; + for (name, float) in cases { + let raw = tagged_shape_pdf(Float { alt: described.clone(), ..float.clone() }, false); + assert!(raw.contains("/Alt(Arrow pointing to the exit)"), "{name}: a described shape is a Figure with its description"); + assert!(raw.contains("0 0 1 rg"), "{name}: the shape is drawn"); + assert!(!tagged_shape_pdf(float.clone(), false).contains("/Alt("), "{name}: an undescribed shape stays an artifact"); + let decorative = AltText { decorative: true, ..described.clone() }; + assert!( + !tagged_shape_pdf(Float { alt: decorative, ..float.clone() }, false).contains("/Alt("), + "{name}: a decorative shape stays an artifact" + ); + let text_box = tagged_shape_pdf(Float { alt: described.clone(), ..float }, true); + assert!(!text_box.contains("/Alt("), "{name}: a text box is not a Figure"); + assert_eq!(text_box.matches("/S/P").count(), 2, "{name}: the text box's text is read as a paragraph"); + } +} From d16dd5cd72d1feb63c4f74dce66372ca0d386960 Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Mon, 19 Oct 2026 18:28:57 +0200 Subject: [PATCH 09/33] fix(pdf): tag each picture in a rotated group on its own A rotated inline group is drawn inside one rotation. Its tag was chosen for the whole group: an artifact as soon as one member was decorative, so a picture next to it lost its Figure and alt text; otherwise one Figure with the first picture's alt text. The group is still turned as a whole, and each picture in it is now tagged on its own: decorative ones as artifacts, the others as Figures with their own alt text. Found by the CodeRabbit review of this PR. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/lib.rs | 49 +++++++++++++++++++++++++++++++++-------- crates/pdf/src/tests.rs | 32 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e65c2b44b..45ce29fd8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -379,6 +379,15 @@ fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } +/// A picture, or a rotation holding one at any depth (a rotated group's turned members). +fn holds_picture(d: &Draw) -> bool { + match d { + Draw::Image { .. } => true, + Draw::Rotated { items, .. } => items.iter().any(holds_picture), + _ => false, + } +} + /// Add the segments of a kurbo path to a krilla path. fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) { let f = |v: f64| v as f32; @@ -792,6 +801,33 @@ impl Exporter<'_> { } } + /// A rotated inline picture or group: turned as a whole, with each picture in it tagged on its + /// own, so a decorative picture is an artifact while the others stay figures with their alt + /// text. A described shape's figure keeps its description; anything else is an artifact. + fn rotated_pictures(&mut self, s: &mut Surface, d: &Draw) { + match d { + Draw::Rotated { cx, cy, spin, items } => { + if !ok(*cx) || !ok(*cy) { + return; + } + let [a, b, c, d, e, f] = spin.matrix(*cx, *cy); + s.push_transform(&Transform::from_row(a, b, c, d, e, f)); + for it in items { + self.rotated_pictures(s, it); + } + s.pop(); + } + Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), + Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| { + for d in draws { + me.draw(s, d); + } + }), + _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + } + } + fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) { if let Some(c) = self.doc.settings.page_color && let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h)) @@ -835,17 +871,12 @@ impl Exporter<'_> { for d in &draws { match d { Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), - // A picture marked decorative, straight or rotated: an artifact screen readers skip. + // A picture marked decorative: an artifact screen readers skip. Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { decorative: true, .. })) => { - self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)) - } Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), - // A rotated inline picture. - Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { .. })) => { - let alt = items.iter().find_map(|i| if let Draw::Image { alt, .. } = i { Some(alt.as_str()) } else { None }); - self.tagged(s, Role::Figure, alt, |me, s| me.draw(s, d)) - } + // A rotated inline picture, or a rotated group holding pictures: turned as a + // whole, each picture tagged on its own (see `rotated_pictures`). + Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), // An inline chart or diagram: its text is part of the figure, not the paragraph. Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| { for d in inner { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 6357bd367..196e87ec2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -928,6 +928,38 @@ fn decorative_pictures_are_artifacts_not_figures() { } } +/// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the +/// other stays a Figure with its alt text. +#[test] +fn rotated_group_tags_each_picture_on_its_own() { + use wordcraft_doc::para::{AltText, Float, GroupChild}; + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let picture = + |alt: &str, float: Float| InlineObject::Image { media: media.clone(), w: 70.0, h: 70.0, alt: alt.into(), float, crop: [0.0; 4], ole: None }; + let decorative = Float { alt: AltText { decorative: true, ..Default::default() }, ..Default::default() }; + let group = InlineObject::Group { + w: 160.0, + h: 70.0, + float: Float { rot: 30.0, ..Default::default() }, + ch_w: 160.0, + ch_h: 70.0, + children: vec![ + GroupChild { x: 0.0, y: 0.0, obj: picture("A decorative border", decorative) }, + GroupChild { x: 90.0, y: 0.0, obj: picture("A red square", Float::default()) }, + ], + canvas: None, + }; + let mut p = Paragraph::with_text("Group: ", CharProps::default()); + let n = p.len(); + p.insert_object(n, group, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned(); + assert!(raw.contains("/Alt(A red square)"), "the picture not marked decorative is a Figure with its alt text"); + assert!(!raw.contains("A decorative border"), "the decorative picture has no Figure and no alt text"); + assert_eq!(raw.matches("/Alt(").count(), 1, "one Figure, for the picture not marked decorative"); +} + /// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm). fn emf_file(records: &[Vec]) -> Vec { let le32 = |vals: &[i32]| -> Vec { vals.iter().flat_map(|v| v.to_le_bytes()).collect() }; From 322b3d765c8ba86eb387a6f3eb277d95117e6d2a Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Mon, 19 Oct 2026 18:32:07 +0200 Subject: [PATCH 10/33] test(pdf): a described shape is exactly one Figure The test checked that the description is there, so a Figure written twice would still pass. It now also counts the alt text entries. Suggested by the CodeRabbit review of this PR. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index da61e7699..5a34df0e7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1903,6 +1903,7 @@ fn described_shapes_are_figures_with_their_description() { for (name, float) in cases { let raw = tagged_shape_pdf(Float { alt: described.clone(), ..float.clone() }, false); assert!(raw.contains("/Alt(Arrow pointing to the exit)"), "{name}: a described shape is a Figure with its description"); + assert_eq!(raw.matches("/Alt(").count(), 1, "{name}: the described shape is one Figure"); assert!(raw.contains("0 0 1 rg"), "{name}: the shape is drawn"); assert!(!tagged_shape_pdf(float.clone(), false).contains("/Alt("), "{name}: an undescribed shape stays an artifact"); let decorative = AltText { decorative: true, ..described.clone() }; From 7360d50e722feab30ec0195958d4c9c852aa9503 Mon Sep 17 00:00:00 2001 From: SlavenDj Date: Mon, 19 Oct 2026 20:09:16 +0200 Subject: [PATCH 11/33] #2958: the Print page's button leads the settings, in the primary accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Print put "Print" between the printer settings and the page settings, styled like every other button there, so the page's one action was hard to find and didn't read as an immediate action. The button now comes first in the settings column, above the printer and the settings it prints with, as Word has it, and wears the accent the app gives a dialog's primary button (the file browser's Open and Save, OK and Save in dialogs): the first thing seen, and plainly the action. It still prints through `ui.print`, and it is still disabled while a native print job is busy. --- crates/ui-egui/src/lib.rs | 33 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/print_page.rs | 23 +++++++++++----------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7f687a4e..2b959f8b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5270,6 +5270,39 @@ mod tests { } } + /// #2958: the Print button leads the settings, above the printer and the settings it prints + /// with (as in Word), and it wears the accent the app gives a dialog's primary button, so the + /// page's one action is found first and reads as one rather than as another setting. + #[test] + fn the_print_button_leads_the_settings_and_is_the_primary_action() { + use egui::accesskit::Role; + use egui_kittest::kittest::{By, Queryable}; + const BLURB: &str = "Send the document straight to the system print dialog, or save a copy in another format below."; + let mut a = app(); + a.services.print = Some(Box::new(|_| Ok(()))); + a.run("file.print", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..4 { + h.step(); + } + // The page's own Print button (the backstage list has a Print entry too): the last. + let print = h.query_all(By::new().role(Role::Button).label("Print")).last().expect("a Print button").rect(); + let blurb = h.get_by_label(BLURB).rect(); + assert!(print.min.y < blurb.min.y, "the Print button leads the settings: {print:?} above {blurb:?}"); + let accent = crate::theme::Tokens::get(&h.ctx).accent; + assert!( + h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == print && r.fill == accent)), + "the Print button is the primary (accent) button" + ); + } + /// #1156: the Print page's preview fills the pane beside the settings, zooms from its own /// bar, scrolls both ways once the page outgrows the pane, and One Page fits it again. #[test] diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 9d093887f..a963bc57e 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -147,6 +147,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 8.0; let native = app.services.print_native.is_some(); let hook = native || app.services.print.is_some(); + // #2958: as in Word, the page's action leads the settings, in the accent the app + // gives a dialog's primary button: the first thing seen, and plainly a button + // that prints now rather than one more setting in the list below. + if hook + && ui + .add_enabled(!native || app.printing.ready(), crate::widgets::primary_button(ui, tl!("Print")).min_size(vec2(320.0, 34.0))) + .clicked() + { + let params = app.ui.print.params(); + let _ = app.run("ui.print", params); + } if native { if let Some(discover) = &app.services.printers { crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned()); @@ -160,18 +171,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer.")); } - ui.add_space(4.0); - if hook - && ui - .add_enabled( - !native || app.printing.ready(), - egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)), - ) - .clicked() - { - let params = app.ui.print.params(); - let _ = app.run("ui.print", params); - } if ui .add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0))) .clicked() From 9139162e0f8c2d4ff95994a5b7a0429d0838ff36 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:19:49 +0200 Subject: [PATCH 12/33] #2802: Delete on whole table cells leaves one empty paragraph in each --- ROADMAP.md | 4 +- crates/engine/src/cmd/column.rs | 7 ++++ crates/engine/src/cmd/table.rs | 51 +++++++++++++++++++++++ crates/engine/src/tests_typing.rs | 68 +++++++++++++++++++++++++++++++ docs/typing-parity.md | 7 ++-- 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..e0f4ce0bb 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-19 · **Change:** trivial (previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; 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-19 | Delete with whole table cells selected (rows, columns, the table or one cell) empties each cell to one paragraph, as in Word: no blank paragraphs are left behind, the paragraph keeps the formatting of the cell's last one, and tables nested in the cells go too. One undo brings everything back (#2802). | | 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-19 | trivial | Progress log: Delete on selected table cells leaves one empty paragraph in each (#2802) | | 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/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index 1a9fe17d9..0763e937b 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -166,6 +166,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se { return r; } + // Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each, + // as in Word (#2802). + if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward") + && let Some(r) = super::table::clear_cells(s) + { + return r; + } let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; let cells = s.cell_range().is_some(); match id { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 9832322f4..9fc82e39f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -615,6 +615,57 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option { Some(r) } +/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in +/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its +/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text +/// is marked deleted as for any selection). +pub(crate) fn clear_cells(s: &mut Session) -> Option { + if s.doc.settings.track_changes { + return None; + } + let cr = s.cell_range()?.clone(); + let t = s.doc.table(cr.story, &cr.table)?; + let prefixes: Vec> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect(); + let spans = text_spans(s, cr.story, &prefixes); + Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans)) +} + +/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the +/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom +/// up so earlier spans stay valid; the caret goes to the start of the first cell. +fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult { + // A cell's first block (its first paragraph may have been in a nested table that goes). + let first_block = |p: &Pos| { + let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec(); + v.push(0); + Pos { story: p.story, path: Path(v), off: 0 } + }; + for (a, b) in spans { + super::controls::check_delete(s, a, b)?; + } + for (a, b) in spans.iter().rev() { + let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue }; + s.doc.delete_range(a, b)?; + // Now the cell's only block, unless it holds a table nested deeper than the paragraph + // walk (`delete_range` leaves those). + let first = first_block(a); + if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1) + && let Ok(p) = s.doc.para_mut(first.story, &first.path) + { + (p.props, p.mark) = last; + p.touch(); + } + } + let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone()); + s.sel = Selection::caret(at); + s.column = None; + s.column_mode = false; + s.goal_x = None; + s.pending = None; + super::controls::restore_placeholder(s)?; + sel_result(s) +} + fn del_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let story = s.sel.focus.story; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index c610fd34c..f1d62f18c 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1833,6 +1833,74 @@ fn backspace_deletes_selected_columns_and_delete_clears_them() { assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); } +/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or +/// `` for a nested table. +fn cell_blocks(s: &Session) -> Vec>> { + let block = |b: &std::sync::Arc| b.as_para().map_or_else(|| "
".to_string(), |p| p.plain_text()); + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect()) + .unwrap_or_default() +} + +#[test] +fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() { + // a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned), + // b2 holds a nested table after its text (#2802). + let setup = || { + let mut s = table_between(0, 0); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "\nX\nY"); + run(&mut s, "para.alignRight", json!({})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "n"); + let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default(); + let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2"); + s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap(); + s + }; + let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect(); + let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [ + ("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]), + ("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]), + ("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]), + ("table.selectTable", (0, 0), "text.delete", all), + // One cell of several: Backspace clears it too. + ("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]), + ]; + for (select, (r, c), key, cleared) in cases { + let mut s = setup(); + let before = cell_blocks(&s); + assert_eq!(before[0][0], ["
", "a1", "X", "Y"], "setup"); + assert!(before[1][1].len() > 2 && before[1][1].contains(&"
".to_string()), "setup: {:?}", before[1][1]); + // a1's first paragraph is after the nested table. + let first = usize::from((r, c) == (0, 0)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}})); + run(&mut s, select, json!({})); + run(&mut s, key, json!({})); + let mut want = before.clone(); + for &(r, c) in &cleared { + want[r][c] = vec![String::new()]; + } + assert_eq!(cell_blocks(&s), want, "{select} {key}"); + // The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell). + if cleared.contains(&(0, 0)) { + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align); + assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}"); + } + assert!(s.sel.is_collapsed(), "{select} {key}"); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}"); + // One undo brings everything back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_blocks(&s), before, "{select} {key}: undo"); + } +} + #[test] fn backspace_on_a_selected_nested_table_removes_only_it() { let mut s = table_between(1, 1); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 289ab6dea..2e23967d2 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -76,9 +76,9 @@ formatting of the text before the cursor. Not yet compared side by side in Word. | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | | Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | -| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table. A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | +| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table (each cell as below). A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | | Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` | -| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) | +| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. A cleared cell is left with one empty paragraph, however many it had, with the formatting of its last paragraph (whose mark ends the cell); a table nested in it goes too (checked against Word 2019 for Windows, #2802). With Track Changes on, Delete still marks the cells' text deleted as before. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`, `delete_on_whole_cells_leaves_one_empty_paragraph_in_each`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) | | Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` | | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | | Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` | @@ -325,6 +325,7 @@ again. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Delete on whole selected cells (rows, columns, the table, a cell) leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too (#2802) | | 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) | | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | From 1dd86b0d0afb0de5aac03f7dca35211fcedcc524 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 20 Oct 2026 09:23:17 +0800 Subject: [PATCH 13/33] A floating picture in a table cell grows the row to hold it (#3108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row/cell's height was computed from the floating *tables* that grow it (float_tables -> floats_bottom in crates/layout/src/lib.rs), but a floating (anchored, non-inline wrap) picture or shape in a cell's paragraph wasn't tracked the same way. The row sized to the cell's text alone, so a picture taller than the text hung past the row's own bottom edge and overlapped whatever came after the table — reproduced with the reporter's own document and confirmed against their real Microsoft Office 365 screenshot, which lays the same file out with the table growing to fully contain the picture. - crates/layout/src/lib.rs: layout_box now tracks the deepest point any floating (Wrap != Inline) picture or shape reaches in a box, the same way it already tracks float_tables, and folds it into floats_bottom. - crates/layout/src/tests.rs: a_floating_picture_in_a_cell_grows_the_row_to_hold_it, mirroring the existing a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it. cargo xtask ci: fmt, clippy, assets, layers and wasm all pass. Full workspace tests pass except the same 3 pre-existing, environment-only Thai-font failures already noted on #3119 and #3184 (no CRAFT_FONTS_DIR/TH Sarabun New in this build environment). Closes #3108. Co-Authored-By: Claude Sonnet 5 --- crates/layout/src/lib.rs | 12 +++++++++++- crates/layout/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a60c5f94d..4d367e0ad 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1178,6 +1178,9 @@ fn layout_box( let mut excl: Vec = Vec::new(); // Areas of the box's floating tables, which other floating tables may have to avoid. let mut float_tables: Vec = Vec::new(); + // Deepest a floating picture or shape reaches, so the box (a table cell especially) grows + // to hold it instead of letting it hang past the box's own bottom edge (#3108). + let mut float_objects_bottom = 0.0f32; let mut table_areas = Vec::new(); let mut page_table_wraps = Vec::new(); let mut y = 0.0f32; @@ -1282,6 +1285,13 @@ fn layout_box( if !p.objects.is_empty() { let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) }; let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth); + for placed in back.iter().chain(front.iter()) { + if let Placed::Object { rect, wrap, .. } = placed + && *wrap != Wrap::Inline + { + float_objects_bottom = float_objects_bottom.max(rect.bottom()); + } + } let at = behind.min(items.len()); behind += back.len(); items.splice(at..at, back); @@ -1342,7 +1352,7 @@ fn layout_box( } } } - let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max); + let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(float_objects_bottom, f32::max); ctx.hide_deleted = outer_hide; BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0), table_areas, page_table_wraps } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 174dde174..4e32f5fb2 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4995,6 +4995,27 @@ fn a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it() { assert!(below_y > last_y + 10.0, "text below the outer table at {below_y}, the inner table's last row at {last_y}"); } +/// A floating (anchored, non-inline) picture in a cell grows the row to hold it too, the same +/// way a floating table does: it must not hang past the row's own bottom edge and overlap +/// whatever follows the table (#3108). +#[test] +fn a_floating_picture_in_a_cell_grows_the_row_to_hold_it() { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + let mut t = Table::new(1, 1, 468.0); + let mut cp = wordcraft_doc::Paragraph::with_text("Caption", Default::default()); + // Short text alone would need one line (well under 100pt); the floated picture is much + // taller than that, so only its own height should set the row's height. + let picture = Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 0.0, y: 0.0, ..Default::default() }; + cp.insert_object(0, rect_shape(200.0, 300.0, picture), &Default::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)]; + let mut d = Document::from_text("Below the table"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let [(_, row_y, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell text line missing") }; + let [(_, below_y, _)] = para_at(&l, &[1])[..] else { panic!("line after the table missing") }; + assert!(below_y - row_y > 290.0, "text after the table at {below_y}, the row's text at {row_y}: the row didn't grow for the 300pt picture"); +} + #[test] fn autofit_sizes_columns_by_their_content() { let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); From 25fb9b93f82e9316f3c008e566d5fcf1fac31c40 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 20 Oct 2026 09:40:48 +0800 Subject: [PATCH 14/33] Use the rotated bounds for a floating picture's row-growing extent rect.bottom() measures the unrotated frame; a rotated picture's actual drawn bottom can extend further down (or less far), so use Spin::bounds(rect).bottom() like wrap_area does for text wrapping. Co-Authored-By: Claude Sonnet 5 --- crates/layout/src/lib.rs | 4 ++-- crates/layout/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4d367e0ad..351167f12 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1286,10 +1286,10 @@ fn layout_box( let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) }; let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth); for placed in back.iter().chain(front.iter()) { - if let Placed::Object { rect, wrap, .. } = placed + if let Placed::Object { rect, spin, wrap, .. } = placed && *wrap != Wrap::Inline { - float_objects_bottom = float_objects_bottom.max(rect.bottom()); + float_objects_bottom = float_objects_bottom.max(spin.bounds(*rect).bottom()); } } let at = behind.min(items.len()); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4e32f5fb2..e88485c80 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5016,6 +5016,28 @@ fn a_floating_picture_in_a_cell_grows_the_row_to_hold_it() { assert!(below_y - row_y > 290.0, "text after the table at {below_y}, the row's text at {row_y}: the row didn't grow for the 300pt picture"); } +#[test] +fn a_rotated_floating_picture_grows_the_row_by_its_rotated_extent() { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + let mut t = Table::new(1, 1, 468.0); + let mut cp = wordcraft_doc::Paragraph::with_text("Caption", Default::default()); + // Unrotated this shape's own `rect.bottom()` is only 40pt down. Rotated 90° about its centre + // (`Spin::bounds`: width and height swap), its drawn bottom moves to (w+h)/2 = 220pt down — + // using the unrotated rect here would undergrow the row to a fraction of that. + let picture = Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 0.0, y: 0.0, rot: 90.0, ..Default::default() }; + cp.insert_object(0, rect_shape(400.0, 40.0, picture), &Default::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)]; + let mut d = Document::from_text("Below the table"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let [(_, row_y, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell text line missing") }; + let [(_, below_y, _)] = para_at(&l, &[1])[..] else { panic!("line after the table missing") }; + assert!( + below_y - row_y > 150.0, + "text after the table at {below_y}, the row's text at {row_y}: the row didn't grow for the picture's rotated extent (unrotated bottom is only 40pt, rotated should reach ~220pt)" + ); +} + #[test] fn autofit_sizes_columns_by_their_content() { let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); From 579b2dc6ef2da5fd738ac6e69b52ca3f480f4fda Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 20 Oct 2026 10:56:26 +0800 Subject: [PATCH 15/33] A successful native print returns to the document (#3210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poll_printing turned a terminal print message into a status-bar line but never closed the Print backstage page, so it stayed open after a successful submission (reported on macOS, where printing never shows a separate OS dialog — WordCraft's own Print page is the whole flow) until the user clicked Back, even though the job had already gone to the spooler. Word returns to the document once the job is away, the same as after Save. A failed submission still leaves the Print page open, so the error and the settings to fix it stay visible. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/lib.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7f687a4e..73e76833a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1936,6 +1936,12 @@ impl WordApp { }; let text = printing::feedback(&message, |label| i18n::t(label).to_owned()); self.status(text); + // Word returns to the document once a print job is away, the same as after Save; a + // failure stays on the Print page so the error (and the settings to fix it) are visible + // (#3210: the Print page used to stay up after a successful submission). + if matches!(message, printing::Feedback::Submitted(_)) && self.ui.backstage && self.ui.backstage_page == "print" { + self.ui.backstage = false; + } } /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. @@ -5270,6 +5276,31 @@ mod tests { } } + /// #3210: a successful native print submission returns to the document, the same as Word — + /// previously the Print page stayed up until the user clicked Back, even though the job had + /// already gone to the spooler. A failed submission stays up instead, so the error (and the + /// settings to fix it) are still visible. + #[test] + fn a_successful_native_print_returns_to_the_document() { + use printing::Printer; + for ok in [true, false] { + let mut a = app(); + a.printing.printers = vec![Printer { name: "Test Printer".into(), default: true }]; + a.printing.settings.printer = "Test Printer".into(); + a.services.print_native = Some(Box::new(move |_, _, _, _| { + let (tx, rx) = std::sync::mpsc::channel(); + let _ = tx.send(if ok { Ok(String::new()) } else { Err("no paper".into()) }); + rx + })); + a.run("file.print", json!({})).unwrap(); + assert!(a.ui.backstage && a.ui.backstage_page == "print", "the Print page is open"); + a.run("ui.print", json!({})).unwrap(); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + assert_eq!(a.ui.backstage, !ok, "ok {ok}: whether the Print page is still open"); + } + } + /// #1156: the Print page's preview fills the pane beside the settings, zooms from its own /// bar, scrolls both ways once the page outgrows the pane, and One Page fits it again. #[test] From 49204c5c8bc9477e8d5537ea258d9b4bb192b940 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 20 Oct 2026 11:11:24 +0800 Subject: [PATCH 16/33] Strengthen the print-returns-to-document test's failure assertions Only checked that the Print page stayed open on a failed submission, which could pass even if some other backstage page ended up showing or the failure feedback itself wasn't retained. Also assert the Print page specifically is still the one up, and that Feedback::Failed with the actual error is still there to see. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 73e76833a..1d3b60f2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5298,6 +5298,14 @@ mod tests { let ctx = egui::Context::default(); ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); assert_eq!(a.ui.backstage, !ok, "ok {ok}: whether the Print page is still open"); + if !ok { + assert_eq!(a.ui.backstage_page, "print", "the Print page is still the one showing, not some other backstage page"); + assert!( + matches!(&a.printing.message, Some(printing::Feedback::Failed(error)) if error == "no paper"), + "the failure is still visible: {:?}", + a.printing.message + ); + } } } From ddfb47d861f35f3f3b137b687f99e9186bee9acc Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 20 Oct 2026 11:22:35 +0800 Subject: [PATCH 17/33] Restore document focus when a successful print closes the Print page Every other place that closes the backstage sets canvas.want_focus so the document actually takes keyboard input back (open_recent and others already do); this one didn't, so typing was ignored until the user clicked the document even though the Print page was gone. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d3b60f2d..c10fd2d0a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1941,6 +1941,7 @@ impl WordApp { // (#3210: the Print page used to stay up after a successful submission). if matches!(message, printing::Feedback::Submitted(_)) && self.ui.backstage && self.ui.backstage_page == "print" { self.ui.backstage = false; + self.canvas.want_focus = true; } } @@ -5294,10 +5295,12 @@ mod tests { })); a.run("file.print", json!({})).unwrap(); assert!(a.ui.backstage && a.ui.backstage_page == "print", "the Print page is open"); + a.canvas.want_focus = false; a.run("ui.print", json!({})).unwrap(); let ctx = egui::Context::default(); ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); assert_eq!(a.ui.backstage, !ok, "ok {ok}: whether the Print page is still open"); + assert_eq!(a.canvas.want_focus, ok, "ok {ok}: the document should take keyboard focus back only once the Print page actually closes"); if !ok { assert_eq!(a.ui.backstage_page, "print", "the Print page is still the one showing, not some other backstage page"); assert!( From 60e3ff8881caa088874005cdbfb4ffa1ba332747 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:57:48 +0000 Subject: [PATCH 18/33] =?UTF-8?q?i18n:=20File=20=E2=80=BA=20Home=20greetin?= =?UTF-8?q?g=20in=20every=20interface=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `home_page` already passes "Good morning", "Good afternoon" and "Good evening" through `tl!`, but nine catalogs had no entry, so File › Home opened in English in German, Estonian, Japanese, Brazilian Portuguese, Serbian (Cyrillic and Latin), Ukrainian and Chinese (Simplified and Traditional). Adds the three greetings to each, and a test that every language has them. Co-authored-by: CRBBeesy --- crates/ui-egui/src/i18n/de.tsv | 5 +++++ crates/ui-egui/src/i18n/et.tsv | 5 +++++ crates/ui-egui/src/i18n/ja.tsv | 5 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++++ crates/ui-egui/src/i18n/sr-latn.tsv | 5 +++++ crates/ui-egui/src/i18n/sr.tsv | 5 +++++ crates/ui-egui/src/i18n/tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 5 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++++ docs/localization-parity.md | 5 +++-- 11 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eeb70c65e..740c36211 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2287,3 +2287,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält. Import… Importieren… + +# File › Home greeting + Good morning Guten Morgen + Good afternoon Guten Tag + Good evening Guten Abend diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0aa9e52c1..0bd6c5d07 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2322,3 +2322,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid. Import… Impordi… + +# File › Home greeting + Good morning Tere hommikust + Good afternoon Tere päevast + Good evening Tere õhtust diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d633eac51..d5fdd0b23 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2288,3 +2288,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。 Import… インポート… + +# File › Home greeting + Good morning おはようございます + Good afternoon こんにちは + Good evening こんばんは diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a1038e1a..1bdb17817 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2300,3 +2300,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções. Import… Importar… + +# File › Home greeting + Good morning Bom dia + Good afternoon Boa tarde + Good evening Boa noite diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8e0a9136c..3f8b3c8ad 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2298,3 +2298,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije. Import… Uvezi… + +# File › Home greeting + Good morning Dobro jutro + Good afternoon Dobar dan + Good evening Dobro veče diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c55fa2c7e..3df17e90f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2302,3 +2302,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције. Import… Увези… + +# File › Home greeting + Good morning Добро јутро + Good afternoon Добар дан + Good evening Добро вече diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 42ff1c606..93daa0407 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -152,6 +152,22 @@ fn every_tab_and_command_is_translated() { assert!(missing.is_empty(), "untranslated: {missing:#?}"); } +/// File › Home opens with a greeting for the time of day (`backstage::home_page`); every +/// language has all three, so the page doesn't start in English. +#[test] +fn the_file_home_greeting_is_translated_in_every_language() { + let mut missing = Vec::new(); + for l in Lang::all().filter(|l| *l != Lang::EN) { + for s in ["Good morning", "Good afternoon", "Good evening"] { + if !has(l, s) { + missing.push(format!("{}: {s:?}", l.code())); + } + } + } + assert!(missing.is_empty(), "untranslated: {missing:#?}"); + assert_eq!(tr(lang("pt-br"), "Good afternoon"), "Boa tarde"); +} + #[test] fn korean_print_settings_and_printer_feedback_are_translated() { let korean = lang("ko"); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f32758595..6f7b37df8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2300,3 +2300,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції. Import… Імпортувати… + +# File › Home greeting + Good morning Доброго ранку + Good afternoon Добрий день + Good evening Добрий вечір diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d733ca8f0..59103b3a2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2300,3 +2300,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。 Import… 导入… + +# File › Home greeting + Good morning 早上好 + Good afternoon 下午好 + Good evening 晚上好 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8bc708ea..5da4d544c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2289,3 +2289,8 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。 Import… 匯入… + +# File › Home greeting + Good morning 早安 + Good afternoon 午安 + Good evening 晚安 diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5d430cf54..34a31e16f 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 (the File › Home greeting is translated in every catalog; 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`. @@ -26,7 +26,7 @@ native-speaker review (human). `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero, - Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every + Read Aloud) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). - **Proofing: English, Polish, Portuguese, Italian, Russian, Czech and Indonesian**, chosen per run from its language (`w:lang`, resolved through @@ -198,6 +198,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | File › Home's greeting (Good morning / afternoon / evening) added to the nine catalogs that lacked it (de, et, ja, pt-br, sr, sr-latn, uk, zh-hans, zh-hant); a test keeps it in every language | | 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 2d645dba0311818c13bae03af45c02735f345d78 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:58:52 +0900 Subject: [PATCH 19/33] #1653: RTF import decodes the East Asian multibyte code pages `\ansicpg932`/`936`/`949`/`950`, a font's `\fcharset128`/`129`/`134`/`136`, `\cpg932` and the OEM charset 255 were read one byte at a time, so Japanese, Korean and Chinese text came out as mojibake. The reader now holds the 8-bit text of a multibyte code page and decodes the run with encoding_rs (SHIFT_JIS, GBK, EUC_KR/UHC, BIG5): a lead byte and its trail byte decode together, escaped (`\'hh`) or raw. A code page switch (`\f`, `\fcharset`, `\cpg`, `\ansicpg`), a group end and the end of the document flush what is pending; invalid sequences read as U+FFFD, as Word writes them, and never lose the rest of the text. The pending buffer is capped at 4096 bytes. Single-byte code pages, `\uN?` escapes and their `\ucN` skips, the font table and every existing test are unchanged. Tests cover Shift-JIS (escaped, raw and through a `\fcharset128` font), CP949/CP936/CP950, a mixed Japanese/ASCII/ Cyrillic document whose code-page switch must flush, `\uN?` alongside multibyte text, an invalid sequence and a Windows-1252 regression. --- crates/doc/src/encoding.rs | 65 ++++++++++- crates/formats/src/rtf.rs | 210 +++++++++++++++++++++++++++++++++--- crates/formats/src/tests.rs | 6 ++ docs/file-format-parity.md | 12 +-- 4 files changed, 266 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/encoding.rs b/crates/doc/src/encoding.rs index ee70d4151..111839171 100644 --- a/crates/doc/src/encoding.rs +++ b/crates/doc/src/encoding.rs @@ -1,7 +1,8 @@ //! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows //! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central -//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman -//! (10000, RTF `\mac`). +//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman +//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC, +//! 950 Big5), whose bytes decode as runs rather than one at a time. /// Windows-1252 code points for 0x80..=0x9F. const CP1252: [u16; 32] = [ @@ -286,13 +287,47 @@ pub fn code_page_byte(code_page: u16, b: u8) -> char { if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) } } +/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`: +/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5 +/// (Traditional Chinese). +pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950]; + +/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]): +/// its bytes decode as a run, a lead byte together with its trail byte. +pub fn is_multibyte_code_page(code_page: u16) -> bool { + multibyte_encoding_name(code_page).is_some() +} + +/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]), +/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes +/// decode one at a time, and anything else. +pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> { + Some(match code_page { + 932 => "shift_jis", + 936 => "gbk", + 949 => "euc-kr", + 950 => "big5", + _ => return None, + }) +} + +/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac +/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]). +pub fn is_text_code_page(code_page: u16) -> bool { + is_byte_code_page(code_page) || is_multibyte_code_page(code_page) +} + /// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern -/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the -/// document's code page applies), symbol fonts and the double-byte East Asian charsets. +/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949, +/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default +/// charsets (the document's code page applies) and symbol fonts. pub fn charset_code_page(charset: i32) -> Option { Some(match charset { 77 => MAC_ROMAN_CODE_PAGE, - 254 | 255 => 437, + 128 => 932, + 129 => 949, + 134 => 936, + 136 => 950, 161 => 1253, 162 => 1254, 163 => 1258, @@ -302,6 +337,7 @@ pub fn charset_code_page(charset: i32) -> Option { 204 => 1251, 222 => 874, 238 => 1250, + 254 | 255 => 437, _ => return None, }) } @@ -347,6 +383,25 @@ mod tests { assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000)); } + /// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode + /// as runs (a lead byte and its trail byte together), not one at a time. + #[test] + fn east_asian_code_pages_are_multibyte() { + assert_eq!(charset_code_page(128), Some(932), "Japanese"); + assert_eq!(charset_code_page(129), Some(949), "Korean"); + assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese"); + assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese"); + assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte"); + assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]); + for cp in MULTIBYTE_CODE_PAGES { + assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}"); + assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}"); + assert!(multibyte_encoding_name(cp).is_some(), "{cp}"); + } + assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000)); + assert!(!is_text_code_page(0) && !is_text_code_page(9999)); + } + #[test] fn oem_code_pages() { let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::(); diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 95625360e..3369c9b33 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -10,8 +10,10 @@ //! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers, //! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN` //! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or -//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman…; -//! Windows-1252 when none is named), the formatting words above, stylesheet-based +//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the +//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte +//! code page's bytes are held and decoded as a run, so a lead byte and its trail byte decode +//! together; the formatting words above, stylesheet-based //! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and //! tables. Group nesting and picture sizes are capped. @@ -27,6 +29,12 @@ const MAX_GROUPS: usize = 512; const MAX_PICT: usize = 64 << 20; /// Most sections we read (`\sect`); later breaks end paragraphs only. const MAX_SECTIONS: usize = 10_000; +/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5), +/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages. +fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding> { + let label = wordcraft_doc::encoding::multibyte_encoding_name(code_page)?; + encoding_rs::Encoding::for_label(label.as_bytes()) +} // --------------------------------------------------------------------------------------------- // Export @@ -1144,6 +1152,11 @@ struct Reader { rev_name: String, skip_chars: usize, pending_high: Option, + /// A streaming decoder for the current multibyte code page, and the code page it was made for. + /// A lead byte and its trail byte decode together even when a text word, a group boundary or the + /// end of a chunk falls between them, so a run of any length never splits a character. + mb_decoder: Option, + mb_cp: u16, // Output. body: Vec, para: Para, @@ -1291,6 +1304,8 @@ impl Reader { } fn close(&mut self) { + // A group end decodes the multibyte bytes read in it, before the state is restored. + self.finish_multibyte(); self.skip_chars = 0; if self.overflow > 0 { self.overflow -= 1; @@ -1378,11 +1393,66 @@ impl Reader { } } - /// A byte of 8-bit text (`\'hh` or raw) as a char, in the current font's code page, else the - /// default font's, else the document's. - fn byte_char(&self, b: u8) -> char { - let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg }); - wordcraft_doc::encoding::code_page_byte(cp, b) + /// The code page 8-bit text is read in: the current font's, else the default font's, else the + /// document's (Windows-1252 when none is named). + fn code_page(&self) -> u16 { + self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg }) + } + + /// A byte of 8-bit text (`\'hh` or raw): a single-byte code page decodes it at once; a + /// multibyte one is fed to a streaming decoder ([`Reader::finish_multibyte`]), so a lead byte decodes + /// together with its trail byte. + fn byte(&mut self, b: u8) { + if self.skip_chars > 0 { + self.skip_chars -= 1; + return; + } + let cp = self.code_page(); + let Some(encoding) = multibyte_encoding(cp) else { + self.finish_multibyte(); + let c = wordcraft_doc::encoding::code_page_byte(cp, b); + self.ch(c); + return; + }; + // A code page switch ends the previous run (a lead byte left then becomes U+FFFD, as Word + // writes it) before the new decoder starts. + if self.mb_cp != cp || self.mb_decoder.is_none() { + self.finish_multibyte(); + self.mb_cp = cp; + self.mb_decoder = Some(encoding.new_decoder()); + } + if let Some(decoder) = self.mb_decoder.as_mut() { + // `decode_to_string` writes into the string's spare capacity only, so reserve for the + // widest one-byte result (U+FFFD is three bytes) before feeding it. + let mut out = String::with_capacity(16); + let _ = decoder.decode_to_string(&[b], &mut out, false); + if !out.is_empty() { + self.text(&out); + } + } + } + + /// Decode the multibyte bytes read so far in the code page they were read in, with U+FFFD for + /// invalid sequences, as Word writes them. A code page switch, a group end and the end of the + /// document flush; the buffer is capped, so it never grows with the input. + /// Ends the current multibyte run: the streaming decoder emits whatever it still holds (a lone + /// lead byte becomes U+FFFD, as Word writes it) and is dropped. A code page switch, a single-byte + /// run, a group end and the end of the document finish the run this way. + fn finish_multibyte(&mut self) { + let Some(mut decoder) = self.mb_decoder.take() else { return }; + let mut out = String::with_capacity(16); + let _ = decoder.decode_to_string(&[], &mut out, true); + if !out.is_empty() { + self.text(&out); + } + } + + /// Whether 8-bit input is read as a multibyte run: the current code page is one, or its bytes + /// are still pending. + /// Whether 8-bit input goes through a multibyte decoder: the ASCII fast path must not overtake + /// text the decoder has not emitted yet. + fn has_pending_multibyte(&self) -> bool { + self.mb_decoder.is_some() || wordcraft_doc::encoding::is_multibyte_code_page(self.code_page()) } fn commit_style(&mut self) { @@ -1571,6 +1641,7 @@ impl Reader { self.skip_chars -= 1; return; } + self.finish_multibyte(); let mut buf = [0u8; 4]; self.text(c.encode_utf8(&mut buf)); } @@ -1728,6 +1799,9 @@ impl Reader { } fn word(&mut self, w: &str, p: Option, starred: bool, first_in_group: bool) { + // Every control word ends a multibyte run: the code page it is read in may change here + // (`\f`, `\fcharset`, `\cpg`, `\ansicpg`, `\pard`), and `\par` must see the text before it. + self.finish_multibyte(); let on = p != Some(0); let pv = p.unwrap_or(0); if self.st.dest == Dest::Skip && !(self.in_info() && matches!(w, "title" | "author" | "subject" | "keywords" | "doccomm" | "creatim")) { @@ -1850,9 +1924,9 @@ impl Reader { self.font_cp_cur = None; } "fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv), - // A font's explicit code page (`\cpg1251`) wins over its charset. + // A font's explicit code page (`\cpg1251`, `\cpg932`) wins over its charset. "cpg" => { - if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) { + if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_text_code_page(*cp)) { self.font_cp_cur = Some(cp); } } @@ -2054,7 +2128,7 @@ impl Reader { } "fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None }, "ansicpg" => { - if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) { + if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_text_code_page(*cp)) { self.ansicpg = cp; } } @@ -2557,8 +2631,7 @@ fn parse_full(bytes: &[u8]) -> Result { if r.st.dest == Dest::Pict { r.text(&format!("{v:02x}")); } else { - let c = r.byte_char(v); - r.ch(c); + r.byte(v); } i += 4; } @@ -2602,14 +2675,13 @@ fn parse_full(bytes: &[u8]) -> Result { j += 1; } let chunk = bytes.get(i..j).unwrap_or(b""); - if r.skip_chars == 0 && chunk.is_ascii() { + if r.skip_chars == 0 && chunk.is_ascii() && !r.has_pending_multibyte() { if let Ok(s) = std::str::from_utf8(chunk) { r.text(s); } } else { for b in chunk { - let c = r.byte_char(*b); - r.ch(c); + r.byte(*b); } } i = j; @@ -2617,7 +2689,8 @@ fn parse_full(bytes: &[u8]) -> Result { } } } - // A header or footer left open by a truncated file ends here. + // A header or footer left open by a truncated file ends here, and so does a multibyte run. + r.finish_multibyte(); r.end_header_footer(); if r.para_has || !r.para.inlines.is_empty() { r.end_para(false); @@ -3024,6 +3097,111 @@ mod tests { // A code page number out of range is ignored, not truncated to another one. let f = parse(br"{\rtf1\ansi\ansicpg65973 caf\'e9\par}").unwrap(); assert_eq!(texts(&f), ["Normal:café"]); + // The OEM charset (255) is DOS code page 437, like 254. + let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset255 Courier New;}}\f0 caf\'82\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:café"]); + } + + /// #1653: the East Asian code pages (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5) decode their + /// bytes as a run, so a lead byte and its trail byte decode together, escaped or raw. + #[test] + fn east_asian_multibyte_code_pages() { + // Japanese `\ansicpg932` (CP932: 日 = 93 fa, 本 = 96 7b, 語 = 8c ea). + let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日本語"]); + // Raw 8-bit text (not `\'hh`), and raw bytes mixed with one escaped byte: RTF can't carry a + // raw `{`, `}` or `\`, so 本's trail byte 7b is written `\'7b` here. + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x8a\xbf\x8e\x9a\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:漢字"]); + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x96\\'7b\x8c\xea\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日本語"]); + // A font names the code page with `\fcharset128` or with `\cpg932`, and `\f0` selects it. + let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fcharset128 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日本語"]); + let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\cpg932 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日本語"]); + // Raw 8-bit bytes in the font table (the font's name) decode in the document's code page. + let d = import(b"{\\rtf1\\ansi\\ansicpg932\\deff0{\\fonttbl{\\f0\\fcharset128 \x96\xbe\x92\xa9;}}\\f0 x\\par}").unwrap(); + assert_eq!(d.styles.default_chr.font.as_deref(), Some("明朝")); + // Korean `\ansicpg949` (UHC: 한국어 = c7 d1 b1 b9 be ee). + let f = parse(br"{\rtf1\ansi\ansicpg949 \'c7\'d1\'b1\'b9\'be\'ee\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:한국어"]); + // Simplified Chinese `\ansicpg936` (GBK: 中文 = d6 d0 ce c4). + let f = parse(br"{\rtf1\ansi\ansicpg936 \'d6\'d0\'ce\'c4\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:中文"]); + // Traditional Chinese `\ansicpg950` (Big5: 中文 = a4 a4 a4 e5). + let f = parse(br"{\rtf1\ansi\ansicpg950 \'a4\'a4\'a4\'e5\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:中文"]); + // `\uN?` escapes still decode alongside multibyte text (`\u26412?` is 本, its `?` fallback + // skipped by `\uc1`). + let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\u26412?\'8c\'ea x\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日本語 x"]); + // An invalid sequence decodes as U+FFFD, as Word writes it, and doesn't lose the rest. + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\\par after\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:\u{FFFD}", "Normal:after"]); + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x93 x\x96\x8c\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日\u{FFFD} x膜"]); + } + + /// A multibyte run longer than any fixed buffer keeps every character: the streaming decoder + /// holds a lead byte and its trail byte together across decode steps instead of cutting the + /// character at a buffer size (the review's finding on the byte-capped flush). + #[test] + fn a_long_multibyte_run_never_splits_a_character() { + let mut bytes = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec(); + bytes.push(b'A'); + // 2048 × あ (CP932 82 a0) is 4096 bytes after the one ASCII byte: a fixed 4096-byte buffer + // would flush with the last あ's lead byte in one step and its trail byte in the next. + for _ in 0..2048 { + bytes.extend_from_slice(&[0x82, 0xA0]); + } + bytes.extend_from_slice(b"\\par}"); + let f = parse(&bytes).unwrap(); + let expected: String = std::iter::once('A').chain((0..2048).map(|_| 'あ')).collect(); + assert_eq!(texts(&f), [format!("Normal:{expected}")]); + } + + /// A lead byte with no trail byte at the end of the document becomes U+FFFD, as Word writes a + /// malformed sequence — the streaming decoder is finalised, not left holding it. + #[test] + fn an_incomplete_lead_byte_at_the_end_is_replacement() { + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93}").unwrap(); + assert_eq!(texts(&f), ["Normal:\u{FFFD}"]); + } + + /// #1653: a code page switch (`\f`, `\fcharset`, `\cpg`, `\ansicpg`), a group end and the end + /// of the document decode the multibyte bytes read so far, before the new code page applies. + #[test] + fn multibyte_code_page_switches_flush_pending_bytes() { + // Japanese (932) → ASCII → Cyrillic (`\fcharset204` with `\cpg1251`): each run decodes in + // its own code page, the last bytes before the switch included. + let f = parse( + br"{\rtf1\ansi\ansicpg932\deff0{\fonttbl{\f0 MS Mincho;}{\f1\fcharset204\cpg1251 Arial CYR;}}\f0 \'93\'fa\'96\'7b\'8c\'ea mixed \f1 \'cf\'f0\'e8\'e2\'e5\'f2\par}", + ) + .unwrap(); + assert_eq!(texts(&f), ["Normal:日本語 mixed Привет"]); + // A group end flushes: the group's 932 bytes decode before the outer 1252 text. + let f = + parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset128 MS Mincho;}}\f0 {\f1 \'93\'fa\'96\'7b\'8c\'ea}caf\'e9\par}") + .unwrap(); + assert_eq!(texts(&f), ["Normal:日本語café"]); + // An `\ansicpg` switch mid-document flushes too. + let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\ansicpg1251 \'cf\'f0\'e8\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:日При"]); + // A run left open at the end of the document isn't lost. + let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa").unwrap(); + assert_eq!(texts(&f), ["Normal:日"]); + } + + /// A Windows-1252 document (the default) reads exactly as before: high bytes one at a time. + #[test] + fn windows_1252_text_is_unchanged() { + let f = parse(b"{\\rtf1\\ansi\\ansicpg1252 caf\xe9 na\xefve \x93quoted\x94 \\'80 5\\'b0\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:café naïve “quoted” € 5°"]); + // No `\ansicpg`: the default is 1252; the byte values of an East Asian code page keep + // their 1252 meaning. + let f = parse(b"{\\rtf1\\ansi \xe9\\'92\\'93 \\'fa\\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:é’“ ú"]); } #[test] diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index caad39dce..8c276a0ce 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -513,6 +513,12 @@ fn hostile_rtf_bits() { ] { let _ = import("rtf", s).unwrap(); } + // #1653: a flood of multibyte code-page bytes (leads and trails, raw and escaped) is capped + // and never panics. + let mut rtf = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec(); + rtf.resize(rtf.len() + 200_000, 0x93); + rtf.extend_from_slice(b"\\'fa\\par\\'93"); + assert!(import("rtf", &rtf).unwrap().is_ok()); } proptest::proptest! { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68488edf..fa7e3da20 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-17 · **Change:** trivial (RTF import decodes the East Asian multibyte code pages, #1653); 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; @@ -41,11 +41,10 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), keep with next (#2813), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | -| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (`
`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified) | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | +| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (`
`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | @@ -233,6 +232,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are 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 | 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) | From a7f888055a03408a5ea8c8164a2a445629129f2b Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:58:52 +0900 Subject: [PATCH 20/33] #1681: HTML export and import keep furigana (ruby) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `InlineObject::Ruby` is written as `basereading`, in the base text's run formatting, and ``/``/`` are read back into one ruby object, so docx/doc → HTML → doc keeps the annotation. `` fallback parentheses and the tags are not text; unclosed or half-marked ruby (no ``, empty ``, no base) is tolerated and keeps the text it held, with a block boundary closing the element as a browser does. HTML states no alignment, size or raise, so import uses the Phonetic Guide's defaults (centred, half the base size, raised clear of the base text; the run's size, or 10.5 pt when none is stated). The flow model gains `Inline::Ruby`; formats without ruby (RTF, ODT, Markdown, LaTeX) write its base text as before. Tests: `html::tests::ruby_*` and `tests::html_ruby_round_trip`. Docs: file-format-parity (HTML row) and localization-parity (Japanese row). --- crates/formats/src/html.rs | 369 ++++++++++++++++++++++++++++----- crates/formats/src/latex.rs | 5 + crates/formats/src/markdown.rs | 6 +- crates/formats/src/model.rs | 75 ++++++- crates/formats/src/odt.rs | 6 +- crates/formats/src/rtf.rs | 2 + crates/formats/src/tests.rs | 27 +++ docs/file-format-parity.md | 12 +- docs/localization-parity.md | 5 +- 9 files changed, 443 insertions(+), 64 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 4f2e4384f..d41ac6656 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -4,12 +4,14 @@ //! HTML's implicit closes for `p`, `li`, `tr`, `td`/`th` and headings. It understands `p`, `div`, //! `h1`–`h6`, `b`/`strong`, `i`/`em`, `u`, `s`/`strike`/`del`, `sup`/`sub`, `code`/`kbd`, `a` //! (links and `id`/`name` anchors), `br`, `hr`, `ul`/`ol`/`li` (nested), `table`/`tr`/`td`/`th` -//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, and the CSS +//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, `ruby` +//! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS //! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`, //! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page //! breaks. Scripts and styles are skipped; nesting is capped. //! -//! Export writes clean semantic HTML with inline CSS and pictures as `data:` URIs. +//! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as +//! ``. use std::sync::Arc; @@ -943,6 +945,21 @@ impl<'r> Builder<'r> { self.pre_tail = preserved; } + /// Add a `` element's base text and reading as one inline (#1681). Half-marked ruby (no + /// base, or no reading) is plain text instead: a guide that annotates nothing is no guide, and + /// the text it held is never lost. + fn push_ruby(&mut self, base: &str, ruby: &str) { + let preserve = self.preserve(); + let text = |s: &str| if preserve { s.to_string() } else { collapse_ws(s) }; + let (base, ruby) = (text(base), text(ruby)); + let f = self.fmt(); + if base.is_empty() || ruby.is_empty() { + self.start_para().push_text(if base.is_empty() { &ruby } else { &base }, &f); + return; + } + self.start_para().inlines.push(Inline::Ruby { base, ruby, fmt: f }); + } + fn pre_fresh(&self) -> bool { self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used) } @@ -1338,6 +1355,13 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { } continue; } + if name == "ruby" && !self_close { + let r = read_ruby(&mut lx); + if !b.in_head() { + b.push_ruby(&r.base, &r.ruby); + } + continue; + } if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close { lx.skip_past_end_tag(&name); continue; @@ -1430,6 +1454,96 @@ fn skip_math(lx: &mut Lexer, first: Tok) { } } +/// A `` element's base text and reading (#1681). +#[derive(Default)] +struct RubyText { + base: String, + ruby: String, +} + +/// Which part of a `` element the text being read belongs to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RubyPart { + /// The text being annotated: an `` child, or the text outside ``/``. + Base, + /// The reading: an `` child. + Ruby, + /// `` fallback parentheses, which are not text: browsers show them only where ruby isn't + /// supported. + Fallback, +} + +/// A block-level tag ends an open `` implicitly, as it does in a browser. +fn ends_ruby(t: &Tok) -> bool { + match t { + Tok::Start { name, .. } | Tok::End(name) => block_kind(name).is_some(), + Tok::Text(_) => false, + } +} + +/// Read a `` element's content: its base text from `` children (or the text outside +/// them), its reading from `` children, and `` fallback parentheses dropped. Tolerant, +/// like the rest of the reader: tags inside are ignored, an unclosed element ends at the next +/// block-level tag (handed back to the caller) or with the input, and the tokens read are capped. +fn read_ruby(lx: &mut Lexer) -> RubyText { + /// Most tokens one `` element is read from: a missing end tag must not scan the rest of + /// a hostile file here. + const MAX_TOKENS: usize = 4096; + let mut out = RubyText::default(); + let mut part = RubyPart::Base; + let mut nested = 0usize; + let mut count = 0usize; + while count < MAX_TOKENS { + count += 1; + let start = lx.pos; + let Some(t) = lx.next() else { break }; + if nested == 0 && ends_ruby(&t) { + // The boundary ends the element: give the token back to the main reader. + lx.pos = start; + break; + } + match t { + Tok::Text(t) => match part { + RubyPart::Base => out.base.push_str(&t), + RubyPart::Ruby => out.ruby.push_str(&t), + RubyPart::Fallback => {} + }, + // The self-closing slash is ignored on HTML elements (`` opens an `rt`), as in a + // browser's parser. + Tok::Start { name, self_close, .. } => match name.as_str() { + "rb" => part = RubyPart::Base, + "rt" => part = RubyPart::Ruby, + "rp" => part = RubyPart::Fallback, + "ruby" if !self_close => nested += 1, + _ => {} + }, + Tok::End(name) => match name.as_str() { + "rb" | "rt" | "rp" => part = RubyPart::Base, + "ruby" if nested > 0 => nested -= 1, + "ruby" => break, + _ => {} + }, + } + } + out +} + +/// Collapse white space inside ruby markup as body text does (HTML's white space only: an +/// ideographic space is text), without the space the element's indentation leaves at the edges. +fn collapse_ws(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') { + if !out.ends_with(' ') { + out.push(' '); + } + } else { + out.push(c); + } + } + out.trim_matches(' ').to_string() +} + /// Parse HTML into a document. pub fn import(s: &str) -> Document { model::to_doc(&parse(s)) @@ -1493,60 +1607,67 @@ fn text_html(t: &str, at_start: bool) -> String { o } +/// The tags that carry `f`'s character formatting: what opens them, and what closes them +/// (innermost last, so the closes are written in reverse). +fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) { + let mut open = String::new(); + let mut close: Vec<&str> = Vec::new(); + // A script or other non-web target is written as plain text (`is_safe_href`). + if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) { + open.push_str(&format!("", esc(l))); + close.push(""); + } + let mut tag = |o: &str, c: &'static str| { + open.push_str(o); + close.push(c); + }; + if f.bold { + tag("", ""); + } + if f.italic { + tag("", ""); + } + if f.underline { + tag("", ""); + } + if f.strike { + tag("", ""); + } + if f.sup { + tag("", ""); + } else if f.sub { + tag("", ""); + } + if f.code { + tag("", ""); + } + let mut css = Vec::new(); + if let Some(c) = f.color { + css.push(format!("color:#{}", c.hex())); + } + if let Some(c) = f.background { + css.push(format!("background-color:#{}", c.hex())); + } + if let Some(s) = f.size { + css.push(format!("font-size:{}pt", trim_num(s))); + } + if let Some(fam) = &f.font { + css.push(format!("font-family:'{}'", esc(&fam.replace('\'', "")))); + } + if !css.is_empty() { + open.push_str(&format!("", css.join(";"))); + close.push(""); + } + (open, close) +} + /// Inline spans → HTML. pub fn inlines_html(inlines: &[Inline]) -> String { let mut o = String::new(); for i in inlines.iter() { match i { Inline::Text(t, f) => { - let mut open = String::new(); - let mut close: Vec<&str> = Vec::new(); - // A script or other non-web target is written as plain text (`is_safe_href`). - if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) { - open.push_str(&format!("", esc(l))); - close.push(""); - } - let mut tag = |o: &str, c: &'static str| { - open.push_str(o); - close.push(c); - }; - if f.bold { - tag("", ""); - } - if f.italic { - tag("", ""); - } - if f.underline { - tag("", ""); - } - if f.strike { - tag("", ""); - } - if f.sup { - tag("", ""); - } else if f.sub { - tag("", ""); - } - if f.code { - tag("", ""); - } - let mut css = Vec::new(); - if let Some(c) = f.color { - css.push(format!("color:#{}", c.hex())); - } - if let Some(c) = f.background { - css.push(format!("background-color:#{}", c.hex())); - } - if let Some(s) = f.size { - css.push(format!("font-size:{}pt", trim_num(s))); - } - if let Some(fam) = &f.font { - css.push(format!("font-family:'{}'", esc(&fam.replace('\'', "")))); - } - if !css.is_empty() { - open.push_str(&format!("", css.join(";"))); - close.push(""); - } + let (open, close) = fmt_tags(f); let at_start = o.is_empty(); o.push_str(&open); o.push_str(&text_html(t, at_start)); @@ -1554,6 +1675,17 @@ pub fn inlines_html(inlines: &[Inline]) -> String { o.push_str(c); } } + // A phonetic guide: the base text with its reading in `` (#1681). HTML ruby has + // no alignment or size, so the guide's own attributes stay in the document model. + Inline::Ruby { base, ruby, fmt } => { + let (open, close) = fmt_tags(fmt); + o.push_str(&open); + let at_start = o.is_empty(); + o.push_str(&format!("{}{}", text_html(base, at_start), text_html(ruby, false))); + for c in close.iter().rev() { + o.push_str(c); + } + } Inline::Image(img) => { o.push_str(&format!( "\"{}\"", @@ -2046,4 +2178,141 @@ mod tests { let back = import(&html); assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead"); } + + /// A document that reads "I read " then a phonetic guide over `base`, then " daily.". + fn ruby_doc(base: &str, ruby: &str) -> Document { + let props = wordcraft_doc::CharProps::default(); + let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone()); + let n = p.len(); + p.insert_object(n, crate::model::ruby_object(base.into(), ruby.into(), 20.0), &props).unwrap(); + let n = p.len(); + p.insert_text(n, " daily.", &props).unwrap(); + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(p)]; + d + } + + /// The first flow ruby: its base text and reading. + fn ruby_of(f: &Flow) -> Option<(String, String)> { + f.blocks.iter().find_map(|b| match b { + FBlock::Para(p) => p.inlines.iter().find_map(|i| match i { + Inline::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())), + _ => None, + }), + _ => None, + }) + } + + /// The first document ruby: its base text and reading. + fn doc_ruby(d: &Document) -> Option<(String, String)> { + d.body.iter().find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())), + _ => None, + }), + _ => None, + }) + } + + /// The first document ruby: base size, ruby size, raise and alignment. + fn doc_ruby_attrs(d: &Document) -> Option<(f32, f32, f32, wordcraft_doc::para::RubyAlign)> { + d.body.iter().find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Ruby { base_size, size, raise, align, .. } => Some((*base_size, *size, *raise, *align)), + _ => None, + }), + _ => None, + }) + } + + #[test] + fn ruby_exports_as_ruby_markup() { + // #1681: the base text with its reading in ``, not plain base text. + let out = export(&ruby_doc("漢字", "かんじ")); + assert!(out.contains("

I read 漢字かんじ daily.

"), "{out}"); + // The base and the reading are escaped like any other text. + let out = export(&ruby_doc("A&B", "xA&Bx<y
"), "{out}"); + } + + #[test] + fn ruby_imports_with_its_surrounding_text() { + let f = parse("

I read 漢字かんじ daily.

"); + assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into()))); + assert_eq!(texts(&f), vec!["NormalNone:I read 漢字 daily."]); + let FBlock::Para(p) = &f.blocks[0] else { panic!() }; + assert!(matches!(&p.inlines[0], Inline::Text(t, _) if t == "I read "), "{:?}", p.inlines); + assert!(matches!(p.inlines.last(), Some(Inline::Text(t, _)) if t == " daily."), "{:?}", p.inlines); + // One document object, holding the base text and the reading. + assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into()))); + } + + #[test] + fn ruby_rb_and_rp_fallbacks_import_as_one_guide() { + // `
` holds the base text and `` the fallback parentheses for browsers without + // ruby: the parentheses are not text, and the base pieces join. + let f = parse("

漢字(かんじ)

"); + assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into()))); + assert_eq!(texts(&f), vec!["NormalNone:漢字"]); + assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into()))); + } + + #[test] + fn ruby_import_uses_the_phonetic_guide_defaults() { + // HTML states no alignment, size or raise: the Phonetic Guide's own defaults over a + // 20 pt run (half the base size, raised clear of the base text, centred)… + let d = crate::html::import("

漢字かんじ

"); + let Some((base_size, size, raise, align)) = doc_ruby_attrs(&d) else { panic!("no ruby") }; + assert_eq!((base_size, size, align.ooxml()), (20.0, 10.0, "center")); + assert!((raise - 18.0).abs() < 0.001, "{raise}"); + // …and the guide's 10.5 pt fallback base size when the source states none. + let d = crate::html::import("

漢字かんじ

"); + let Some((base_size, size, raise, _)) = doc_ruby_attrs(&d) else { panic!("no ruby") }; + assert_eq!((base_size, size), (10.5, 5.0)); + assert!((raise - 9.4).abs() < 0.001, "{raise}"); + } + + #[test] + fn ruby_survives_an_html_round_trip() { + let html = export(&ruby_doc("漢字", "かんじ")); + assert_eq!(ruby_of(&parse(&html)), Some(("漢字".into(), "かんじ".into()))); + let back = crate::html::import(&html); + assert_eq!(doc_ruby(&back), Some(("漢字".into(), "かんじ".into()))); + assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily."); + } + + #[test] + fn ruby_keeps_the_run_formatting() { + let props = wordcraft_doc::CharProps { bold: Some(true), ..Default::default() }; + let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone()); + let n = p.len(); + p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap(); + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(p)]; + assert!(export(&d).contains("漢字かんじ"), "{}", export(&d)); + // Importing it back keeps the formatting for the base text and the reading. + let f = parse("

漢字かんじ

"); + let FBlock::Para(p) = &f.blocks[0] else { panic!() }; + assert!(matches!(&p.inlines[0], Inline::Ruby { fmt, .. } if fmt.bold), "{:?}", p.inlines); + } + + #[test] + fn malformed_ruby_keeps_its_text() { + // No reading, an empty reading, a missing base, an unclosed element and a block boundary + // closing it: none of them panics, and the text inside is kept. + let cases: [(&str, &[&str]); 7] = [ + ("

a 漢字 b

", &["NormalNone:a 漢字 b"]), + ("

a 漢字 b

", &["NormalNone:a 漢字 b"]), + ("

a かんじ b

", &["NormalNone:a かんじ b"]), + ("

a b

", &["NormalNone:a b"]), + ("", &[]), + ("

a 漢字かんじ b

next

", &["NormalNone:a 漢字 b", "NormalNone:next"]), + ("

a 漢字かんじ

next

", &["NormalNone:a 漢字", "NormalNone:next"]), + ]; + for (src, want) in cases { + assert_eq!(texts(&parse(src)), want, "{src}"); + } + // Ruby in ruby: the inner element's text joins the outer guide's. + assert_eq!(ruby_of(&parse("

漢字字じかんじ

")), Some(("漢字字".into(), "じかんじ".into()))); + } } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index cc2157268..fe544f91f 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -266,6 +266,11 @@ impl Writer { } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), Inline::Figure(_) => {} + // LaTeX keeps no ruby (no package): its base text, in the run's formatting. + Inline::Ruby { base, fmt, .. } => { + let s = self.span(base, fmt); + o.push_str(&s); + } Inline::Equation { linear, display } => { self.uses.math = true; let src = linear_to_latex(linear); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 93ef23efe..994eed71c 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1143,7 +1143,9 @@ fn inlines_md_table(inlines: &[Inline]) -> String { fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { // Split into (text, fmt) pieces with whitespace moved outside emphasis. let mut pieces: Vec<(String, Fmt, Option)> = Vec::new(); - let inlines = crate::model::equations_as_text(inlines); + let eq = crate::model::equations_as_text(inlines); + // Markdown keeps no ruby: its base text goes through the text path below. + let inlines = crate::model::ruby_as_text(&eq); for i in inlines.iter() { match i { Inline::Text(t, f) => { @@ -1176,6 +1178,8 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { } Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), Inline::Equation { .. } => {} + // Read as its base text by `ruby_as_text` above. + Inline::Ruby { .. } => {} } } let mut out = String::new(); diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 449ba1990..caf63b524 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -5,7 +5,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::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise}; use wordcraft_doc::props::{ Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VMerge, VertAlign, }; @@ -91,6 +91,34 @@ pub enum Inline { linear: String, display: bool, }, + /// Ruby (phonetic guide): `ruby` text (furigana, pinyin…) over `base` text, in the formatting + /// of the run it sits in (#1681). Formats without ruby write the base text + /// ([`ruby_as_text`]). + Ruby { + base: String, + ruby: String, + fmt: Fmt, + }, +} + +/// Base text size (points) a phonetic guide falls back to when the source states none: the +/// Phonetic Guide command's own fallback for an unresolved size +/// (`crates/engine/src/cmd/phonetic.rs`). +pub const RUBY_BASE_SIZE: f32 = 10.5; + +/// Ruby text size for a base of `base_size` points: half the base size, rounded down to a half +/// point, as the Phonetic Guide command sets it. +fn ruby_default_size(base_size: f32) -> f32 { + (base_size.floor() / 2.0).max(1.0) +} + +/// The ruby a format without its own ruby markup gets: a [`InlineObject::Ruby`] over `base` with +/// the Phonetic Guide's defaults (centred, half the base size, raised clear of the base text). +pub fn ruby_object(base: String, ruby: String, base_size: f32) -> InlineObject { + let base_size = if base_size.is_finite() { base_size.clamp(1.0, 1584.0) } else { RUBY_BASE_SIZE }; + let size = ruby_default_size(base_size); + let raise = ruby_raise(0.0, base_size, size).clamp(0.0, 1584.0); + InlineObject::Ruby { base, ruby, align: RubyAlign::default(), size, raise, base_size, lang: String::new(), props: Default::default() } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -170,12 +198,13 @@ impl Para { } self.inlines.push(Inline::Text(s.to_string(), f.clone())); } - /// The plain text (images and anchors left out; equations as their linear text). + /// The plain text (images and anchors left out; equations as their linear text, ruby as its + /// base text). pub fn text(&self) -> String { let mut s = String::new(); for i in &self.inlines { match i { - Inline::Text(t, _) | Inline::Equation { linear: t, .. } => s.push_str(t), + Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t), Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} } } @@ -191,7 +220,7 @@ impl Para { Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)), _ => s.push_str(t), }, - Inline::Equation { linear: t, .. } => s.push_str(t), + Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t), Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} } } @@ -363,6 +392,22 @@ pub fn equations_as_text(inlines: &[Inline]) -> std::borrow::Cow<'_, [Inline]> { std::borrow::Cow::Owned(p.inlines) } +/// `inlines` with ruby as its base text, for formats that have no ruby markup: the base text (and +/// everything around it) keeps its formatting, and the reading is left out. +pub fn ruby_as_text(inlines: &[Inline]) -> std::borrow::Cow<'_, [Inline]> { + if !inlines.iter().any(|i| matches!(i, Inline::Ruby { .. })) { + return std::borrow::Cow::Borrowed(inlines); + } + let mut p = Para::default(); + for i in inlines { + match i { + Inline::Ruby { base, fmt, .. } => p.push_text(base, fmt), + other => p.inlines.push(other.clone()), + } + } + std::borrow::Cow::Owned(p.inlines) +} + // --------------------------------------------------------------------------------------------- // Flow → Document @@ -568,6 +613,20 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(obj)); } } + Inline::Ruby { base, ruby, fmt } => { + let (base, ruby) = (clean_text(base), clean_text(ruby)); + let cp = char_props(fmt); + // A source without a base or without a reading (malformed markup) is just its + // text: a guide that annotates nothing is no guide. + if base.is_empty() || ruby.is_empty() { + append(&mut out, if base.is_empty() { &ruby } else { &base }, &cp, None); + } else { + // Ruby attributes a format without them doesn't state (HTML): the + // Phonetic Guide's defaults for a new guide over this run's text. + let obj = ruby_object(base, ruby, cp.size.unwrap_or(RUBY_BASE_SIZE)); + append(&mut out, "", &cp, Some(obj)); + } + } } } out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m)); @@ -988,6 +1047,14 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { let linear = if linear.is_empty() { wordcraft_doc::math::to_linear(&math.nodes) } else { linear.clone() }; out.inlines.push(Inline::Equation { linear, display: *display }); } + Some(InlineObject::Ruby { base, ruby, .. }) => { + if !buf.is_empty() { + out.push_text(&std::mem::take(&mut buf), &f); + } + // The base text and reading, in the run's formatting; the guide's own + // attributes (alignment, size, raise) stay in the document model. + out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() }); + } Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 2df9d4519..70092b1f8 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -347,7 +347,9 @@ impl Writer { fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) { let mut prev_space = true; - let inl = model::equations_as_text(inl); + let eq = model::equations_as_text(inl); + // ODF has no ruby: its base text goes through the text path below. + let inl = model::ruby_as_text(&eq); let last = inl.len().saturating_sub(1); for (idx, i) in inl.iter().enumerate() { match i { @@ -424,6 +426,8 @@ impl Writer { prev_space = false; } Inline::Anchor(a) => out.push_str(&format!("", x(a))), + // Read as its base text by `ruby_as_text` above. + Inline::Ruby { .. } => {} Inline::Figure(_) | Inline::Equation { .. } => {} } } diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 95625360e..6d25068c4 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -403,6 +403,8 @@ impl Writer { let a = esc(a); out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}")); } + // RTF keeps no ruby yet: its base text, in the run's formatting. + Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), Inline::Figure(_) | Inline::Equation { .. } => {} } } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index caad39dce..b437445d5 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -224,6 +224,33 @@ fn html_round_trip() { assert_eq!(back.core.title, "Sample Title"); } +#[test] +fn html_ruby_round_trip() { + // #1681: a phonetic guide exports as `` and opens back as one ruby object, its base + // text still in the paragraph. + let props = CharProps::default(); + let mut p = Paragraph::with_text("I read ", props.clone()); + let n = p.len(); + p.insert_object(n, model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap(); + let n = p.len(); + p.insert_text(n, " daily.", &props).unwrap(); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + + let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap(); + assert!(html.contains("漢字かんじ"), "{html}"); + let back = import("html", html.as_bytes()).unwrap().unwrap(); + assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily."); + let ruby = back.body.iter().find_map(|b| match &**b { + Block::Para(p) => p.objects.iter().find_map(|o| match o { + InlineObject::Ruby { base, ruby, .. } => Some((base.as_str(), ruby.as_str())), + _ => None, + }), + _ => None, + }); + assert_eq!(ruby, Some(("漢字", "かんじ"))); +} + #[test] fn rtf_round_trip() { check_round_trip("rtf", true); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68488edf..f563b1b66 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-17 · **Change:** trivial (HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681); 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; @@ -41,11 +41,10 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), keep with next (#2813), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | -| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (`
`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified) | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | +| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (`
`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | @@ -233,6 +232,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | HTML export writes ruby (furigana, phonetic guides) as `basereading`, in the base text's run formatting, and HTML import reads ``/``/`` back into one ruby object: `` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | | 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/localization-parity.md b/docs/localization-parity.md index 5d430cf54..35e468df3 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-17 · **Change:** trivial (Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681); 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`. @@ -54,7 +54,7 @@ shared items below. | French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) | | Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) | | Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 | -| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 | +| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); no vertical text or document grid | no | partial | 3–5 | | German | de | 2,021 entries; same key set as zh-hans (#574); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 1–2 | | Korean | ko | 2,463 entries (all catalog keys and preview context) | most; newer panes English | Hangul ✅ (Korean-first embedded/system UI font fallback); macOS Korean IME fixes pending (#163, #164) | no | partial | 3–5 | | Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) | @@ -198,6 +198,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Japanese row: ruby (Phonetic Guide) is supported over its base text and HTML export/import keeps it (#1681); the row had listed ruby as missing | | 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 fc36739622a7596b38c2ad4938813ddf0b43ef4f Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:31:15 +0200 Subject: [PATCH 21/33] #2038: a click below a floating table goes to the text, not its last row A table dragged by its move handle floats and the empty paragraph after it moves up beside it; a click below the table then landed in a cell of its last row (the nearest line). Pages now list their floating body tables, and DocLayout::hit leaves out a floating table's cell lines when the point is outside the area its cells cover, as long as other lines are left. Checked against Word 2019 for Windows: clicks below the table and Ctrl+End go to the paragraph beside or after it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/engine/src/cmd/table_mouse.rs | 31 ++++++++++++++++++++++++++++ crates/layout/src/hit.rs | 25 +++++++++++++++++++++- crates/layout/src/lib.rs | 4 ++++ docs/layout-parity.md | 5 +++-- 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..6282b5d32 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 click below a floating table reaches the text after it, #2038; 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 click below a floating table (one dragged by its move handle, or from a .docx) goes to the paragraph beside or after it, as in Word, instead of a cell of its last row, so the text after a moved table can be reached with the mouse again (#2038). - 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 click below a floating table goes to the text after it, not its last row (#2038) | | 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/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 38ac1409d..240328569 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -1027,4 +1027,35 @@ mod tests { let moved = cell_rect(&mut s, 0, 0); assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}"); } + + /// #2038: a table dragged to the right floats and the empty paragraph after it moves up beside + /// it. As in Word, a click anywhere below the table then goes to that paragraph, not to a cell of the last row; a click in a cell still lands there. + #[test] + fn a_click_below_a_floating_table_goes_to_the_text_not_its_cells() { + let mut s = table_session(3, 3); + let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]); + let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0); + s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 60.0, "toY": hy + 10.0})).unwrap(); + assert!(first_table(&s).props.float.is_some()); + let after = Path(vec![table_path(&s).0[0] + 1]); + assert!(s.doc.block(StoryRef::Body, &after).is_some_and(|b| b.as_table().is_none()), "a paragraph follows the table"); + let (first, last) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 2)); + let (mid, bottom, margin) = ((first.x + last.right()) / 2.0, last.bottom(), s.layout().pages[0].body.x + 5.0); + let at = |s: &mut Session, x: f32, y: f32| { + s.run("caret.set", &json!({"page": 0, "x": x, "y": y})).unwrap(); + s.sel.focus.path.clone() + }; + for (name, x, y) in [ + ("just below the middle", mid, bottom + 12.0), + ("far below the middle", mid, bottom + 200.0), + ("below, at the margin", margin, bottom + 12.0), + ] { + assert_eq!(at(&mut s, x, y), after, "a click {name}"); + } + let b2 = cell_rect(&mut s, 1, 1); + assert_eq!(at(&mut s, b2.x + b2.w / 2.0, mid_y(b2)), cell_pos(&s, 1, 1).path, "in B2"); + assert_eq!(at(&mut s, last.x + last.w / 2.0, last.bottom() - 1.0), cell_pos(&s, 2, 2).path, "low in C3"); + s.run("caret.docEnd", &json!({})).unwrap(); + assert_eq!(s.sel.focus.path, after, "Ctrl+End"); + } } diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 2445b90a1..86b4e931d 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -253,6 +253,23 @@ fn nearest_line_at<'a>(page: &'a Page, y: f32, top_only: bool) -> Option Vec<(&Path, Rect)> { + page.float_tables + .iter() + .filter_map(|t| { + page.items + .iter() + .filter_map(|it| match it { + Placed::Cell { rect, table, story: StoryRef::Body, .. } if table == t => Some(*rect), + _ => None, + }) + .reduce(|a, b| a.union(&b)) + .map(|r| (t, r)) + }) + .collect() +} + /// How far `v` is outside the band `top..top + h`. fn band_dist(v: f32, top: f32, h: f32) -> f32 { if v < top { @@ -276,7 +293,13 @@ impl DocLayout { // A page of turned text (a vertical section): the nearest turned line. return nearest_turned(p, story, x, y); } - let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?; + // Like Word, a click outside a floating table doesn't land in its cells while there's + // other text on the page: below one, it goes to the text beside or after it (#2038). + let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() }; + let away = + |l: &LineHit| floats.iter().any(|(t, r)| l.path.0.len() > t.0.len() && l.path.0.starts_with(&t.0) && !r.contains(Point::new(x, y))); + let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y)); + let best = lines.iter().filter(|&l| !away(l)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?; let off = best.para.off_at_x(best.li, x - best.x); Some(Pos { story: best.story, path: best.path.clone(), off }) } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a60c5f94d..f614df533 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -484,6 +484,9 @@ pub struct Page { /// Width of the markup area on the right of `w` that holds comment balloons (Print Markup, /// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide. pub markup: f32, + /// The top-level body tables placed on this page as floating tables (`w:tblpPr`): a click + /// outside one doesn't land in its cells ([`DocLayout::hit`]). + pub float_tables: Vec, } /// The whole layout. @@ -4284,6 +4287,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa } y += row.height; } + pg.float_tables.push(Path::top(block)); } let area = r.inset(-dl, -dt, -dr, -db); pb.excl.push((area, false, None, WrapText::BothSides)); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4873f4481..80b4147a0 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 click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; 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, @@ -60,7 +60,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | -| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | +| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); other 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; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | @@ -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 click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | | 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 79270d8314b0460a54e7011fb1f616f7ed27e252 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:28:50 +0200 Subject: [PATCH 22/33] #2038: the same for turned text in a vertical section On a page of turned text (a vertical section) DocLayout::hit returned through nearest_turned before the floating-table rule applied, so a click outside a floating table there could still land in one of its cells. nearest_turned now takes the same rule: the nearest turned line outside the floating tables, else the nearest of any (as before). Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/hit.rs | 22 ++++++------- crates/layout/src/tests_vertical.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 86b4e931d..2eb70b316 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -226,11 +226,12 @@ fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option { }) } -/// The position in the turned text of `story` nearest to page point (x, y), wherever it is. -fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option { +/// The position in the turned text of `story` nearest to page point (x, y), wherever it is, in a +/// paragraph `skip` doesn't leave out. +fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option { let dist = |r: Rect| band_dist(x, r.x, r.w).hypot(band_dist(y, r.y, r.h)); let it = items_of(page, story) - .filter(|it| matches!(it, Placed::Lines { story: s, .. } if *s == story)) + .filter(|it| matches!(it, Placed::Lines { story: s, path, .. } if *s == story && !skip(path))) .filter_map(|it| it.turned_bounds().map(|r| (it, dist(r)))) .min_by(|a, b| a.1.total_cmp(&b.1))? .0; @@ -288,18 +289,17 @@ impl DocLayout { if let Some(pos) = turned_hit(p, story, x, y) { return Some(pos); } - let lines = page_lines(p, story); - if lines.is_empty() { - // A page of turned text (a vertical section): the nearest turned line. - return nearest_turned(p, story, x, y); - } // Like Word, a click outside a floating table doesn't land in its cells while there's // other text on the page: below one, it goes to the text beside or after it (#2038). let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() }; - let away = - |l: &LineHit| floats.iter().any(|(t, r)| l.path.0.len() > t.0.len() && l.path.0.starts_with(&t.0) && !r.contains(Point::new(x, y))); + let away = |path: &Path| floats.iter().any(|(t, r)| path.0.len() > t.0.len() && path.0.starts_with(&t.0) && !r.contains(Point::new(x, y))); + let lines = page_lines(p, story); + if lines.is_empty() { + // A page of turned text (a vertical section): the nearest turned line. + return nearest_turned(p, story, x, y, &away).or_else(|| nearest_turned(p, story, x, y, &|_: &Path| false)); + } let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y)); - let best = lines.iter().filter(|&l| !away(l)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?; + let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?; let off = best.para.off_at_x(best.li, x - best.x); Some(Pos { story: best.story, path: best.path.clone(), off }) } diff --git a/crates/layout/src/tests_vertical.rs b/crates/layout/src/tests_vertical.rs index aaec1c9ef..3cff4797e 100644 --- a/crates/layout/src/tests_vertical.rs +++ b/crates/layout/src/tests_vertical.rs @@ -118,3 +118,52 @@ fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() { assert_eq!(l.caret(&Pos::body(0, 0)).unwrap().width, 0.0); assert!(l.caret(&Pos::body(1, 0)).unwrap().width > 0.0); } + +/// #2038 in a vertical section, where every line is turned: a click outside a floating table +/// goes to the text beside or after it, not to a cell of the table nearer the click. +#[test] +fn a_click_outside_a_floating_table_in_a_vertical_section_skips_its_cells() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::TableFloat; + let mut d = Document::from_text("Text"); + d.last_section.text_direction = TextDirection::Down; + let mut t = wordcraft_doc::Table::new(3, 2, 200.0); + t.props.float = Some(TableFloat { + h_rel: Anchor::Column, + v_rel: Anchor::Paragraph, + x: 150.0, + dist: [9.0, 0.0, 9.0, 0.0], + overlap: true, + ..Default::default() + }); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + let l = lay(&d); + let page = &l.pages[0]; + assert_eq!(page.float_tables, [Path::top(0)]); + let area = + page.items.iter().filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None }).reduce(|a, b| a.union(&b)).unwrap(); + let turned: Vec<(&Path, Rect)> = page + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, .. } => it.turned_bounds().map(|r| (path, r)), + _ => None, + }) + .collect(); + assert!(!turned.is_empty() && hit::page_lines(page, StoryRef::Body).is_empty(), "only turned lines on the page"); + let band = |v: f32, lo: f32, len: f32| (lo - v).max(v - lo - len).max(0.0); + let dist = |r: &Rect, x: f32, y: f32| band(x, r.x, r.w).hypot(band(y, r.y, r.h)); + // Points just outside the table, off every line, whose nearest line is one of the table's. + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let points: Vec<(f32, f32)> = [(area.x - 12.0, cy), (area.right() + 12.0, cy), (cx, area.y - 12.0), (cx, area.bottom() + 12.0)] + .into_iter() + .filter(|&(x, y)| page.body.contains(Point::new(x, y)) && turned.iter().all(|(_, r)| !r.expand(2.0).contains(Point::new(x, y)))) + .filter(|&(x, y)| turned.iter().min_by(|a, b| dist(&a.1, x, y).total_cmp(&dist(&b.1, x, y))).is_some_and(|(p, _)| p.0.len() > 1)) + .collect(); + assert!(!points.is_empty(), "a click there would land in the table: {area:?}"); + for (x, y) in points { + assert_eq!(l.hit(0, x, y, StoryRef::Body).unwrap().path, Path::top(1), "a click at ({x}, {y})"); + } + // Inside the table a click still lands in a cell. + assert!(l.hit(0, cx, cy, StoryRef::Body).unwrap().path.0.len() > 1); +} From 37f28eedd0846523b46e7f91a47f18e7214293c1 Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:38 +0200 Subject: [PATCH 23/33] #2428: the table handles show while the caret is in the table As in Word, the caret's table shows its move handle past the top-left corner and its resize handle past the bottom-right one wherever the pointer is, not only while the pointer is over the table. table_mouse::caret_handles gives them per page; the app paints them. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/engine/src/cmd/table_mouse.rs | 27 ++++++++++++++++++++++- crates/engine/src/tests.rs | 17 ++++++++++++++ crates/ui-egui/src/table_mouse.rs | 9 ++++++++ crates/ui-egui/src/table_resize.rs | 33 ++++++++++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..29d90fa1e 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: the table handles show while the caret is in the table, #2428; 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 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) | | 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: the table handles show while the caret is in the table (#2428) | | 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/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 38ac1409d..6b26ad15c 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -199,6 +199,31 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, .max_by_key(|g| g.table.0.len()) } +/// The move and resize handles of table `table` of `story` on this page, as Word shows them +/// while the caret is in the table: the move handle where its first row is, the resize handle +/// where its last row is. +pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option, Option) { + let rows = doc.table(story, table).map_or(0, |t| t.rows.len()); + let (mut first, mut last, mut bounds) = (false, false, None::); + for it in l.pages.get(page).map_or(&[][..], |pg| pg.items.as_slice()) { + if let Placed::Cell { rect, table: t, story: st, row, .. } = it + && *st == story + && t == table + { + first |= *row == 0; + last |= *row + 1 == rows; + bounds = Some(bounds.map_or(*rect, |b| b.union(rect))); + } + } + let Some(b) = bounds else { return (None, None) }; + // Where `handle` and `ResizeTarget::handle` put them for the drawn table. + let resize = table_resize::HANDLE; + ( + first.then(|| Rect::new(b.x - HANDLE_GAP - HANDLE, b.y - HANDLE_GAP - HANDLE, HANDLE, HANDLE)), + last.then(|| Rect::new(b.right() + 1.0, b.bottom() + 1.0, resize, resize)), + ) +} + /// The insert button a target is, if it's one. pub fn insert_mark(t: &Target) -> Option { let g = &t.geom; @@ -425,7 +450,7 @@ fn insert_at(s: &mut Session, v: &Value) -> CmdResult { } /// Where table `table` of `story` is first drawn: its page and top-left corner. -fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> { +pub fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> { l.pages.iter().enumerate().find_map(|(pi, pg)| { let cells = pg.items.iter().filter_map(|it| match it { Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4031e230a..81e12260f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1972,6 +1972,23 @@ fn table_text_direction_cycles_and_turns_the_text() { } } +/// While the caret is in a table, Word shows its move handle past the top-left corner and its +/// resize handle past the bottom-right one (#2428); the app paints the ones this gives. +#[test] +fn the_caret_table_has_its_handles() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let l = s.layout(); + let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, s.sel.focus.story, &tp, 0); + let (m, z) = (m.expect("the move handle"), z.expect("the resize handle")); + let (_, x, y) = crate::cmd::table_mouse::corner(&l, s.sel.focus.story, &tp).unwrap(); + assert!(m.right() <= x && m.bottom() <= y, "past the top-left corner"); + assert!(z.x > m.right() && z.y > m.bottom(), "past the bottom-right one"); + let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, s.sel.focus.story, &tp, 1); + assert!(m.is_none() && z.is_none(), "not on a page without the table"); +} + /// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle /// the direction, one Undo step each, and the layout mirrors the table. #[test] diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index 4c5f8daf9..8933eccd1 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -160,6 +160,15 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay if !available(app) { return; } + // The caret's table shows its move handle wherever the pointer is, as in Word. + for (page, h, _) in crate::table_resize::caret_handles(app, painter, layout, rects) { + if let Some(h) = h + && let (Some(a), Some(b)) = + (to_screen(rects, layout, scale, page, h.x, h.y), to_screen(rects, layout, scale, page, h.right(), h.bottom())) + { + move_handle(painter, Rect::from_two_pos(a, b), t); + } + } let Some(p) = hover else { return }; let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) else { return }; let Some(sc) = page_scale(rects, layout, scale, page) else { return }; diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs index 69d8d7085..4556e1098 100644 --- a/crates/ui-egui/src/table_resize.rs +++ b/crates/ui-egui/src/table_resize.rs @@ -263,6 +263,16 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay if !available(app) { return; } + // The caret's table shows it wherever the pointer is, as in Word. + for (page, _, h) in caret_handles(app, painter, layout, rects) { + if let Some(h) = h + && let (Some(a), Some(b)) = (s(page, h.x, h.y), s(page, h.right(), h.bottom())) + { + let r = Rect::from_two_pos(a, b); + painter.rect_filled(r, 0.0, egui::Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + } + } // The handle shows while the pointer is over the table (or on the handle itself). let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) else { return }; let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) else { return }; @@ -276,6 +286,29 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay } } +/// The move and resize handles (page points) of the caret's table on the pages in view: Word +/// shows them while the caret is in a table, not only under the pointer. +pub(crate) fn caret_handles( + app: &WordApp, + painter: &egui::Painter, + layout: &DocLayout, + rects: &[Rect], +) -> Vec<(usize, Option, Option)> { + let focus = &app.session.sel.focus; + let Some((tp, _, _)) = focus.path.cell() else { return Vec::new() }; + let clip = painter.clip_rect(); + rects + .iter() + .enumerate() + .filter(|(_, r)| r.intersects(clip)) + .map(|(page, _)| { + let (m, z) = wordcraft_engine::cmd::table_mouse::caret_handles(layout, &app.session.doc, focus.story, &tp, page); + (page, m, z) + }) + .filter(|(_, m, z)| m.is_some() || z.is_some()) + .collect() +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d4948067e..b55697e91 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 (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: 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-20 · **Change:** trivial (the table handles show while the caret is in the table, #2428; 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: 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 @@ -38,7 +38,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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 | -| 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 | +| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 | | 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 (only zooms; a coast or notch still gliding stops, #1743), 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% | — | | 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), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | @@ -74,6 +74,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Tables on the page: the move and resize handles show while the caret is in the table, not only under the pointer (#2428) | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 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) | From a03deeeb26ce51a87b616d082aa58a6e2b94a9ba Mon Sep 17 00:00:00 2001 From: rabis <340973947+rabis7575@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:59:48 +0200 Subject: [PATCH 24/33] #2428: the caret table's handles show where it starts and ends, where the pointer handles are A repeated header row or a row running on to the next page no longer puts them on every page, and they come from the same geometry as the handles under the pointer, so they can be grabbed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table_mouse.rs | 37 ++++++++++++----------- crates/engine/src/tests.rs | 44 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 6b26ad15c..3cc23dd67 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -200,28 +200,27 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, } /// The move and resize handles of table `table` of `story` on this page, as Word shows them -/// while the caret is in the table: the move handle where its first row is, the resize handle -/// where its last row is. +/// while the caret is in the table: the move handle where it starts, the resize handle where it +/// ends. pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option, Option) { let rows = doc.table(story, table).map_or(0, |t| t.rows.len()); - let (mut first, mut last, mut bounds) = (false, false, None::); - for it in l.pages.get(page).map_or(&[][..], |pg| pg.items.as_slice()) { - if let Placed::Cell { rect, table: t, story: st, row, .. } = it - && *st == story - && t == table - { - first |= *row == 0; - last |= *row + 1 == rows; - bounds = Some(bounds.map_or(*rect, |b| b.union(rect))); - } + let has_row = |p: usize, r: usize| { + l.pages.get(p).is_some_and(|pg| { + pg.items.iter().any(|it| matches!(it, Placed::Cell { table: t, story: st, row, .. } if *st == story && t == table && *row == r)) + }) + }; + // A repeated header row, or a row running on to the next page, is drawn on more than one + // page: the handles go where the table starts and where it ends (its pages are consecutive). + let first = has_row(page, 0) && !page.checked_sub(1).is_some_and(|p| has_row(p, 0)); + let last = rows.checked_sub(1).is_some_and(|r| has_row(page, r) && !page.checked_add(1).is_some_and(|p| has_row(p, r))); + if !first && !last { + return (None, None); } - let Some(b) = bounds else { return (None, None) }; - // Where `handle` and `ResizeTarget::handle` put them for the drawn table. - let resize = table_resize::HANDLE; - ( - first.then(|| Rect::new(b.x - HANDLE_GAP - HANDLE, b.y - HANDLE_GAP - HANDLE, HANDLE, HANDLE)), - last.then(|| Rect::new(b.right() + 1.0, b.bottom() + 1.0, resize, resize)), - ) + // The table's geometry on this page that the pointer handles are drawn and grabbed from. + let Some(g) = table_resize::tables_on(l, doc, page).into_iter().find(|g| g.story == story && g.table == *table) else { + return (None, None); + }; + (first.then(|| handle(&g)), last.then(|| g.handle())) } /// The insert button a target is, if it's one. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 81e12260f..8ce2859fd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1985,10 +1985,54 @@ fn the_caret_table_has_its_handles() { let (_, x, y) = crate::cmd::table_mouse::corner(&l, s.sel.focus.story, &tp).unwrap(); assert!(m.right() <= x && m.bottom() <= y, "past the top-left corner"); assert!(z.x > m.right() && z.y > m.bottom(), "past the bottom-right one"); + // Exactly where the pointer handles are, so a click on them grabs them. + let mid = |r: wordcraft_geom::Rect| (r.x + r.w / 2.0, r.y + r.h / 2.0); + let (hx, hy) = mid(m); + let g = crate::cmd::table_mouse::handle_table(&l, &s.doc, s.sel.focus.story, 0, hx, hy, 4.0).expect("the move handle grabs"); + assert_eq!(crate::cmd::table_mouse::handle(&g), m); + let (hx, hy) = mid(z); + let g = crate::cmd::table_resize::table_under(&l, &s.doc, 0, hx, hy, 4.0).expect("the resize handle grabs"); + assert_eq!(g.handle(), z); let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, s.sel.focus.story, &tp, 1); assert!(m.is_none() && z.is_none(), "not on a page without the table"); } +/// A table over more than one page shows its move handle only where it starts and its resize +/// handle only where it ends, also when its header row repeats or its one row runs on (#2428). +#[test] +fn the_caret_table_shows_its_handles_where_it_starts_and_ends() { + for header in [true, false] { + let mut s = s(); + if header { + // 60 rows under a header row that repeats on the next page. + run(&mut s, "insert.table", json!({"rows": 60, "cols": 2})); + run(&mut s, "table.properties", json!({"headerRow": true})); + } else { + // One row whose cell runs on to the next page. + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + for i in 0..80 { + run(&mut s, "text.insert", json!({"text": format!("Line {i}")})); + run(&mut s, "text.newParagraph", json!({})); + } + } + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let story = s.sel.focus.story; + let l = s.layout(); + let holds = |p: usize, r: Option| { + l.pages[p] + .items + .iter() + .any(|it| matches!(it, wordcraft_layout::Placed::Cell { table, row, .. } if *table == tp && r.is_none_or(|r| *row == r))) + }; + assert!(l.pages.len() >= 2 && holds(1, Some(0)), "row 0 is drawn on page 2 too (header {header})"); + let end = (0..l.pages.len()).rev().find(|p| holds(*p, None)).unwrap(); + for p in 0..l.pages.len() { + let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, story, &tp, p); + assert_eq!((m.is_some(), z.is_some()), (p == 0, p == end), "page {p} (header {header})"); + } + } +} + /// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle /// the direction, one Undo step each, and the layout mirrors the table. #[test] From c2fa8763e30c3d179e91f9a0c4fd5acd65ab77b1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:14:01 +0200 Subject: [PATCH 25/33] Translate the spooled sheet count in print confirmations Fixes #3317 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 8 ++++++++ crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/printing.rs | 11 +++++++++++ 24 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1994f2a15..ea80bfd30 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2598,6 +2598,7 @@ Submitting print job… Изпращане на задание за печат… Print job submitted. Заданието за печат е изпратено. Print job submitted: {receipt} Заданието за печат е изпратено: {receipt} + Print job submitted: spooled {count} sheet(s) Заданието за печат е изпратено: подадени {count} листа към спулера Print failed: the printing worker stopped. Печатът е неуспешен: процесът на печат спря. Print settings Настройки за печат Submit print job Изпращане на задание за печат diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a7c506073..7c08d53d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2601,6 +2601,7 @@ preview Title Ukázka Submitting print job… Odesílání tiskové úlohy… Print job submitted. Tisková úloha odeslána. Print job submitted: {receipt} Tisková úloha odeslána: {receipt} + Print job submitted: spooled {count} sheet(s) Tisková úloha odeslána: do tiskové fronty předáno listů: {count} Print failed: the printing worker stopped. Tisk se nezdařil: proces tisku se zastavil. Print settings Nastavení tisku Submit print job Odeslat tiskovou úlohu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eeb70c65e..b1151f201 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2215,6 +2215,7 @@ Submitting print job… Druckauftrag wird gesendet… Print job submitted. Druckauftrag gesendet. Print job submitted: {receipt} Druckauftrag gesendet: {receipt} + Print job submitted: spooled {count} sheet(s) Druckauftrag gesendet: {count} Blatt an den Spooler übergeben Print failed: the printing worker stopped. Drucken fehlgeschlagen: Der Druckvorgang wurde angehalten. Print settings Druckeinstellungen Submit print job Druckauftrag senden diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19ef8fcf5..24820aca0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2582,6 +2582,7 @@ preview Title Título Submitting print job… Enviando trabajo de impresión… Print job submitted. Trabajo de impresión enviado. Print job submitted: {receipt} Trabajo de impresión enviado: {receipt} + Print job submitted: spooled {count} sheet(s) Trabajo de impresión enviado: {count} hoja(s) en cola de impresión Print failed: the printing worker stopped. La impresión falló: el proceso de impresión se detuvo. Print settings Configuración de impresión Submit print job Enviar trabajo de impresión diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0aa9e52c1..57ef6d8c7 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2250,6 +2250,7 @@ Submitting print job… Prinditöö saatmine… Print job submitted. Prinditöö saadetud. Print job submitted: {receipt} Prinditöö saadetud: {receipt} + Print job submitted: spooled {count} sheet(s) Printimistöö saadetud: prindijärjekorda lisati lehti: {count} Print failed: the printing worker stopped. Printimine nurjus: printimisprotsess peatus. Print settings Printimisseaded Submit print job Saada prinditöö diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44c9cb937..d6001ad47 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2223,6 +2223,7 @@ Submitting print job… Envoi du travail d'impression… Print job submitted. Travail d'impression envoyé. Print job submitted: {receipt} Travail d'impression envoyé : {receipt} + Print job submitted: spooled {count} sheet(s) Travail d’impression envoyé : {count} feuille(s) mise(s) en file d’attente Print failed: the printing worker stopped. L'impression a échoué : le processus d'impression s'est arrêté. Print settings Paramètres d'impression Submit print job Envoyer le travail d'impression diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 15bbbb54d..684f4f9be 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -1672,6 +1672,7 @@ template Letter Pismo Print failed: {error} Ispis nije uspio: {error} Print job submitted. Zadatak ispisa je poslan. Print job submitted: {receipt} Zadatak ispisa je poslan: {receipt} + Print job submitted: spooled {count} sheet(s) Zadatak ispisa je poslan: u red za ispis poslano listova: {count} Print the page color Ispiši boju stranice Print to PDF Ispiši u PDF Print\nLayout Izgled\nispisa diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33951fee5..051745bb0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2605,6 +2605,7 @@ Submitting print job… Nyomtatási feladat küldése… Print job submitted. Nyomtatási feladat elküldve. Print job submitted: {receipt} Nyomtatási feladat elküldve: {receipt} + Print job submitted: spooled {count} sheet(s) A nyomtatási feladat elküldve: {count} lap a nyomtatási sorba került Print failed: the printing worker stopped. A nyomtatás sikertelen: a nyomtatási folyamat leállt. Print settings Nyomtatási beállítások Submit print job Nyomtatási feladat küldése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3dd2b6d61..8988deb4f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2592,6 +2592,7 @@ preview Title Esempio Submitting print job… Invio del lavoro di stampa in corso… Print job submitted. Lavoro di stampa inviato. Print job submitted: {receipt} Lavoro di stampa inviato: {receipt} + Print job submitted: spooled {count} sheet(s) Lavoro di stampa inviato: {count} foglio/i messo/i in coda Print failed: the printing worker stopped. Stampa non riuscita: il processo di stampa si è interrotto. Print settings Impostazioni di stampa Submit print job Invia lavoro di stampa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d633eac51..d9638441e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2216,6 +2216,7 @@ Submitting print job… 印刷ジョブを送信しています… Print job submitted. 印刷ジョブを送信しました。 Print job submitted: {receipt} 印刷ジョブを送信しました:{receipt} + Print job submitted: spooled {count} sheet(s) 印刷ジョブを送信しました: {count} 枚をスプールしました Print failed: the printing worker stopped. 印刷に失敗しました:印刷ワーカーが停止しました。 Print settings 印刷設定 Submit print job 印刷ジョブの送信 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7e50812e0..ebda3942b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1097,6 +1097,7 @@ Submitting print job… 인쇄 작업 전송 중… Print job submitted. 인쇄 작업을 전송했습니다. Print job submitted: {receipt} 인쇄 작업을 전송했습니다: {receipt} + Print job submitted: spooled {count} sheet(s) 인쇄 작업을 보냈습니다: {count}장을 스풀했습니다 Print failed: the printing worker stopped. 인쇄 실패: 인쇄 작업자가 중지되었습니다. Couldn't open {path}: {error} {path}을(를) 열 수 없습니다: {error} Column Selection 열 선택 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ccbeb471f..d277b09ae 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2616,6 +2616,7 @@ preview Title Tytuł Submitting print job… Wysyłanie zadania drukowania… Print job submitted. Zadanie drukowania wysłane. Print job submitted: {receipt} Zadanie drukowania wysłane: {receipt} + Print job submitted: spooled {count} sheet(s) Zadanie drukowania wysłane: liczba arkuszy przekazanych do spoolera: {count} Print failed: the printing worker stopped. Drukowanie nie powiodło się: proces drukowania został zatrzymany. Print settings Ustawienia drukowania Submit print job Wyślij zadanie drukowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a1038e1a..2249b14d5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2228,6 +2228,7 @@ Submitting print job… Enviando trabalho de impressão… Print job submitted. Trabalho de impressão enviado. Print job submitted: {receipt} Trabalho de impressão enviado: {receipt} + Print job submitted: spooled {count} sheet(s) Trabalho de impressão enviado: {count} folha(s) na fila de impressão Print failed: the printing worker stopped. A impressão falhou: o processo de impressão parou. Print settings Configurações de impressão Submit print job Enviar trabalho de impressão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cd064b4ee..ba9e23845 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2607,6 +2607,7 @@ preview Title Пример Submitting print job… Отправка задания на печать… Print job submitted. Задание на печать отправлено. Print job submitted: {receipt} Задание на печать отправлено: {receipt} + Print job submitted: spooled {count} sheet(s) Задание печати отправлено: в очередь передано листов: {count} Print failed: the printing worker stopped. Печать не удалась: процесс печати остановлен. Print settings Параметры печати Submit print job Отправить задание на печать diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8e0a9136c..7a83ff926 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2226,6 +2226,7 @@ Submitting print job… Slanje zadatka za štampanje… Print job submitted. Zadatak za štampanje je poslat. Print job submitted: {receipt} Zadatak za štampanje je poslat: {receipt} + Print job submitted: spooled {count} sheet(s) Zadatak za štampu je poslat: u red za štampu poslato listova: {count} Print failed: the printing worker stopped. Štampanje nije uspelo: proces štampanja je zaustavljen. Print settings Podešavanja štampanja Submit print job Pošalji zadatak za štampanje diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c55fa2c7e..2c7b49ca7 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2230,6 +2230,7 @@ Submitting print job… Слање задатка за штампање… Print job submitted. Задатак за штампање је послат. Print job submitted: {receipt} Задатак за штампање је послат: {receipt} + Print job submitted: spooled {count} sheet(s) Задатак за штампу је послат: у ред за штампу послато листова: {count} Print failed: the printing worker stopped. Штампање није успело: процес штампања је заустављен. Print settings Подешавања штампања Submit print job Пошаљи задатак за штампање diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2098fc278..b6ea9b93d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2529,6 +2529,7 @@ preview Title Titel Submitting print job… Skickar utskriften… Print job submitted. Utskriften har skickats. Print job submitted: {receipt} Utskriften har skickats: {receipt} + Print job submitted: spooled {count} sheet(s) Utskriftsjobbet skickades: {count} ark lades i utskriftskön Print failed: the printing worker stopped. Utskriften misslyckades: utskriftsprocessen avbröts. Wait for the print job to finish submitting before closing. Vänta tills utskriften har skickats innan du stänger. Find “{text}” in the document Sök efter ”{text}” i dokumentet diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 42ff1c606..90f44b251 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1560,3 +1560,11 @@ fn bundled_interface_fonts_cover_vietnamese_letters() { } assert!(!lang("vi").uses_cjk()); } + +/// #3317: the spooler's English receipt is not spliced into a translated sentence. +#[test] +fn spooled_print_receipt_is_translated() { + let german = lang("de"); + let msg = crate::printing::feedback(&crate::printing::Feedback::Submitted("spooled 2 sheet(s)".into()), |s| tr(german, s).to_owned()); + assert_eq!(msg, "Druckauftrag gesendet: 2 Blatt an den Spooler übergeben"); +} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 50799c62d..82ff47d3e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2606,6 +2606,7 @@ preview Title Örnek Submitting print job… Yazdırma işi gönderiliyor… Print job submitted. Yazdırma işi gönderildi. Print job submitted: {receipt} Yazdırma işi gönderildi: {receipt} + Print job submitted: spooled {count} sheet(s) Yazdırma işi gönderildi: {count} sayfa kuyruğa alındı Print failed: the printing worker stopped. Yazdırma başarısız oldu: yazdırma işlemi durdu. Print settings Yazdırma ayarları Submit print job Yazdırma işini gönder diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f32758595..8fd7cb33c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2228,6 +2228,7 @@ Submitting print job… Надсилання завдання на друк… Print job submitted. Завдання на друк надіслано. Print job submitted: {receipt} Завдання на друк надіслано: {receipt} + Print job submitted: spooled {count} sheet(s) Завдання друку надіслано: у чергу передано аркушів: {count} Print failed: the printing worker stopped. Друк не вдався: процес друку зупинився. Print settings Параметри друку Submit print job Надіслати завдання на друк diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2bfe75ec..1e25e79b7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2552,6 +2552,7 @@ Submitting print job… Đang gửi lệnh in… Print job submitted. Đã gửi lệnh in. Print job submitted: {receipt} Đã gửi lệnh in: {receipt} + Print job submitted: spooled {count} sheet(s) Đã gửi lệnh in: đã đưa {count} tờ vào hàng đợi Print failed: the printing worker stopped. In thất bại: tiến trình in đã dừng. Print settings Cài đặt in Submit print job Gửi lệnh in diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d733ca8f0..686846951 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2228,6 +2228,7 @@ Submitting print job… 正在提交打印作业… Print job submitted. 打印作业已提交。 Print job submitted: {receipt} 打印作业已提交:{receipt} + Print job submitted: spooled {count} sheet(s) 打印作业已发送:已将 {count} 张纸送入后台处理 Print failed: the printing worker stopped. 打印失败:打印工作线程已停止。 Print settings 打印设置 Submit print job 提交打印作业 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8bc708ea..c25681c68 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2217,6 +2217,7 @@ Submitting print job… 正在提交列印工作… Print job submitted. 列印工作已提交。 Print job submitted: {receipt} 列印工作已提交:{receipt} + Print job submitted: spooled {count} sheet(s) 列印工作已傳送:已將 {count} 張紙送入背景處理 Print failed: the printing worker stopped. 列印失敗:列印工作執行緒已停止。 Print settings 列印設定 Submit print job 提交列印工作 diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 805eabf78..22f774cfb 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -101,6 +101,13 @@ pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option< })) } +const SPOOLED: &str = "Print job submitted: spooled {count} sheet(s)"; + +/// The sheet count of the Windows backend's English receipt `spooled N sheet(s)`. +fn spooled_sheets(receipt: &str) -> Option { + receipt.trim().strip_prefix("spooled ")?.strip_suffix(" sheet(s)")?.parse().ok() +} + pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { match message { Feedback::FindingPrinters => translate("Finding printers…"), @@ -108,6 +115,10 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin Feedback::DiscoveryFailed => translate("Printer discovery failed."), Feedback::Submitting => translate("Submitting print job…"), Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), + Feedback::Submitted(receipt) if spooled_sheets(receipt).is_some_and(|_| translate(SPOOLED) != SPOOLED) => { + let count = spooled_sheets(receipt).unwrap_or_default(); + translate(SPOOLED).replace("{count}", &count.to_string()) + } Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), From 67d21b0a68f9b37d2e761d9a5587a5135caff120 Mon Sep 17 00:00:00 2001 From: Nghiathan13 Date: Wed, 21 Oct 2026 18:21:49 +0700 Subject: [PATCH 26/33] #3415: banded rows shade the same rows in Word and WordCraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word reads a table style without w:tblStyleRowBandSize / w:tblStyleColBandSize as bands of zero and leaves its banded rows and columns unshaded (MS-OE376 §2.7.5.7, MS-OI29500 §17.7.6.5); ECMA-376 says one, which is what we did. Our writer only wrote a size a style set, and our built-in styles set none, so a table in Grid Table 4 saved by WordCraft had shaded rows here and none in Word. - An unset or zero band size now means no bands, as in Word. PartSet::of_cell takes row and column band sizes; column bands get a size of their own (col_band_size). - Our banded built-in styles and new table styles set both sizes to one, and the writer writes them (and zero), so Word bands them too. - The reader reads both sizes, zero included. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/styles.rs | 51 +++++++++++++++++++---- crates/docx/src/read/mod.rs | 3 +- crates/docx/src/write/mod.rs | 7 +++- crates/docx/tests/fixtures.rs | 46 ++++++++++++++++++++ crates/engine/src/cmd/table_style.rs | 8 ++++ crates/engine/src/tests.rs | 1 + crates/layout/src/table.rs | 5 ++- crates/layout/src/tests.rs | 31 ++++++++++++++ crates/ui-egui/src/dialogs_table_style.rs | 2 +- docs/file-format-parity.md | 5 ++- 11 files changed, 145 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09f..3637281bf 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 (progress log: banded rows shade as in Word, #3415; 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-21: Table styles shade the same banded rows in Word and WordCraft: our banded styles (Grid Table 4…) and new table styles write their band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`), which Word otherwise reads as zero and leaves unbanded, and a file's style without them is unbanded here too (#3415). - 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-21 | trivial | Progress log: table style band sizes, banded rows as in Word (#3415) | | 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/doc/src/styles.rs b/crates/doc/src/styles.rs index 59416ef9f..18bcb42fd 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -73,8 +73,13 @@ pub struct TableStyleParts { pub band_chr: CharProps, /// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`). pub band_borders: Option, - /// Rows per band (`w:tblStyleRowBandSize`); one when unset. + /// Rows per band (`w:tblStyleRowBandSize`). Unset (in the whole based-on chain) or zero, the + /// row bands are off: Word reads a missing size as zero, where ECMA-376 says one (MS-OE376 + /// §2.7.5.7, #3415). Our own banded styles set one. pub band_size: Option, + /// Columns per band (`w:tblStyleColBandSize`); unset or zero, the column bands are off, as + /// in Word (MS-OI29500 §17.7.6.5). + pub col_band_size: Option, pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, @@ -179,17 +184,24 @@ pub struct PartSet(u16); impl PartSet { /// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`, - /// in a table with options `look` and row bands of `band_size` rows. - pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet { + /// in a table with options `look`, row bands of `row_size` rows and column bands of + /// `col_size` columns (a size of zero turns those bands off, as in Word). + pub fn of_cell( + look: &TableLook, + (row_size, col_size): (u32, u32), + (ri, nrows): (usize, usize), + (g, span, ncols): (usize, usize, usize), + ) -> PartSet { let header = look.header_row && ri == 0; let total = look.total_row && ri + 1 == nrows && nrows > 1; let first_col = look.first_column && g == 0; let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1; - let size = band_size.clamp(1, 1000) as usize; - let row_band = look.banded_rows && !header; - let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2); - let col_band = look.banded_columns && !first_col; - let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); + let rows = row_size.min(1000) as usize; + let cols = col_size.min(1000) as usize; + let row_band = look.banded_rows && !header && rows > 0; + let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / rows.max(1)).is_multiple_of(2); + let col_band = look.banded_columns && !first_col && cols > 0; + let odd_col = (g.saturating_sub(usize::from(look.first_column)) / cols.max(1)).is_multiple_of(2); let mut bits = TablePart::WholeTable.bit(); for (on, p) in [ (col_band && odd_col, TablePart::Band1Vert), @@ -270,6 +282,7 @@ impl TableStyleParts { self.header_borders = merge(self.header_borders, patch.header_borders); self.band_borders = merge(self.band_borders, patch.band_borders); self.band_size = patch.band_size.or(self.band_size); + self.col_band_size = patch.col_band_size.or(self.col_band_size); self.band_chr.overlay(&patch.band_chr); self.fill = patch.fill.or(self.fill); self.cell_margins = patch.cell_margins.or(self.cell_margins); @@ -846,6 +859,7 @@ fn p_style() -> Style { /// A few table styles of our own (grid, plain, light/medium accents). fn table_styles() -> Vec