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 |