From 6526b134befbf6933fd87a63bfea37a7828f29c4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 21:29:13 +0200 Subject: [PATCH] Document grid: lines per page and characters per line (#391) (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model: `SectionProps::doc_grid` (`w:docGrid` type, line pitch, charSpace) and the paragraph `w:snapToGrid` flag; DOCX read and write (ECMA-376 §17.6.5). - Layout: with a line grid, lines of snapping paragraphs take whole line pitches with their text centred; with a character grid, East Asian characters take whole cells of the character pitch (all text with snapToChars), Latin text stays proportional. Hostile pitches and charSpace are clamped. Headers and footers don't snap. - Command `layout.documentGrid` (type, linesPerPage, charsPerLine, linePitch, charSpace), one undo step, section scope; catalog row under Layout › Page Setup. - Page Setup dialog: Document Grid section; View › Gridlines draws the document grid of a section that has one. UI strings in every catalog (incl. et, sr, sr-latn). - Tests: DOCX round trip, line pitch and lines per page, character pitch placement, command with clamping and undo. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 6 +- crates/doc/src/props.rs | 6 +- crates/doc/src/resolve.rs | 3 + crates/doc/src/section.rs | 111 ++++++++++++++++++++++++++++ crates/docx/src/read/props.rs | 19 ++++- crates/docx/src/write/props.rs | 1 + crates/docx/src/write/story.rs | 12 +++ crates/docx/tests/roundtrip.rs | 7 +- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/page.rs | 63 +++++++++++++++- crates/engine/src/tests.rs | 23 ++++++ crates/layout/src/lib.rs | 13 ++++ crates/layout/src/para.rs | 107 ++++++++++++++++++++++++--- crates/layout/src/table.rs | 1 + crates/layout/src/tests_grid.rs | 93 +++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 13 +++- crates/ui-egui/src/dialogs.rs | 42 ++++++++++- crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/et.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 7 ++ crates/ui-egui/src/i18n/sr.tsv | 7 ++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ docs/file-format-parity.md | 4 +- docs/gaps.md | 3 +- docs/layout-parity.md | 3 +- docs/parity-checklist.md | 4 +- docs/target-app-parity.md | 7 +- 33 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 crates/layout/src/tests_grid.rs diff --git a/ROADMAP.md b/ROADMAP.md index ed6e666..ea18713 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **428 / 450 (95.1%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -87,7 +87,7 @@ the two partial ones (real-world .docx, printing) complete, roughly. | Right-to-left and complex scripts | 50% | 15–25 | | Proofing (spelling, grammar, thesaurus, languages) | 35% | 25–45 | | Accessibility | 35% | 15–25 | -| East Asian typography | 15% | 20–30 | +| East Asian typography | 25% | 15–25 | | Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 | | Agent control (CLI, MCP, control channel, macros) | beyond Word | — | @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it | | 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons | | 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) | | 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) | @@ -161,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: document grid (#391) | | 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) | | 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 | | 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 6d01d28..c8cdcff 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -709,13 +709,17 @@ pub struct ParaProps { /// Tracked formatting change (`w:pPrChange`): the paragraph properties before it. #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// `w:snapToGrid`: lines snap to the section's document grid. Unset = on. + #[serde(skip_serializing_if = "Option::is_none")] + pub snap_to_grid: Option, } impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing, contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, - suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); + suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, + snap_to_grid); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 430c200..1ec1c21 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -152,6 +152,8 @@ pub struct ResolvedPara { pub top_line_punct: bool, pub auto_space_de: bool, pub auto_space_dn: bool, + /// Lines snap to the document grid, when the section has one. + pub snap_to_grid: bool, } const MAX_INDENT: f32 = 1584.0; // 22" @@ -203,6 +205,7 @@ impl ResolvedPara { top_line_punct: p.top_line_punct.unwrap_or(false), auto_space_de: p.auto_space_de.unwrap_or(true), auto_space_dn: p.auto_space_dn.unwrap_or(true), + snap_to_grid: p.snap_to_grid.unwrap_or(true), } } } diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index c27bbe5..1c546c1 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -356,6 +356,79 @@ pub enum LineNumberRestart { Continuous, } +/// Document grid kind (`w:docGrid/@w:type`, ECMA-376 §17.6.5, §17.18.14). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DocGridType { + /// No grid. + #[default] + Default, + /// Line grid only: line heights snap to the line pitch. + Lines, + /// Line and character grid: East Asian characters advance by the character pitch. + LinesAndChars, + /// Line grid, and all text snaps to the character grid. + SnapToChars, +} + +impl DocGridType { + pub fn ooxml(self) -> &'static str { + match self { + DocGridType::Default => "default", + DocGridType::Lines => "lines", + DocGridType::LinesAndChars => "linesAndChars", + DocGridType::SnapToChars => "snapToChars", + } + } + pub fn from_ooxml(s: &str) -> Option { + [DocGridType::Default, DocGridType::Lines, DocGridType::LinesAndChars, DocGridType::SnapToChars].into_iter().find(|t| t.ooxml() == s) + } +} + +/// Smallest and largest line or character pitch the layout uses, points. +pub const MIN_GRID_PITCH: f32 = 1.0; +pub const MAX_GRID_PITCH: f32 = 1584.0; +/// `w:charSpace` bound (4096ths of a point): ±1584 pt. +pub const MAX_CHAR_SPACE: i32 = 1584 * 4096; + +/// The document grid (`w:docGrid`): lines per page and characters per line, as pitches. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DocGrid { + #[serde(rename = "type")] + pub kind: DocGridType, + /// Distance between grid lines, points (`w:linePitch`, twips in the file). + pub line_pitch: f32, + /// Added to the default font size to give the character pitch, 4096ths of a point + /// (`w:charSpace`). + pub char_space: i32, +} + +impl Default for DocGrid { + /// Word's grid for a new document: no grid, an 18 pt pitch kept for when one is turned on. + fn default() -> Self { + DocGrid { kind: DocGridType::Default, line_pitch: 18.0, char_space: 0 } + } +} + +impl DocGrid { + /// The line pitch, clamped to something layout can use. + pub fn line_pitch(&self) -> f32 { + if self.line_pitch.is_finite() { self.line_pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 18.0 } + } + /// The character pitch for a document whose default font is `font_size` points. + pub fn char_pitch(&self, font_size: f32) -> f32 { + let size = if font_size.is_finite() { font_size.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 10.5 }; + (size + self.char_space.clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE) as f32 / 4096.0).clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) + } + /// The `w:charSpace` that gives `pitch` with the default font at `font_size` points. + pub fn char_space_for(pitch: f32, font_size: f32) -> i32 { + let pitch = if pitch.is_finite() { pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { font_size }; + let size = if font_size.is_finite() { font_size.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 10.5 }; + (((pitch - size) * 4096.0).round() as i32).clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE) + } +} + /// Page setup and section-level settings. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -389,6 +462,8 @@ pub struct SectionProps { /// Tracked change of the section's properties (`w:sectPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// Document grid (`w:docGrid`); `None` when the file has none (no grid). + pub doc_grid: Option, /// The section's own footnote and endnote options (unset: the document's). #[serde(skip_serializing_if = "NoteProps::is_empty")] pub footnote_pr: NoteProps, @@ -422,6 +497,7 @@ impl Default for SectionProps { page_borders: None, rtl: false, fmt_change: None, + doc_grid: None, footnote_pr: NoteProps::default(), endnote_pr: NoteProps::default(), } @@ -456,6 +532,25 @@ impl SectionProps { self.margin_top = r; } } + /// The line grid's pitch, points, when the section has a line grid (any grid but "no grid"). + pub fn grid_line_pitch(&self) -> Option { + self.doc_grid.filter(|g| g.kind != DocGridType::Default).map(|g| g.line_pitch()) + } + /// The character grid's pitch, points, when the section has one (`linesAndChars`, + /// `snapToChars`); `font_size` is the default font size (Normal style). + pub fn grid_char_pitch(&self, font_size: f32) -> Option { + self.doc_grid.filter(|g| matches!(g.kind, DocGridType::LinesAndChars | DocGridType::SnapToChars)).map(|g| g.char_pitch(font_size)) + } + /// Lines per page the line pitch gives (at least 1). + pub fn grid_lines_per_page(&self) -> u32 { + let pitch = self.doc_grid.unwrap_or_default().line_pitch(); + ((self.text_height() / pitch + 0.001).floor() as u32).max(1) + } + /// Characters per line the character pitch gives (at least 1). + pub fn grid_chars_per_line(&self, font_size: f32) -> u32 { + let pitch = self.doc_grid.unwrap_or_default().char_pitch(font_size); + ((self.text_width() / pitch + 0.001).floor() as u32).max(1) + } /// Column (x offset from the text area's left, width) for each column, in reading order: /// in a right-to-left section (`rtl`, `w:bidi`) the first column stands at the right edge /// and the columns (with the space after each) run leftwards. @@ -513,6 +608,22 @@ mod tests { assert_eq!((s.page_w, s.page_h), (612.0, 792.0)); } + #[test] + fn grid_pitches_are_clamped() { + let mut s = SectionProps::default(); + assert_eq!(s.grid_line_pitch(), None); + s.doc_grid = Some(DocGrid { kind: DocGridType::Lines, line_pitch: 0.0, char_space: i32::MIN }); + assert_eq!(s.grid_line_pitch(), Some(MIN_GRID_PITCH)); + assert_eq!(s.grid_char_pitch(10.5), None); + s.doc_grid = Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: f32::NAN, char_space: i32::MIN }); + assert_eq!(s.grid_line_pitch(), Some(18.0)); + assert_eq!(s.grid_char_pitch(10.5), Some(MIN_GRID_PITCH)); + // 648 pt of text height at an 18 pt pitch: 36 lines; 468 pt / 13 pt = 36 characters. + s.doc_grid = Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: 18.0, char_space: DocGrid::char_space_for(13.0, 10.5) }); + assert_eq!(s.grid_lines_per_page(), 36); + assert_eq!(s.grid_chars_per_line(10.5), 36); + } + #[test] fn columns_split() { let s = SectionProps { columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() }; diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 8af6621..d423107 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -6,7 +6,10 @@ use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; -use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NotePos, NoteProps, NoteRestart, NumFormat, SectionProps, SectionStart}; +use wordcraft_doc::section::{ + Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, + SectionProps, SectionStart, +}; use crate::units::{int, measure, on_off, tw, u32_of}; use crate::xml::El; @@ -148,6 +151,7 @@ impl PropCtx { "w:topLinePunct" => p.top_line_punct = Some(on_off(k)), "w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)), "w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)), + "w:snapToGrid" => p.snap_to_grid = Some(on_off(k)), "w:framePr" => { if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) { p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8); @@ -520,6 +524,19 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { } } "w:pgBorders" => s.page_borders = Some(borders(k)), + "w:docGrid" => { + let mut g = DocGrid::default(); + if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) { + g.kind = t; + } + if let Some(p) = tw(k, "w:linePitch").filter(|v| v.is_finite() && *v > 0.0) { + g.line_pitch = p.min(MAX_GRID_PITCH); + } + if let Some(c) = k.attr("w:charSpace").and_then(int) { + g.char_space = c.clamp(-(MAX_CHAR_SPACE as i64), MAX_CHAR_SPACE as i64) as i32; + } + s.doc_grid = Some(g); + } _ => {} } } diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index cea1412..5a90ea9 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -257,6 +257,7 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:autoSpaceDE", p.auto_space_de); toggle(w, "w:autoSpaceDN", p.auto_space_dn); toggle(w, "w:bidi", p.bidi); + toggle(w, "w:snapToGrid", p.snap_to_grid); if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() { let before = p.space_before.map(|v| twips(v.max(0.0))); let after = p.space_after.map(|v| twips(v.max(0.0))); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 2192d62..9037658 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1176,6 +1176,18 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { if s.rtl { w.empty("w:bidi", &[]); } + if let Some(g) = &s.doc_grid { + let mut a = vec![("w:linePitch", twips(g.line_pitch()))]; + if g.kind != wordcraft_doc::section::DocGridType::Default { + a.insert(0, ("w:type", g.kind.ooxml().to_string())); + } + if g.char_space != 0 { + let cs = g.char_space.clamp(-wordcraft_doc::section::MAX_CHAR_SPACE, wordcraft_doc::section::MAX_CHAR_SPACE); + a.push(("w:charSpace", n(cs as i64))); + } + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.empty("w:docGrid", &refs); + } } } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 435ea54..612fbdb 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -8,7 +8,7 @@ use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; -use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; +use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; use wordcraft_doc::table::{Cell, Table}; use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block}; @@ -145,6 +145,7 @@ fn every_para_prop_round_trips() { auto_space_dn: Some(true), num_change: None, fmt_change: None, + snap_to_grid: Some(false), }; let variants = [ ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() }, @@ -293,12 +294,14 @@ fn sections_headers_footers_round_trip() { page_borders: Some(Borders::box_(Border::single(1.0))), line_numbers: Some(LineNumbering { count_by: 5, start: 1, distance: 18.0, restart: LineNumberRestart::Section }), gutter: 18.0, + // #391: a line and character grid (Word writes `w:docGrid` even without a grid). + doc_grid: Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: 17.5, char_space: -1843 }), ..Default::default() }; s1.headers.default = Some(h1); s1.headers.first = Some(hf); s1.footers.default = Some(f1); - let mut s2 = SectionProps { start: SectionStart::OddPage, rtl: true, ..Default::default() }; + let mut s2 = SectionProps { start: SectionStart::OddPage, rtl: true, doc_grid: Some(DocGrid::default()), ..Default::default() }; s2.set_landscape(true); s2.columns = Columns { count: 2, space: 24.0, separator: true, widths: Vec::new() }; s2.footers.default = Some(f2); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 5fb2ad7..196b650 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -217,6 +217,7 @@ Layout|Page Setup|Line Numbers|layout.lineNumbers Layout|Page Setup|Hyphenation|layout.hyphenation Layout|Page Setup|Page Setup Dialog|layout.pageSetup Layout|Page Setup|Vertical Alignment|layout.verticalAlign +Layout|Page Setup|Document Grid|layout.documentGrid Layout|Page Setup|Section Direction|layout.sectionRtl Layout|Paragraph|Indent|para.indents Layout|Paragraph|Spacing|para.spacing diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 7601650..f7c3388 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,7 +1,9 @@ //! Layout tab: page setup (margins, orientation, size, columns), breaks, line numbers, hyphenation. use serde_json::{Value, json}; -use wordcraft_doc::section::{Columns, LineNumbering, SectionProps, SectionStart}; +use wordcraft_doc::section::{ + Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MIN_GRID_PITCH, SectionProps, SectionStart, +}; use wordcraft_doc::{Block, Pos}; use super::sel_result; @@ -86,6 +88,9 @@ pub fn specs() -> Vec { }) }) .params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#), + CommandSpec::new("layout.documentGrid", "Document Grid", "Layout › Page Setup › Document Grid", document_grid).params( + r#"{"type"?: "none|lines|linesAndChars|snapToChars", "linesPerPage"?: n (sets the line pitch from the text height), "charsPerLine"?: n (sets the character pitch from the text width), "linePitch"?: pt, "charSpace"?: n (4096ths of a point added to the default font size: the character pitch)} — left out stays as it is; returns the grid with its lines per page and characters per line"#, + ), CommandSpec::new("layout.section", "Section Properties", "Layout › Page Setup", |s, _| { Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) }) @@ -116,6 +121,62 @@ pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdRe Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) } +/// The default font size (Normal style), which the character pitch is based on. +fn grid_font_size(s: &Session) -> f32 { + let size = s.doc.styles.resolve_char(Some("Normal"), &wordcraft_doc::CharProps::default()).size; + if size.is_finite() { size.clamp(1.0, 1584.0) } else { 10.5 } +} + +fn grid_result(x: &SectionProps, font_size: f32) -> Value { + let g = x.doc_grid.unwrap_or_default(); + json!({ + "type": if g.kind == DocGridType::Default { "none" } else { g.kind.ooxml() }, + "linePitch": g.line_pitch(), + "charSpace": g.char_space, + "charPitch": g.char_pitch(font_size), + "linesPerPage": x.grid_lines_per_page(), + "charsPerLine": x.grid_chars_per_line(font_size), + }) +} + +/// Layout › Page Setup › Document Grid: the grid type, lines per page and characters per line. +fn document_grid(s: &mut Session, v: &Value) -> CmdResult { + let kind = match p::str(v, "type") { + None => None, + Some("none" | "default") => Some(DocGridType::Default), + Some(t) => Some(DocGridType::from_ooxml(t).ok_or_else(|| CmdError::Params(format!("unknown grid type `{t}`")))?), + }; + let lines = p::u64(v, "linesPerPage").map(|n| n.clamp(1, 1000) as f32); + let chars = p::u64(v, "charsPerLine").map(|n| n.clamp(1, 1000) as f32); + let line_pitch = p::f32(v, "linePitch").map(|x| x.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH)); + let char_space = + v.get("charSpace").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-(MAX_CHAR_SPACE as f64), MAX_CHAR_SPACE as f64) as i32); + let size = grid_font_size(s); + if kind.is_none() && lines.is_none() && chars.is_none() && line_pitch.is_none() && char_space.is_none() { + return Ok(grid_result(§(s), size)); + } + with_sect(s, |x| { + let mut g = x.doc_grid.unwrap_or_default(); + if let Some(k) = kind { + g.kind = k; + } + if let Some(n) = lines { + g.line_pitch = (x.text_height() / n).clamp(MIN_GRID_PITCH, MAX_GRID_PITCH); + } + if let Some(pt) = line_pitch { + g.line_pitch = pt; + } + if let Some(n) = chars { + g.char_space = DocGrid::char_space_for(x.text_width() / n, size); + } + if let Some(cs) = char_space { + g.char_space = cs; + } + x.doc_grid = Some(g); + })?; + Ok(grid_result(§(s), size)) +} + fn margins(s: &mut Session, v: &Value) -> CmdResult { let preset = p::str(v, "preset"); let (t, b, l, r) = match preset { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 18c2957..829504e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2845,6 +2845,29 @@ fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() { assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty()))); } +#[test] +fn document_grid_sets_lines_and_characters_in_one_undo_step() { + // #391: Layout › Page Setup › Document Grid. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "日本語"})); + let undo = s.undo_labels().len(); + let r = run(&mut s, "layout.documentGrid", json!({"type": "linesAndChars", "linesPerPage": 40, "charsPerLine": 30})); + assert_eq!(r["type"], "linesAndChars"); + assert_eq!(r["linesPerPage"], 40); + assert_eq!(r["charsPerLine"], 30); + let g = crate::cmd::page::sect(&s).doc_grid.unwrap(); + assert!((g.line_pitch - 648.0 / 40.0).abs() < 1e-3, "{g:?}"); + assert_eq!(s.undo_labels().len(), undo + 1); + // Hostile values are clamped, not trusted. + let r = run(&mut s, "layout.documentGrid", json!({"linePitch": -5, "charSpace": 1e15})); + assert_eq!(r["linePitch"], 1.0); + assert!(r["charPitch"].as_f64().unwrap() <= 1584.0); + assert!(s.run("layout.documentGrid", &json!({"type": "diagonal"})).is_err()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(crate::cmd::page::sect(&s).doc_grid, None); +} + #[test] fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours() { use wordcraft_doc::para::InlineObject; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 44dd8de..2de0290 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -313,6 +313,7 @@ struct Key { notes: u64, excl: u64, eq: u32, + grid: (u32, u32, u32, bool), } /// Memoised paragraph layouts. @@ -387,6 +388,8 @@ struct Ctx<'a> { boxes: wordcraft_doc::BoxBudget, /// Linked text boxes (Create Link). chains: textbox::Chains, + /// The current section's document grid (body text only: none for headers and footers). + grid: para::Grid, } impl Ctx<'_> { @@ -432,6 +435,7 @@ impl Ctx<'_> { proofing: false, exclusions: &[], eq_number: 0, + grid: para::Grid::default(), }; let pl = Arc::new(para::layout_para(&p, &env)); self.numbers.insert(n, pl.clone()); @@ -476,6 +480,7 @@ impl Ctx<'_> { notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 }, excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) }, eq: if eq_here > 0 { eq_number } else { 0 }, + grid: self.grid.key(), }; self.cache.used.insert(key.clone()); if let Some(pl) = self.cache.paras.get(&key) { @@ -495,6 +500,7 @@ impl Ctx<'_> { proofing: self.opts.proofing, exclusions, eq_number, + grid: self.grid, }; let pl = Arc::new(para::layout_para(p, &env)); self.cache.paras.insert(key, pl.clone()); @@ -1414,6 +1420,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no eq_count: 0, boxes: wordcraft_doc::BoxBudget::default(), chains: Default::default(), + grid: para::Grid::default(), }; let sections = doc.sections(); let web = opts.view != ViewMode::Print; @@ -1483,6 +1490,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no let sect: &SectionProps = if web { sect_ref } else { sect }; pb.sect = sect; pb.sect_idx = si; + ctx.grid = para::Grid::default(); let (foot, end_opts) = (doc.note_options(sect, false), doc.note_options(sect, true)); pb.sect_beneath = foot.pos == wordcraft_doc::section::NotePos::BeneathText; if !web && !doc.footnote_separators.is_empty() { @@ -1490,6 +1498,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no } let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, sect.headers.default) }; let first_top = if web || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, sect.headers.first) }; + ctx.grid = para::Grid::of(doc, sect); let restart = sect.page_num_start; if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) { pb.line_no = 0; @@ -1582,6 +1591,7 @@ fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, no .fold(0.0f32, f32::max); p.h = bottom + 36.0; } + ctx.grid = para::Grid::default(); if !web { headers_footers(&mut ctx, &mut pages, §ions); } @@ -2373,6 +2383,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { proofing: false, exclusions: &[], eq_number: ctx.eq_count, + grid: ctx.grid, }; let _ = rp; let pl = para::layout_para(p, &env); @@ -2628,3 +2639,5 @@ mod tests; mod tests_bidi; #[cfg(test)] mod tests_textbox; +#[cfg(test)] +mod tests_grid; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 994d98e..f2a8a04 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -322,6 +322,79 @@ pub struct ParaEnv<'a> { pub exclusions: &'a [Exclusion], /// Automatic equation numbers used before this paragraph. pub eq_number: u32, + /// The section's document grid (`Grid::default()`: none). + pub grid: Grid, +} + +/// The section's document grid as layout applies it (`w:docGrid`, ECMA-376 §17.6.5). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Grid { + /// Line pitch, points: line heights snap to multiples of it. + pub line: Option, + /// Character pitch, points: East Asian characters advance by it. + pub chars: Option, + /// The default font size the character pitch is based on, points. + pub font_size: f32, + /// `snapToChars`: all text snaps to the character grid, not just East Asian characters. + pub snap_all: bool, +} + +impl Grid { + /// The grid of section `s` of `doc` (its default font size from the Normal style). + pub fn of(doc: &Document, s: &wordcraft_doc::SectionProps) -> Grid { + let Some(line) = s.grid_line_pitch() else { return Grid::default() }; + let size = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size; + let font_size = if size.is_finite() { size.clamp(1.0, 1584.0) } else { 10.5 }; + let chars = s.grid_char_pitch(font_size); + let snap_all = s.doc_grid.is_some_and(|g| g.kind == wordcraft_doc::section::DocGridType::SnapToChars); + Grid { line: Some(line), chars, font_size, snap_all } + } + /// A cache key for paragraph layouts. + pub fn key(&self) -> (u32, u32, u32, bool) { + (self.line.map_or(0, f32::to_bits), self.chars.map_or(0, f32::to_bits), self.font_size.to_bits(), self.snap_all) + } + /// `h` rounded up to whole line pitches (unchanged without a line grid). + pub fn snap_height(&self, h: f32) -> f32 { + match self.line { + Some(p) if h.is_finite() => ((h / p - 0.001).ceil().clamp(1.0, 10_000.0) * p).min(1e6), + _ => h, + } + } +} + +/// East Asian characters the character grid places on its pitch: CJK ideographs, kana, Hangul, +/// CJK punctuation and full-width forms. +pub fn is_east_asian(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF | 0x2E80..=0x2FDF | 0x2FF0..=0x303F | 0x3040..=0x30FF | 0x3100..=0x312F | 0x3130..=0x318F + | 0x3190..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF + | 0xFE30..=0xFE4F | 0xFF01..=0xFF60 | 0xFFE0..=0xFFE6 | 0x20000..=0x3FFFF) +} + +/// Character grid: East Asian characters (all text with `snapToChars`) take whole cells of the +/// pitch, the glyph centred in them. A character at the default size takes one cell, a larger one +/// as many as its size needs; Latin text stays proportional unless everything snaps. +fn snap_chars(b: &mut Builder, text: &str, grid: &Grid) { + let Some(pitch) = grid.chars else { return }; + let base = grid.font_size.max(1.0); + for c in b.clusters.iter_mut() { + if !matches!(c.kind, ClKind::Text | ClKind::Space) || c.adv <= 0.0 { + continue; + } + let Some(ch) = text.get(c.start..).and_then(|t| t.chars().next()) else { continue }; + let east_asian = is_east_asian(ch); + if !(east_asian || (grid.snap_all && c.kind == ClKind::Text)) { + continue; + } + let size = b.styles.get(c.style as usize).map_or(base, |st| st.size); + let cells = if east_asian { (size / base).round() } else { (c.adv / pitch - 0.01).ceil() }.clamp(1.0, 64.0); + let adv = cells * pitch; + let shift = (adv - c.adv) / 2.0; + for g in b.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() { + g.dx += shift; + } + c.adv = adv; + } } /// An area text wraps around. @@ -1073,6 +1146,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { b.shape(s, range.start + seg, &rc, None); } } + if rp.snap_to_grid { + snap_chars(&mut b, &p.text, &env.grid); + } // Break opportunities and hyphenation points come from the text as laid out: hidden text that is // not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there // is no place to break, and words are hyphenated as they read. @@ -1221,6 +1297,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio // the list label and alignment are measured from the right. Lines are mirrored back (and // bidirectional text reordered) once broken; floating objects are mirrored in. let rtl_para = rp.bidi; + // The document grid's line pitch, for paragraphs that snap to it. + let grid = Some(env.grid).filter(|g| g.line.is_some() && rp.snap_to_grid); + let grid = grid.as_ref(); let bidi = rtl_para || !pl.bidi_levels.is_empty(); let mirrored: Vec; let exclusions: &[Exclusion] = if rtl_para { @@ -1560,12 +1639,20 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio // no rule tried so far (none, only the text's descent) matched both; it needs Word probes. let natural = asc + desc; let pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 }; - let height = match rp.line_spacing { - LineSpacing::Multiple(m) => (natural * m).max(pictures), - LineSpacing::AtLeast(v) => natural.max(pictures).max(v), - LineSpacing::Exactly(v) => v, + // Document grid: the line takes whole line pitches, its text centred in them. + let (height, centred) = match (rp.line_spacing, grid) { + (LineSpacing::Multiple(m), Some(g)) => ((g.snap_height(natural) * m).max(g.snap_height(pictures)), true), + (LineSpacing::AtLeast(v), Some(g)) => (g.snap_height(natural.max(pictures)).max(v), true), + (LineSpacing::Multiple(m), None) => ((natural * m).max(pictures), false), + (LineSpacing::AtLeast(v), None) => (natural.max(pictures).max(v), false), + (LineSpacing::Exactly(v), _) => (v, false), + }; + let baseline = if centred { + let up = asc.max(obj_asc); + top + ((height - up - desc) / 2.0).max(0.0) + up + } else { + top + height - desc }; - let baseline = top + height - desc; // Alignment / justification (trailing spaces hang). let mut content_end = xs.last().copied().unwrap_or(line_start_x); @@ -1681,10 +1768,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio if matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) { let (asc, desc) = pl.styles.get(mark_style as usize).map(|s| (s.ascent, s.descent)).unwrap_or((10.0, 3.0)); let natural = asc + desc; - let h = match rp.line_spacing { - LineSpacing::Multiple(m) => natural * m, - LineSpacing::AtLeast(v) => natural.max(v), - LineSpacing::Exactly(v) => v, + let h = match (rp.line_spacing, grid) { + (LineSpacing::Multiple(m), Some(g)) => g.snap_height(natural) * m, + (LineSpacing::AtLeast(v), Some(g)) => g.snap_height(natural).max(v), + (LineSpacing::Multiple(m), None) => natural * m, + (LineSpacing::AtLeast(v), None) => natural.max(v), + (LineSpacing::Exactly(v), _) => v, }; let x0 = rp.indent_left; let (x0, left, right) = if rtl_para { (width - x0, width - base_right, width - x0) } else { (x0, x0, base_right) }; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 3d3805b..c9f4851 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -170,6 +170,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f proofing: false, exclusions: &[], eq_number: 0, + grid: crate::para::Grid::default(), }; let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT); if cell.props.vmerge != VMerge::Continue && !cell.props.text_direction.is_turned() { diff --git a/crates/layout/src/tests_grid.rs b/crates/layout/src/tests_grid.rs new file mode 100644 index 0000000..fc2c900 --- /dev/null +++ b/crates/layout/src/tests_grid.rs @@ -0,0 +1,93 @@ +//! Document grid (`w:docGrid`): line pitch and character pitch (#391). Assertions are relations +//! to the pitch, never font metrics (fonts differ between machines; CJK glyphs may be missing). + +use super::*; +use wordcraft_doc::props::LineSpacing; +use wordcraft_doc::section::{DocGrid, DocGridType}; + +fn lay(doc: &Document) -> DocLayout { + layout(doc, &mut LayoutCache::new(), &LayoutOptions::default()) +} + +fn with_grid(text: &str, kind: DocGridType, char_space: i32) -> Document { + let mut d = Document::from_text(text); + for b in d.body.iter_mut() { + if let Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + p.props.line_spacing = Some(LineSpacing::Multiple(1.0)); + } + } + d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space }); + d +} + +/// (first-page lines, every line height) of a layout. +fn lines(l: &DocLayout) -> (usize, Vec) { + let mut first = 0; + let mut hs = Vec::new(); + for (pi, p) in l.pages.iter().enumerate() { + for it in &p.items { + if let Placed::Lines { para, l0, l1, .. } = it { + if pi == 0 { + first += l1 - l0; + } + hs.extend(para.lines[*l0..*l1].iter().map(|line| line.height)); + } + } + } + (first, hs) +} + +#[test] +fn line_grid_snaps_line_heights_and_fixes_lines_per_page() { + let text = vec!["Grid line"; 80].join("\n"); + let d = with_grid(&text, DocGridType::Lines, 0); + let (first, hs) = lines(&lay(&d)); + for h in &hs { + let cells = h / 18.0; + assert!((cells - cells.round()).abs() < 1e-3 && cells >= 1.0, "line height {h} is not a multiple of the pitch"); + } + // Text at the default size fits one pitch: 648 pt of text height holds 36 lines. + let h = hs[0]; + assert_eq!(first, (648.0 / h + 1e-3).floor() as usize, "heights {h}"); + // Turning the paragraphs' snapToGrid off leaves their natural line heights. + let mut off = d.clone(); + for b in off.body.iter_mut() { + if let Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.snap_to_grid = Some(false); + } + } + let plain = with_grid(&text, DocGridType::Default, 0); + assert_eq!(lines(&lay(&off)).1[0], lines(&lay(&plain)).1[0]); +} + +#[test] +fn character_grid_places_east_asian_text_on_the_pitch() { + let text = "日本語の文書abc"; + // The default font size plus 2.5 pt: a pitch unlike any font's advance. + let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() }; + let size = para::Grid::of(&Document::from_text(""), &lines).font_size; + let cs = DocGrid::char_space_for(size + 2.5, size); + let d = with_grid(text, DocGridType::LinesAndChars, cs); + let pitch = d.last_section.grid_char_pitch(size).unwrap(); + assert!((pitch - (size + 2.5)).abs() < 0.01); + let l = lay(&d); + let plain = lay(&with_grid(text, DocGridType::Lines, cs)); + let first = + |l: &DocLayout| l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap(); + let (g, p) = (first(&l), first(&plain)); + let line = &g.lines[0]; + let latin = text.find('a').unwrap(); + for (k, c) in g.clusters.iter().enumerate() { + let x = line.xs[k] - line.xs[0]; + if c.start < latin { + // Ideographs and kana sit on the pitch, one cell each. + assert!((x - pitch * k as f32).abs() < 0.01, "cluster {k} at {x}, pitch {pitch}"); + assert!((c.adv - pitch).abs() < 0.01); + } else { + // Latin text keeps its own advances. + assert!((c.adv - p.clusters[k].adv).abs() < 0.01, "latin cluster {k}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d1e3ec..2a1c288 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -504,7 +504,18 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { let settings = &app.session.doc.settings; - let step = (drawing_grid_step(settings.grid_h, page_scale), drawing_grid_step(settings.grid_v, page_scale)); + // A section with a document grid (`w:docGrid`) shows that grid: lines at the line + // pitch and, for a character grid, columns at the character pitch. + let sections = app.session.doc.sections(); + let sect = sections.get(page.section).map(|(_, s)| *s); + let step = match sect.and_then(|s| s.grid_line_pitch().map(|lp| (s, lp))) { + Some((s, lp)) => { + let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size; + let cp = s.grid_char_pitch(size).map_or(f32::INFINITY, |cp| drawing_grid_step(cp, page_scale)); + (cp, drawing_grid_step(lp, page_scale)) + } + None => (drawing_grid_step(settings.grid_h, page_scale), drawing_grid_step(settings.grid_v, page_scale)), + }; let (xs, ys) = drawing_grid(page.body, step); let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22)); let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b753dca..ff6f01b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -89,6 +89,10 @@ pub enum Dialog { left: f32, right: f32, landscape: bool, + /// Document grid: kind (0 none, 1 lines, 2 lines and characters, 3 snap to characters), + /// lines per page and characters per line; `grid_was` as opened (applied only if changed). + grid: (u8, u32, u32), + grid_was: (u8, u32, u32), /// Section direction: right to left (`layout.sectionRtl`). rtl: bool, }, @@ -814,12 +818,23 @@ impl Dialog { "insertTable" => Dialog::InsertTable { rows: 2, cols: 5 }, "pageSetup" => { let sp = wordcraft_engine::cmd::page::sect(&app.session); + use wordcraft_doc::section::DocGridType; + let kind = match sp.doc_grid.map(|g| g.kind) { + Some(DocGridType::Lines) => 1, + Some(DocGridType::LinesAndChars) => 2, + Some(DocGridType::SnapToChars) => 3, + _ => 0, + }; + let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size; + let grid = (kind, sp.grid_lines_per_page().min(1000), sp.grid_chars_per_line(size).min(1000)); Dialog::PageSetup { top: sp.margin_top / 72.0, bottom: sp.margin_bottom / 72.0, left: sp.margin_left / 72.0, right: sp.margin_right / 72.0, landscape: sp.landscape, + grid, + grid_was: grid, rtl: sp.rtl, } } @@ -1444,7 +1459,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::PageSetup { top, bottom, left, right, landscape, rtl } => { + Dialog::PageSetup { top, bottom, left, right, landscape, rtl, grid, grid_was } => { ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); egui::Grid::new("ps").num_columns(4).show(ui, |ui| { ui.label(tl!("Top:")); @@ -1463,6 +1478,20 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(landscape, false, tl!("Portrait")); ui.radio_value(landscape, true, tl!("Landscape")); }); + ui.label(egui::RichText::new(tl!("Document Grid")).font(semibold(12.5))); + ui.horizontal_wrapped(|ui| { + ui.radio_value(&mut grid.0, 0, tl!("No grid")); + ui.radio_value(&mut grid.0, 1, tl!("Lines only")); + ui.radio_value(&mut grid.0, 2, tl!("Lines and characters")); + ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters")); + }); + egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| { + ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))); + ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)); + ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))); + ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)); + ui.end_row(); + }); ui.horizontal(|ui| { ui.label(tl!("Section direction:")); ui.radio_value(rtl, false, tl!("Left-to-right")); @@ -1473,6 +1502,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }})); let _ = app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0})); + if grid != grid_was { + let kind = ["none", "lines", "linesAndChars", "snapToChars"].get(grid.0 as usize).copied().unwrap_or("none"); + let mut v = json!({"type": kind}); + if grid.1 != grid_was.1 { + v["linesPerPage"] = json!(grid.1); + } + if grid.2 != grid_was.2 { + v["charsPerLine"] = json!(grid.2); + } + let _ = app.run("layout.documentGrid", v); + } if *rtl != wordcraft_engine::cmd::page::sect(&app.session).rtl { let _ = app.run("layout.sectionRtl", json!({"value": *rtl})); } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 67091be..53daa14 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1412,6 +1412,13 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Document Grid Cuadrícula del documento + No grid Sin cuadrícula + Lines only Solo líneas + Lines and characters Líneas y caracteres + Text snaps to characters El texto se ajusta a los caracteres + Characters per line: Caracteres por línea: + Lines per page: Líneas por página: Moved from Movido desde Moved to Movido a Right-to-Left Table Tabla de derecha a izquierda diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 2e7f5fd..75fa642 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1444,6 +1444,13 @@ Rotate all text 270° Pööra kogu teksti 270° Rotate all text 90° Pööra kogu teksti 90° Stacked Virnas + Document Grid Dokumendi ruudustik + No grid Ruudustikuta + Lines only Ainult read + Lines and characters Read ja märgid + Text snaps to characters Tekst joondub märkide ruudustikku + Characters per line: Märke reas: + Lines per page: Ridu lehel: Location Asukoht Endnotes Lõpumärkused Below text Teksti all diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5c1af1b..f598e09 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1396,6 +1396,13 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Document Grid 文書グリッド + No grid グリッドなし + Lines only 行のみ + Lines and characters 行と文字 + Text snaps to characters 文字をグリッドに合わせる + Characters per line: 1 行の文字数: + Lines per page: 1 ページの行数: Moved from 移動元 Moved to 移動先 Right-to-Left Table 右から左の表 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e95c630..df8b9b8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1408,6 +1408,13 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Document Grid Grade do documento + No grid Sem grade + Lines only Somente linhas + Lines and characters Linhas e caracteres + Text snaps to characters O texto se ajusta aos caracteres + Characters per line: Caracteres por linha: + Lines per page: Linhas por página: Moved from Movido de Moved to Movido para Right-to-Left Table Tabela da direita para a esquerda diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f3699e1..786a925 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,13 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Document Grid Сетка документа + No grid Без сетки + Lines only Только строки + Lines and characters Строки и символы + Text snaps to characters Текст привязывается к символам + Characters per line: Символов в строке: + Lines per page: Строк на странице: Moved from Перемещено из Moved to Перемещено в Right-to-Left Table Таблица справа налево diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 0856ae0..d00d9cc 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1406,6 +1406,13 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Document Grid Mreža dokumenta + No grid Bez mreže + Lines only Samo redovi + Lines and characters Redovi i znakovi + Text snaps to characters Tekst se poravnava sa znakovima + Characters per line: Znakova po redu: + Lines per page: Redova po stranici: Moved from Premešteno odavde Moved to Premešteno ovde Right-to-Left Table Tabela sa desna na levo diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d604ab7..4541e8f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1410,6 +1410,13 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Document Grid Мрежа документа + No grid Без мреже + Lines only Само редови + Lines and characters Редови и знакови + Text snaps to characters Текст се поравнава са знаковима + Characters per line: Знакова по реду: + Lines per page: Редова по страници: Moved from Премештено одавде Moved to Премештено овде Right-to-Left Table Табела са десна на лево diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7ead77c..e62c0c6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,13 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Document Grid Belge Kılavuzu + No grid Kılavuz yok + Lines only Yalnızca satırlar + Lines and characters Satırlar ve karakterler + Text snaps to characters Metin karakterlere yapışır + Characters per line: Satır başına karakter: + Lines per page: Sayfa başına satır: Moved from Taşındı (kaynak) Moved to Taşındı (hedef) Right-to-Left Table Sağdan Sola Tablo diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cbf455a..6c9244d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1408,6 +1408,13 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Document Grid Сітка документа + No grid Без сітки + Lines only Лише рядки + Lines and characters Рядки й символи + Text snaps to characters Текст прив’язується до символів + Characters per line: Символів у рядку: + Lines per page: Рядків на сторінці: Moved from Переміщено звідси Moved to Переміщено сюди Right-to-Left Table Таблиця справа наліво diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2fd2dc8..6141f47 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1408,6 +1408,13 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Document Grid 文档网格 + No grid 无网格 + Lines only 仅行 + Lines and characters 行和字符 + Text snaps to characters 文字贴合字符网格 + Characters per line: 每行字数: + Lines per page: 每页行数: Moved from 移动自 Moved to 移动到 Right-to-Left Table 从右向左表格 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9321a9b..176b8c2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1397,6 +1397,13 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Document Grid 文件格線 + No grid 無格線 + Lines only 僅行 + Lines and characters 行與字元 + Text snaps to characters 文字貼齊字元格線 + Characters per line: 每行字數: + Lines per page: 每頁行數: Moved from 移動自 Moved to 移動到 Right-to-Left Table 從右至左表格 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 872a86a..53e89e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -68,7 +68,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | -| `w:eastAsianLayout`, `w:fitText`, document grid (`w:docGrid`) | ❌ | ❌ | East Asian layout | +| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read | +| `w:eastAsianLayout`, `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks), `w:altChunk`, sub-documents | ❌ | ❌ | | | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | @@ -85,6 +86,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) | | 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) | | 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) | | 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) | diff --git a/docs/gaps.md b/docs/gaps.md index 3c65ca0..b96d95c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | -| 17 | **East Asian typography**: vertical text, document grid, Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 16–24 | [layout-parity.md](layout-parity.md) | +| 17 | **East Asian typography**: vertical text, Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Document grid landed (#391); removed from the East Asian gap | | 2026-10-11 | trivial | Move tracking landed (#400); gap #5 narrowed to content controls, 5–8 h | | 2026-10-11 | trivial | #4: note options per section (below text, end of section, restart each section/page) and the Footnote and Endnote dialog (#385) | | 2026-10-11 | trivial | #10: Shape Format › Text: text direction, align text and linked text boxes (#369) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7e1341c..62b51b2 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -45,7 +45,7 @@ font can't match Word's metrics. | Line numbers (restart per page/section, count by) | ✅ | | — | | Vertical page alignment | ✅ | | — | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 | -| Document grid (`w:docGrid`, lines per page, characters per line) | ❌ | East Asian documents paginate differently without it | 4–6 | +| Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | 🟡 | cell text direction landed (#245); text-box text direction (rotate 90°, 270°, stacked) landed (#369); page-level vertical text and East Asian upright vertical glyphs missing | 8–12 | | Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 | | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | @@ -68,6 +68,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Document grid: line and character pitch (#391) | | 2026-10-11 | trivial | Linked text boxes, Align Text and text-box text direction (#369) | | 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) | | 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 222dceb..b0932f6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**428 of 450 catalog features have commands (95.1%).** +**429 of 451 catalog features have commands (95.1%).** | Tab | Live | Total | |---|---|---| @@ -12,7 +12,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Insert | 45 | 53 | | Draw | 6 | 11 | | Design | 10 | 10 | -| Layout | 20 | 20 | +| Layout | 21 | 21 | | References | 24 | 24 | | Mailings | 17 | 17 | | Review | 28 | 30 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0ce374d..28353a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **428 / 450 (95.1%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -61,7 +61,7 @@ full parity for the area. | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | | Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | -| Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no document grid; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | +| Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). | @@ -74,7 +74,7 @@ full parity for the area. | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | -| East Asian typography | 1 | 15% | not rated | 18–27 | IME input works (winit fixes pending, #155–#164). Ruby/Phonetic Guide with DOCX round trip (#288). No vertical text, document grid, `w:eastAsianLayout`, enclose characters, Asian line-break options. | +| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No vertical text, `w:eastAsianLayout`, enclose characters, Asian line-break options. | | Accessibility | 1 | 35% | not rated | 15–25 | AccessKit enabled in eframe; an accessibility checker exists. Screen-reader access to the document canvas is untested; no alt-text workflow polish, no read-order tools. | | Agent control (CLI, MCP, control channel, macro record/playback) | 0 | beyond Word | 100%+ | — | Every action is a command id usable from CLI, MCP and a keyed control port. Word has VBA and Office.js instead (see Ecosystem). | @@ -268,6 +268,7 @@ can be used without committing Word output. | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change | | 2026-10-10 | minor | Merged main (12 PRs): Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info fields, Serbian. Catalog 378/428 → 384/430 (89.3%); typing 82→85%, styles 72→74%, tables 74→76%; feature depth 67→68%; mainstream 54→55%; full stays ~60%, essentials ~63% | | 2026-10-10 | minor | Readiness table: hours to ~95% per audience (full ~650–1,100 h, mainstream ~300–500 h, essentials ~90–140 h). Full number restored to the additive weighted sum, ~47% → ~60%: method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h | +| 2026-10-11 | trivial | East Asian typography 15% → 25%: document grid (#391); kinsoku and Asian Typography options already landed | | 2026-10-10 | minor | Added mainstream practitioner (~54%) and essentials user (~63%) numbers with written weights and discounts; full ready-for-real-work recomputed with the same multiplicative discounts: 60% → ~47% (method change, not new product evidence; stage stays alpha: above 40% and the gate passes); beta now ~450–700 h to the 75% bar; user-evidence counts from GitHub | | 2026-10-10 | minor | Stage checked against the core-workflow alpha gate: passes, stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's "Parity by area" and "Estimate to 100%" sections; full re-measure against Word for Mac 16.113.4 (bundle listing, catalog diff from source, issue tracker); split into feature depth (67%) and ready for real work (60%); added proofing, East Asian, accessibility areas and the dimension table; hours re-calibrated from PR history |