diff --git a/ROADMAP.md b/ROADMAP.md index 2d71563..12a3f93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **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%) | diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 1c546c1..b502800 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -459,6 +459,10 @@ pub struct SectionProps { pub page_borders: Option, /// 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>>, @@ -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 { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 5b67dd1..3270683 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -508,6 +508,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { "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") { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index c7d553b..238ae35 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -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", &[]); } diff --git a/crates/docx/tests/section_text_direction.rs b/crates/docx/tests/section_text_direction.rs new file mode 100644 index 0000000..77efbaf --- /dev/null +++ b/crates/docx/tests/section_text_direction.rs @@ -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 { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + 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#""#); + let body = format!( + r#"{}縦書き{}UpOdd{}"#, + sect("tbRl"), + sect("btLr"), + sect("diagonal") + ); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let dirs: Vec = 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#""#), "{xml}"); + assert!(xml.contains(r#""#)); + assert_eq!(xml.matches("w:textDirection").count(), 2); + let back = wordcraft_docx::read(&out).unwrap(); + let again: Vec = back.sections().iter().map(|(_, s)| s.text_direction).collect(); + assert_eq!(again, dirs); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 0e5c00e..5b289fa 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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 diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 7f0116e..cba78d4 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -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); diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index a331189..c98be2a 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -60,6 +60,9 @@ pub fn specs() -> Vec { 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() { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index d2c8ed5..fed691f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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, diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index a4266b4..f20c9ac 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -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) -> Vec { + 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 +} diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 31cda25..b78aff7 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -176,6 +176,24 @@ fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option { }) } +/// The position in the turned text of `story` nearest to page point (x, y), wherever it is. +fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option { + 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 }) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index e0ba9ed..0aaba40 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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> = 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(§ions, si, |s| s.headers), linked(§ions, 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; diff --git a/crates/layout/src/tests_vertical.rs b/crates/layout/src/tests_vertical.rs new file mode 100644 index 0000000..aaec1c9 --- /dev/null +++ b/crates/layout/src/tests_vertical.rs @@ -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> = + 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); +} diff --git a/crates/layout/src/vertical.rs b/crates/layout/src/vertical.rs new file mode 100644 index 0000000..cbd0b13 --- /dev/null +++ b/crates/layout/src/vertical.rs @@ -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 { + 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())), + } +} diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index dad0b7e..9f3e830 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -225,6 +225,7 @@ fn controls(tab: &str) -> Vec { m("Page Borders", "design.pageBorders"), ], "Layout" => vec![ + m("Text Direction", "layout.textDirection"), m("Margins", "layout.margins"), m("Orientation", "layout.orientation"), m("Size", "layout.size"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 7f7ca50..6c8a168 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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"), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 04cf7b3..a54dc95 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index c65c8fa..d630069 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | -| 17 | **East Asian typography**: vertical text, 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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c7bcc07..e7134ef 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0c29940..dc75384 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1d95896..fcc9db4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **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% |