- 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:
parent
e46133817a
commit
50acc0c5f3
|
|
@ -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%) |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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", &[]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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(§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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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% |
|
||||
|
|
|
|||
Loading…
Reference in New Issue