Vertical text for sections (w:textDirection) (#491) (#496)

- Model: SectionProps.text_direction (horizontal, tbRl, btLr); unknown
  values read as horizontal.
- DOCX: w:textDirection in w:sectPr read and written in CT_SectPr order
  (after w:titlePg, before w:bidi), also inside w:sectPrChange.
- Layout: a vertical section is laid out on its page turned on its side
  (margins, page borders and columns turned with it) and each page is
  turned onto the real page: tbRl lines run top to bottom stacking from
  the right, btLr bottom to top from the left. Pictures, shapes and text
  boxes stay upright; headers and footers stay horizontal; a section
  turning the other way starts a new page.
- Display: in top-to-bottom text ideographs, kana, Hangul and full-width
  forms stand upright (ideographic comma/full stop moved to the upper
  right); Latin lies on its side. Raster and PDF draw it unchanged.
- Hit testing: clicks outside turned lines find the nearest turned line.
- Command layout.textDirection (Layout › Page Setup › Text Direction):
  turns the selected cells or the text box the caret is in, else the
  section; ribbon menu and keytip.

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-12 01:45:49 +02:00 committed by GitHub
parent e46133817a
commit 50acc0c5f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 545 additions and 16 deletions

View File

@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **459 / 475 (96.6%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **460 / 476 (96.6%)** | 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) |
@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
@ -174,6 +175,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
| 2026-10-11 | trivial | Progress log: content controls (#381) |
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |

View File

@ -459,6 +459,10 @@ pub struct SectionProps {
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
/// Which way the section's text runs (`w:textDirection`, ECMA-376 §17.6.20): vertical
/// sections turn the page's text area (headers and footers stay horizontal).
#[serde(skip_serializing_if = "is_horizontal")]
pub text_direction: crate::props::TextDirection,
/// 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>>>,
@ -496,6 +500,7 @@ impl Default for SectionProps {
valign: VAlign::Top,
page_borders: None,
rtl: false,
text_direction: crate::props::TextDirection::Horizontal,
fmt_change: None,
doc_grid: None,
footnote_pr: NoteProps::default(),
@ -504,6 +509,10 @@ impl Default for SectionProps {
}
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
impl SectionProps {
/// Width of the text area, points.
pub fn text_width(&self) -> f32 {

View File

@ -508,6 +508,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
"w:titlePg" => s.title_page = on_off(k),
"w:bidi" => s.rtl = on_off(k),
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:pgNumType" => {
s.page_num_start = k.attr("w:start").and_then(u32_of);
if let Some(f) = k.attr("w:fmt") {

View File

@ -1283,6 +1283,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
if s.title_page {
w.empty("w:titlePg", &[]);
}
if s.text_direction.is_turned() {
w.val("w:textDirection", s.text_direction.ooxml());
}
if s.rtl {
w.empty("w:bidi", &[]);
}

View File

@ -0,0 +1,50 @@
//! Section text direction (`w:textDirection` in `w:sectPr`, ECMA-376 Part 1 §17.6.20). Fixtures
//! are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextDirection;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_text_direction_round_trips_in_schema_order() {
// Three sections: vertical East Asian (tbRl), bottom-to-top (btLr) and a value no one knows.
let sect = |dir: &str| format!(r#"<w:sectPr><w:titlePg/><w:textDirection w:val="{dir}"/><w:bidi/></w:sectPr>"#);
let body = format!(
r#"<w:p><w:pPr>{}</w:pPr><w:r><w:t>縦書き</w:t></w:r></w:p><w:p><w:pPr>{}</w:pPr><w:r><w:t>Up</w:t></w:r></w:p><w:p><w:r><w:t>Odd</w:t></w:r></w:p>{}"#,
sect("tbRl"),
sect("btLr"),
sect("diagonal")
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let dirs: Vec<TextDirection> = d.sections().iter().map(|(_, s)| s.text_direction).collect();
assert_eq!(dirs, vec![TextDirection::Down, TextDirection::Up, TextDirection::Horizontal]);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
// After w:titlePg and before w:bidi (CT_SectPr's sequence); a horizontal section writes none.
assert!(xml.contains(r#"<w:titlePg/><w:textDirection w:val="tbRl"/><w:bidi/>"#), "{xml}");
assert!(xml.contains(r#"<w:textDirection w:val="btLr"/>"#));
assert_eq!(xml.matches("w:textDirection").count(), 2);
let back = wordcraft_docx::read(&out).unwrap();
let again: Vec<TextDirection> = back.sections().iter().map(|(_, s)| s.text_direction).collect();
assert_eq!(again, dirs);
}

View File

@ -220,6 +220,7 @@ 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|Page Setup|Text Direction|layout.textDirection
Layout|Paragraph|Indent|para.indents
Layout|Paragraph|Spacing|para.spacing
Layout|Arrange|Position|arrange.position

View File

@ -460,7 +460,7 @@ pub fn text_box_target(s: &Session) -> Option<(Pos, u32)> {
}
/// Change the target text box's text direction, alignment or link.
fn set_text_body(s: &mut Session, f: impl Fn(&mut TextBody)) -> CmdResult {
pub(crate) fn set_text_body(s: &mut Session, f: impl Fn(&mut TextBody)) -> CmdResult {
let (pos, id) = text_box_target(s).ok_or_else(|| CmdError::Disabled(NO_TEXT_BOX.into()))?;
let part = s.doc.parts.get_mut(&id).ok_or_else(|| CmdError::Failed("text box vanished".into()))?;
f(&mut part.body);

View File

@ -60,6 +60,9 @@ pub fn specs() -> Vec<CommandSpec> {
with_sect(s, |x| x.rtl = on)
})
.params(r#"{"value"?: bool} (true: the section's columns run right to left; without a value it toggles)"#),
CommandSpec::new("layout.textDirection", "Text Direction", "Layout › Page Setup", text_direction).params(
r#"{"value"?: "horizontal|down|up"} (also lrTb|tbRl|btLr; down: rotate all text 90°, lines top to bottom stacking right to left; up: 270°; default: the next after the current). In a table cell it turns the selected cells, in a text box the box; anywhere else the section(s) of the selection"#,
),
CommandSpec::new("layout.differentFirstPage", "Different First Page", "Header & Footer › Options", |s, v| {
let on = p::bool(v, "value").unwrap_or(!sect(s).title_page);
with_sect(s, |x| x.title_page = on)
@ -236,6 +239,44 @@ 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 text direction Layout › Text Direction would change: the caret cell's, the text box's,
/// or the section's.
pub fn text_direction_at(s: &Session) -> wordcraft_doc::props::TextDirection {
if let Some((tp, r, c)) = s.sel.focus.path.cell() {
return s.doc.table(s.sel.focus.story, &tp).and_then(|t| t.rows.get(r)?.cells.get(c).map(|cl| cl.props.text_direction)).unwrap_or_default();
}
if let Some((_, id)) = super::objects::text_box_target(s) {
return s.doc.parts.get(&id).map(|p| p.body.vert.turn()).unwrap_or_default();
}
sect(s).text_direction
}
/// Layout › Text Direction: like Word's button it works on what the caret is in — the selected
/// table cells, the text box, or else the section.
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::props::{TextDirection, TextVert};
let dir = match p::str(v, "value") {
Some("horizontal" | "lrTb" | "horz") => Some(TextDirection::Horizontal),
Some("down" | "tbRl" | "vert" | "rotate90") => Some(TextDirection::Down),
Some("up" | "btLr" | "vert270" | "rotate270") => Some(TextDirection::Up),
Some(x) => return Err(CmdError::Params(format!("unknown text direction `{x}`"))),
None => None,
};
let dir = dir.unwrap_or(text_direction_at(s).next());
if s.sel.focus.path.cell().is_some() {
return super::table::text_direction(s, &json!({"value": dir.ooxml()}));
}
if super::objects::text_box_target(s).is_some() {
let vert = match dir {
TextDirection::Horizontal => TextVert::Horz,
TextDirection::Down => TextVert::Vert,
TextDirection::Up => TextVert::Vert270,
};
return super::objects::set_text_body(s, |b| b.vert = vert);
}
with_sect(s, |x| x.text_direction = dir)
}
/// 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;
@ -468,6 +509,29 @@ mod tests {
Session::new(wordcraft_doc::Document::from_text("one\ntwo\nthree"))
}
/// Layout › Text Direction turns the section, or the table cell the caret is in; one undo step.
#[test]
fn text_direction_turns_the_section_or_the_cell() {
use wordcraft_doc::props::TextDirection;
let mut s = doc();
s.run("layout.textDirection", &json!({"value": "down"})).unwrap();
assert_eq!(sect(&s).text_direction, TextDirection::Down);
assert_eq!(text_direction_at(&s), TextDirection::Down);
// Without a value: the next one, like Word's button.
s.run("layout.textDirection", &json!({})).unwrap();
assert_eq!(sect(&s).text_direction, TextDirection::Up);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(sect(&s).text_direction, TextDirection::Down);
assert!(s.run("layout.textDirection", &json!({"value": "diagonal"})).is_err());
assert_eq!(sect(&s).text_direction, TextDirection::Down);
// In a table cell it turns the cell, not the section.
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
assert!(s.sel.focus.path.cell().is_some());
s.run("layout.textDirection", &json!({"value": "btLr"})).unwrap();
assert_eq!(text_direction_at(&s), TextDirection::Up);
assert_eq!(sect(&s).text_direction, TextDirection::Down);
}
/// More Columns: unequal widths with a line between, as one undo step.
#[test]
fn unequal_columns_with_a_line_between() {

View File

@ -415,7 +415,7 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
let (tp, r, c) = cell(s)?;
let dir = match p::str(v, "value") {
Some("horizontal" | "lrTb") => TextDirection::Horizontal,

View File

@ -561,6 +561,9 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
*link = None;
}
}
if *turn == TextDirection::Down {
items = upright_east_asian(items);
}
out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items });
}
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
@ -1490,3 +1493,90 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f
fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
Draw::Shape { rect, kind: ShapeKind::Rectangle, fill: c.fill, stroke: c.stroke, stroke_width: c.stroke_width, effects: Default::default() }
}
/// Whether `c` stands upright in top-to-bottom East Asian text (`tbRl`) rather than turning with
/// the line: ideographs, kana, Hangul, full-width letters and digits, and the ideographic comma
/// and full stop (an approximation of Unicode's vertical orientation, UAX #50). Brackets, dashes
/// and the prolonged sound mark turn with the line, as their vertical forms do.
pub fn upright_in_vertical(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11FF}'
| '\u{2E80}'..='\u{2FFF}'
| '\u{3001}'..='\u{3007}'
| '\u{3012}'..='\u{3013}'
| '\u{3020}'..='\u{302F}'
| '\u{3031}'..='\u{303F}'
| '\u{3040}'..='\u{30FB}'
| '\u{30FD}'..='\u{30FF}'
| '\u{3100}'..='\u{31EF}'
| '\u{31F0}'..='\u{4DBF}'
| '\u{4E00}'..='\u{9FFF}'
| '\u{A960}'..='\u{A97F}'
| '\u{AC00}'..='\u{D7FF}'
| '\u{F900}'..='\u{FAFF}'
| '\u{FE30}'..='\u{FE4F}'
| '\u{FF01}'..='\u{FF07}'
| '\u{FF0A}'..='\u{FF0C}'
| '\u{FF0E}'..='\u{FF3A}'
| '\u{FF3C}'
| '\u{FF3E}'..='\u{FF5A}'
| '\u{FFE0}'..='\u{FFE6}'
| '\u{20000}'..='\u{3FFFF}'
)
}
/// Top-to-bottom text drawn turned 90° clockwise, with its East Asian characters turned back
/// upright, each about the centre of its em square (`tbRl`: ideographs stand, Latin lies).
/// Glyph runs are split where uprightness changes, so the text keeps its order.
fn upright_east_asian(items: Vec<Draw>) -> Vec<Draw> {
let mut out = Vec::with_capacity(items.len());
for d in items {
let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } = d else {
out.push(d);
continue;
};
let up = |k: usize| ranges.get(k).and_then(|r| text.get(r.clone())).and_then(|t| t.chars().next()).is_some_and(upright_in_vertical);
if ranges.len() != glyphs.len() || !(0..glyphs.len()).any(up) {
out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges });
continue;
}
let piece = |k0: usize, k1: usize| -> Draw {
let gs = glyphs.get(k0..k1).unwrap_or(&[]).to_vec();
let rs = ranges.get(k0..k1).unwrap_or(&[]);
let (b0, b1) = (rs.iter().map(|r| r.start).min().unwrap_or(0), rs.iter().map(|r| r.end).max().unwrap_or(0));
let t = text.get(b0..b1).unwrap_or("").to_string();
let rs = rs.iter().map(|r| r.start.saturating_sub(b0)..r.end.saturating_sub(b0)).collect();
Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs }
};
let mut k0 = 0;
while k0 < glyphs.len() {
let u = up(k0);
let k1 = (k0 + 1..glyphs.len()).find(|&k| up(k) != u).unwrap_or(glyphs.len());
if !u {
out.push(piece(k0, k1));
} else {
for k in k0..k1 {
let Some(&(_, gx, base)) = glyphs.get(k) else { continue };
// The em square: one em along the line (the advance to the next glyph when
// known), from about 0.88 em above the baseline to 0.12 em below it.
let adv = glyphs.get(k + 1).map(|g| g.1 - gx).filter(|a| *a > 0.0 && *a < size * 2.0).unwrap_or(size);
let (cx, cy) = (gx + adv / 2.0, base - size * 0.38);
let mut g = piece(k, k + 1);
// The ideographic comma and full stop sit in the upper right of their square in
// vertical text (lower left in horizontal): moved there before standing up.
if let Draw::Glyphs { text, glyphs, .. } = &mut g
&& matches!(text.chars().next(), Some('\u{3001}' | '\u{3002}' | '\u{FF0C}' | '\u{FF0E}'))
{
for (_, x, y) in glyphs.iter_mut() {
*x += size * 0.55;
*y -= size * 0.55;
}
}
out.push(Draw::Rotated { cx, cy, spin: Spin::new(-90.0, false, false), items: vec![g] });
}
}
k0 = k1;
}
}
out
}

View File

@ -176,6 +176,24 @@ fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
})
}
/// The position in the turned text of `story` nearest to page point (x, y), wherever it is.
fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
let dist = |r: Rect| band_dist(x, r.x, r.w).hypot(band_dist(y, r.y, r.h));
let it = items_of(page, story)
.filter(|it| matches!(it, Placed::Lines { story: s, .. } if *s == story))
.filter_map(|it| it.turned_bounds().map(|r| (it, dist(r))))
.min_by(|a, b| a.1.total_cmp(&b.1))?
.0;
let Placed::Lines { path, para, l0, l1, x: ox, y: oy, turn, .. } = it else { return None };
let (u, v) = crate::unturn_point(*turn, *ox, *oy, x, y);
let first = para.lines.get(*l0)?;
let li = (*l0..*l1)
.filter_map(|li| para.lines.get(li).map(|l| (li, l.top - first.top, l.height)))
.min_by(|a, b| band_dist(v, a.1, a.2).total_cmp(&band_dist(v, b.1, b.2)))?
.0;
Some(Pos { story, path: path.clone(), off: para.off_at_x(li, u) })
}
/// How far `v` is outside the band `top..top + h`.
fn band_dist(v: f32, top: f32, h: f32) -> f32 {
if v < top {
@ -195,6 +213,10 @@ impl DocLayout {
return Some(pos);
}
let lines = page_lines(p, story);
if lines.is_empty() {
// A page of turned text (a vertical section): the nearest turned line.
return nearest_turned(p, story, x, y);
}
let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?;
let off = best.para.off_at_x(best.li, x - best.x);
Some(Pos { story: best.story, path: best.path.clone(), off })

View File

@ -17,6 +17,7 @@ pub mod math;
pub mod para;
mod table;
mod textbox;
mod vertical;
pub mod wordart;
use std::collections::{HashMap, HashSet};
@ -1604,8 +1605,24 @@ fn layout_pass(
// Text boxes laid out in balancing attempts that were thrown away.
let mut boxes_discarded = 0usize;
let mut block = 0usize;
// Vertical sections are laid out on their pages turned on their side (see `vertical`).
let turned: Vec<Option<SectionProps>> = sections
.iter()
.map(|(_, s)| {
(!web && s.text_direction.is_turned()).then(|| {
let top = body_top_for(&mut ctx, s, s.headers.default);
vertical::unturned_sect(s, top)
})
})
.collect();
let mut prev_turn = TextDirection::Horizontal;
for (si, (end, sect)) in sections.iter().enumerate() {
let sect: &SectionProps = if web { sect_ref } else { sect };
let turn = if web { TextDirection::Horizontal } else { sect.text_direction };
let sect: &SectionProps = match turned.get(si) {
Some(Some(t)) => t,
_ if web => sect_ref,
_ => sect,
};
pb.sect = sect;
pb.sect_idx = si;
ctx.grid = para::Grid::default();
@ -1618,10 +1635,10 @@ fn layout_pass(
ctx.fields.section_pages = totals.section(si);
// Headers and footers left out are linked to the previous section's, and take its room.
let (heads, feet) = (linked(&sections, si, |s| s.headers), linked(&sections, si, |s| s.footers));
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
let first_top = if web || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
let foot_top = if web { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
let first_foot = if web || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) };
let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) };
let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) };
let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) };
let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.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) {
@ -1630,7 +1647,8 @@ fn layout_pass(
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
if web && si > 0 {
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous {
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
// A page holds text running one way: a section turning the other way starts a page.
if let Some(n) = restart {
pb.number = n.saturating_sub(1);
}
@ -1651,6 +1669,7 @@ fn layout_pass(
pb.y = pb.top;
pb.max_y = pb.top;
}
prev_turn = turn;
pb.foot_top = foot_top;
let last = (*end).min(doc.body.len().saturating_sub(1));
// Newspaper columns are balanced on the section's last page when the next section starts
@ -1710,6 +1729,13 @@ fn layout_pass(
}
pb.carry.clear();
let mut pages = pb.pages;
for p in &mut pages {
if let Some((_, s)) = sections.get(p.section)
&& !web
{
vertical::turn_page(p, s.text_direction, s);
}
}
if web && let Some(p) = pages.first_mut() {
let bottom = p
.items
@ -2887,3 +2913,5 @@ mod tests_grid;
mod tests_pages;
#[cfg(test)]
mod tests_textbox;
#[cfg(test)]
mod tests_vertical;

View File

@ -0,0 +1,120 @@
//! Vertical sections (`w:textDirection` in `w:sectPr`): lines run down the page and stack from
//! the right (`tbRl`), or run up and stack from the left (`btLr`).
use super::*;
use wordcraft_doc::Pos;
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
fn vertical_doc(dir: TextDirection) -> Document {
let text = "Vertical sections lay their lines out down the page, one after another from right to left. ".repeat(4);
let mut d = Document::from_text(&format!("{text}\n日本語のテキスト"));
d.last_section.text_direction = dir;
d
}
/// Body lines on page 0: (x, y, turn) of each piece.
fn body_lines(l: &DocLayout) -> Vec<(f32, f32, TextDirection)> {
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, x, y, turn, .. } => Some((*x, *y, *turn)),
_ => None,
})
.collect()
}
#[test]
fn top_to_bottom_section_stacks_lines_from_the_right() {
let d = vertical_doc(TextDirection::Down);
let l = lay(&d);
let page = &l.pages[0];
// The page keeps its size; the text area is the real one (inside the margins).
assert_eq!((page.w, page.h), (d.last_section.page_w, d.last_section.page_h));
assert!(page.body.x >= d.last_section.margin_left - 0.5 && page.body.right() <= page.w - d.last_section.margin_right + 0.5);
let lines = body_lines(&l);
assert!(lines.len() >= 2);
assert!(lines.iter().all(|(.., t)| *t == TextDirection::Down));
// The first paragraph's first line is the rightmost, the next paragraph left of it.
let p0 = Pos::body(0, 0);
let c0 = l.caret(&p0).unwrap();
let c1 = l.caret(&Pos::body(1, 0)).unwrap();
assert!(c1.x < c0.x, "second paragraph left of the first: {c1:?} vs {c0:?}");
assert!(c0.x <= page.body.right() + 0.5 && c0.top >= page.body.y - 0.5);
// Characters progress downward along a line; the caret lies across it.
let c5 = l.caret(&Pos::body(0, 2)).unwrap();
assert!(c5.top > c0.top && (c5.x - c0.x).abs() < 0.5, "{c5:?} vs {c0:?}");
assert!(c0.width > c0.height);
// Line two of the first paragraph is left of line one.
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
let wrap = l.index.get(&(StoryRef::Body, Path::top(0))).map(|v| v[0]).unwrap();
let Placed::Lines { para, .. } = &l.pages[wrap.0].items[wrap.1] else { panic!() };
assert!(para.lines.len() >= 2 && para.lines[1].start < p.len());
let c_next = l.caret(&Pos::body(0, para.lines[1].start + 1)).unwrap();
assert!(c_next.x < c0.x - 1.0);
// Clicking a caret position finds it again.
for off in [0, 2, para.lines[1].start + 1] {
let c = l.caret(&Pos::body(0, off)).unwrap();
let hit = l.hit(c.page, c.x + c.width / 2.0, c.top + 0.2, StoryRef::Body).unwrap();
assert_eq!(hit.off, off);
}
// Clicking in the empty space below a short line lands on that line (its end).
let c_cjk = l.caret(&Pos::body(1, 0)).unwrap();
let below = l.hit(0, c_cjk.x + c_cjk.width / 2.0, page.body.bottom() - 2.0, StoryRef::Body).unwrap();
assert_eq!(below.path, Path::top(1));
}
#[test]
fn bottom_to_top_section_stacks_lines_from_the_left() {
let l = lay(&vertical_doc(TextDirection::Up));
assert!(body_lines(&l).iter().all(|(.., t)| *t == TextDirection::Up));
let c0 = l.caret(&Pos::body(0, 0)).unwrap();
let c1 = l.caret(&Pos::body(1, 0)).unwrap();
let c5 = l.caret(&Pos::body(0, 2)).unwrap();
assert!(c1.x > c0.x, "second paragraph right of the first");
assert!(c5.top < c0.top, "characters progress upward");
}
#[test]
fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
let l = lay(&vertical_doc(TextDirection::Down));
let d = vertical_doc(TextDirection::Down);
let draws = display::page_display(&d, &l.pages[0], &Default::default());
let turned: Vec<&Vec<display::Draw>> =
draws.iter().filter_map(|x| if let display::Draw::Turned { items, .. } = x { Some(items) } else { None }).collect();
let text_of = |ds: &[display::Draw]| -> String {
ds.iter()
.map(|x| match x {
display::Draw::Glyphs { text, .. } => text.clone(),
display::Draw::Rotated { items, .. } => {
items.iter().map(|i| if let display::Draw::Glyphs { text, .. } = i { text.clone() } else { String::new() }).collect()
}
_ => String::new(),
})
.collect()
};
// Each ideograph turned back upright on its own; the Latin text lies with the line.
let cjk = turned.iter().find(|ds| text_of(ds).contains('日')).expect("the Japanese line is drawn turned");
let upright = cjk.iter().filter(|x| matches!(x, display::Draw::Rotated { spin, .. } if spin.deg == 270.0)).count();
assert_eq!(upright, "日本語のテキスト".chars().count());
assert!(turned.iter().any(|ds| text_of(ds).contains("Vertical") && ds.iter().all(|x| !matches!(x, display::Draw::Rotated { .. }))));
}
#[test]
fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
use wordcraft_doc::section::SectionStart;
let mut d = Document::from_text("Horizontal\nVertical");
// Section 1 ends after paragraph 0 (continuous), section 2 is vertical.
let first = SectionProps { start: SectionStart::Continuous, ..Default::default() };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
d.last_section.start = SectionStart::Continuous;
d.last_section.text_direction = TextDirection::Down;
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert_eq!(l.caret(&Pos::body(0, 0)).unwrap().width, 0.0);
assert!(l.caret(&Pos::body(1, 0)).unwrap().width > 0.0);
}

View File

@ -0,0 +1,123 @@
//! Vertical sections (Layout › Text Direction, `w:textDirection` in `w:sectPr`).
//!
//! A vertical section is laid out unturned on a page turned onto its side — lines as long as the
//! page's text area is tall, stacking across its width — and each finished page is then turned
//! onto the real page, as a table cell's turned text is: top-to-bottom lines stack from the right
//! (`tbRl`), bottom-to-top ones from the left (`btLr`). Pictures, shapes and text boxes keep
//! standing upright where the turn carries them; headers and footers stay horizontal.
use wordcraft_doc::StoryRef;
use wordcraft_doc::props::{Borders, TextDirection};
use wordcraft_doc::section::SectionProps;
use wordcraft_geom::{Point, Rect};
use crate::{Page, Placed, turn_point, turn_rect};
/// The page a section running `turn` is laid out on, unturned: the real page `s` on its side,
/// its margins turned with it. `body_top` is where the real page's body starts (below a tall
/// header). Columns divide the turned page's width, so they stack down the real page.
pub(crate) fn unturned_sect(s: &SectionProps, body_top: f32) -> SectionProps {
let mut v = s.clone();
v.page_w = s.page_h;
v.page_h = s.page_w;
v.gutter = 0.0;
let (top, right, bottom, left) = (body_top, s.margin_right, s.margin_bottom, s.margin_left + s.gutter);
// The unturned frame's (u, v) lands at (W - v, u) for `Down` and (v, H - u) for `Up`: its
// top edge is the real right edge (left for `Up`), its left edge the real top (bottom).
match s.text_direction {
TextDirection::Horizontal => return v,
TextDirection::Down => {
(v.margin_top, v.margin_left, v.margin_bottom, v.margin_right) = (right, top, left, bottom);
v.page_borders = s.page_borders.as_ref().map(|b| Borders { top: b.right, left: b.top, bottom: b.left, right: b.bottom, ..*b });
}
TextDirection::Up => {
(v.margin_top, v.margin_left, v.margin_bottom, v.margin_right) = (left, bottom, right, top);
v.page_borders = s.page_borders.as_ref().map(|b| Borders { top: b.left, left: b.bottom, bottom: b.right, right: b.top, ..*b });
}
}
v
}
/// Where the unturned frame's origin lands on the real `w` × `h` page.
fn origin(turn: TextDirection, w: f32, h: f32) -> (f32, f32) {
match turn {
TextDirection::Horizontal => (0.0, 0.0),
TextDirection::Down => (w, 0.0),
TextDirection::Up => (0.0, h),
}
}
/// Turn a page laid out unturned (see [`unturned_sect`]) onto the real `phys` page.
pub(crate) fn turn_page(page: &mut Page, turn: TextDirection, phys: &SectionProps) {
if !turn.is_turned() {
return;
}
let (ox, oy) = origin(turn, phys.page_w, phys.page_h);
page.w = phys.page_w;
page.h = phys.page_h;
page.body = turn_rect(turn, ox, oy, page.body);
// Pictures, shapes and text boxes stand upright: their centres move with the turn. A text
// box's text moves with its box (it runs as the box says, not as the page does).
let upright = |r: Rect| {
let (cx, cy) = turn_point(turn, ox, oy, r.x + r.w / 2.0, r.y + r.h / 2.0);
Rect::new(cx - r.w / 2.0, cy - r.h / 2.0, r.w, r.h)
};
let mut boxes: Vec<(u32, Rect, (f32, f32))> = Vec::new();
for it in &mut page.items {
if let Placed::Object { rect, origin, text_box, .. } = it {
let r = upright(*rect);
if let Some(id) = text_box {
boxes.push((*id, *rect, (r.x - rect.x, r.y - rect.y)));
}
*rect = r;
(origin.x, origin.y) = turn_point(turn, ox, oy, origin.x, origin.y);
}
}
let in_box = |it: &Placed| -> Option<(f32, f32)> {
if let Placed::Lines { story: StoryRef::Part(id), .. } = it
&& let Some((_, _, d)) = boxes.iter().find(|(b, _, _)| b == id)
{
return Some(*d);
}
let r = bounds(it)?;
boxes
.iter()
.find(|(_, b, _)| b.expand(0.5).contains(Point::new(r.x, r.y)) && b.expand(0.5).contains(Point::new(r.right(), r.bottom())))
.map(|(_, _, d)| *d)
};
for it in &mut page.items {
if matches!(it, Placed::Object { .. }) {
continue;
}
if let Some((dx, dy)) = in_box(it) {
it.translate(dx, dy);
continue;
}
match it {
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } => *rect = upright(*rect),
_ => it.turn(turn, ox, oy),
}
}
}
/// An item's unturned page area (lines: their band; `None` for turned lines, which a text box
/// in the section holds).
fn bounds(it: &Placed) -> Option<Rect> {
match it {
Placed::Lines { para, l0, l1, x, y, turn, .. } if !turn.is_turned() => {
let first = para.lines.get(*l0)?;
let last = para.lines.get(l1.checked_sub(1)?)?;
let right = para.lines.get(*l0..*l1).unwrap_or(&[]).iter().map(|l| l.right).fold(0.0f32, f32::max);
Some(Rect::new(*x, *y, right, last.top + last.height - first.top))
}
Placed::Lines { .. } => it.turned_bounds(),
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Clip { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. } => Some(*rect),
Placed::Rule { x0, y0, x1, y1, .. } => Some(Rect::new(x0.min(*x1), y0.min(*y1), (x1 - x0).abs(), (y1 - y0).abs())),
}
}

View File

@ -225,6 +225,7 @@ fn controls(tab: &str) -> Vec<Control> {
m("Page Borders", "design.pageBorders"),
],
"Layout" => vec![
m("Text Direction", "layout.textDirection"),
m("Margins", "layout.margins"),
m("Orientation", "layout.orientation"),
m("Size", "layout.size"),

View File

@ -968,6 +968,16 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
fn layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| {
// Text Direction works on what the caret is in: table cells, a text box, else the section.
let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session);
menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, |ui, app| {
use wordcraft_doc::props::TextDirection;
for (label, d) in
[("Horizontal", TextDirection::Horizontal), ("Rotate all text 90°", TextDirection::Down), ("Rotate all text 270°", TextDirection::Up)]
{
mi_check(ui, app, label, dir == d, "layout.textDirection", json!({"value": d.ooxml()}));
}
});
menu_button(ui, app, "margins", Some("Margins"), "Margins", true, |ui, app| {
for (l, k) in [
("Normal 1\" all", "normal"),

View File

@ -71,6 +71,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles |
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
| Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) |
| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language |
| 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 |
@ -109,6 +110,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
| 2026-10-11 | trivial | Section text direction read and written (#491) |
| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded |
| 2026-10-10 | trivial | Footnote and endnote separators and continuation notices read and written (#352) |
| 2026-10-11 | trivial | Enclosed characters (`EQ \o\ac`) read, drawn and written (#297) |

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, 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) |
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | 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: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions, 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) |
@ -107,5 +107,6 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list |
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
| 2026-10-10 | minor | Beta note restated: full number back to the additive ~60% (method aligned with the standard, no new evidence) |
| 2026-10-11 | trivial | #491: vertical sections landed; vertical text removed from the East Asian gap |
| 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours |
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed |

View File

@ -48,9 +48,9 @@ font can't match Word's metrics.
| Vertical page alignment | ✅ | | — |
| Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats move with the text); book fold missing | 2–3 |
| Document grid (`w:docGrid`, lines per page, characters per line) | ❌ | East Asian documents paginate differently without it | 4–6 |
| Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 |
| 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) | 🟡 | #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 |
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
@ -72,6 +72,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |
| 2026-10-11 | trivial | Mirror indents (`w:mirrorIndents`): left/right indents swap on even pages; suppress line numbers inherited from styles (#512) |
| 2026-10-11 | trivial | NUMPAGES/SECTIONPAGES in the body show the real totals (the body is laid out again once the page count is known, at most three extra passes, #451); footnotes referenced in table cells go to the page bottom (#450); PDF links to a bookmark go to the page its start is on, not its paragraph's first page (#455) |
| 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) |

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.
**459 of 475 catalog features have commands (96.6%).**
**460 of 476 catalog features have commands (96.6%).**
| Tab | Live | Total |
|---|---|---|
@ -12,7 +12,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Insert | 46 | 53 |
| Draw | 10 | 11 |
| Design | 10 | 10 |
| Layout | 21 | 21 |
| Layout | 22 | 22 |
| References | 24 | 24 |
| Mailings | 17 | 17 |
| Review | 29 | 31 |

View File

@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **459 / 475 (96.6%)** | **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 | **460 / 476 (96.6%)** | **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 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; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). |
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). 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); bibliography sources not yet in DOCX (#169 open). |
@ -76,7 +76,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 | 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. |
| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No `w:eastAsianLayout`, enclose characters, Asian line-break options. |
| Accessibility | 1 | 50% | not rated | 10–18 | AccessKit enabled in eframe. Alt Text pane for pictures, shapes, text boxes, groups, charts and tables with Mark as decorative, all round-tripping in DOCX (#386). The Accessibility pane groups errors, warnings and tips with jump-to and one-click fixes; rules for alt text, heading order, header rows, blank table rows/columns, merged cells, low contrast (WCAG ratio), floating objects and blank paragraphs. Screen-reader access to the document canvas is still untested (#18); no reading-order tools. The document canvas describes its text, headings, lists, tables, links, pictures and caret to screen readers through AccessKit (#488). |
| 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). |
@ -274,6 +274,7 @@ can be used without committing Word output.
| 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change |
| 2026-10-11 | trivial | #395: Lasso Select, Add Pen, Ink to Shape and Ink Replay; catalog 423/448 → 427/448 (95.3%) |
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) |
| 2026-10-11 | trivial | Vertical sections (#491): Page layout and East Asian rows updated; no percentage change |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) |
| 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% |