Document grid: lines per page and characters per line (#391) (#401)

- 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 21:29:13 +02:00 committed by GitHub
parent 30993ffb3f
commit 6526b134be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 593 additions and 26 deletions

View File

@ -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) |

View File

@ -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<Box<PropChange<ParaProps>>>,
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
}
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);

View File

@ -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),
}
}
}

View File

@ -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> {
[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<Box<crate::props::PropChange<SectionProps>>>,
/// Document grid (`w:docGrid`); `None` when the file has none (no grid).
pub doc_grid: Option<DocGrid>,
/// 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<f32> {
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<f32> {
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() };

View File

@ -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<HfRef>) {
}
}
"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);
}
_ => {}
}
}

View File

@ -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)));

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -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

View File

@ -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<CommandSpec> {
})
})
.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(&sect(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(&sect(s), size))
}
fn margins(s: &mut Session, v: &Value) -> CmdResult {
let preset = p::str(v, "preset");
let (t, b, l, r) = match preset {

View File

@ -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;

View File

@ -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, &sections);
}
@ -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;

View File

@ -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<f32>,
/// Character pitch, points: East Asian characters advance by it.
pub chars: Option<f32>,
/// 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<Exclusion>;
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) };

View File

@ -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() {

View File

@ -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<f32>) {
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}");
}
}
}

View File

@ -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);

View File

@ -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}));
}

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 右から左の表

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Таблица справа налево

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Табела са десна на лево

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -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 Таблиця справа наліво

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 从右向左表格

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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 從右至左表格

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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) |

View File

@ -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) |

View File

@ -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) |

View File

@ -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 |

View File

@ -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 |