DOCX: mixed Hebrew/Latin keeps its order in Word (runs split by direction, w:rtl and language) (#1543) (#1950)

* WIP for #1543

* DOCX: split mixed-direction runs and mark right-to-left pieces w:rtl so Word orders them as WordCraft does (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* DOCX: right-to-left pieces of split runs say their language (w:lang w:bidi) (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* DOCX: no phantom tracked formatting on split runs; typed text doesn't inherit run direction (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Engine: text typed or inserted anywhere takes its direction from its own letters, not the caret's run (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 04:41:59 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 82bf806118
commit c9f3da2fc5
14 changed files with 510 additions and 11 deletions
Generated
+1
View File
@@ -5223,6 +5223,7 @@ dependencies = [
"sha1",
"sha2",
"thiserror 2.0.21",
"unicode-bidi",
"wordcraft-doc",
"wordcraft-geom",
"zeroize",
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|---|---|
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
@@ -651,3 +652,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
+1
View File
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
unicode-bidi = { workspace = true }
cfb = { workspace = true }
aes = { workspace = true }
sha1 = { workspace = true }
+148
View File
@@ -0,0 +1,148 @@
//! Run direction for mixed right-to-left and left-to-right text (#1543).
//!
//! WordCraft lays a paragraph out with the Unicode Bidirectional Algorithm (UAX #9) from its
//! text and direction alone, so a run need not say its direction. Word orders a run's characters
//! by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run without it is laid out left
//! to right, so Hebrew words on either side of a Latin name, and the punctuation between them,
//! change places. A run that doesn't say its direction is therefore split where the resolved
//! level changes parity, and the right-to-left pieces are written `w:rtl`, as Word writes text
//! typed in a right-to-left paragraph. Runs that say their direction, and paragraphs without
//! right-to-left characters, are written as they are.
//!
//! Word gives right-to-left text without a bidi language (`w:lang w:bidi`, §17.3.2.20) its
//! default complex-script language, Arabic, so Hebrew would proof and show as Arabic. The pieces
//! marked `w:rtl` therefore also say their language, unless the run or its styles already do.
use std::borrow::Cow;
use std::ops::Range;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::styles::StyleSheet;
/// The language Word expects for right-to-left text of `c`'s script, for the scripts that have
/// one usual language.
fn script_lang(c: char) -> Option<&'static str> {
match u32::from(c) {
0x0590..=0x05FF | 0xFB1D..=0xFB4F => Some("he-IL"),
0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => Some("ar-SA"),
0x0700..=0x074F => Some("syr-SY"),
0x0780..=0x07BF => Some("dv-MV"),
_ => None,
}
}
/// The language of the first right-to-left letter of `text` with a usual language.
fn text_lang(text: &str) -> Option<&'static str> {
text.chars().find_map(script_lang)
}
/// A language tag of a right-to-left language (`he-IL`, `yi`, `fa-IR`, `ar-EG`…)?
fn is_rtl_lang(tag: &str) -> bool {
let primary = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
matches!(primary.as_str(), "he" | "iw" | "yi" | "ar" | "fa" | "ur" | "ps" | "sd" | "ug" | "ckb" | "syr" | "dv" | "ku")
}
/// Does a paragraph of style `para_style` inherit a bidi language from the document defaults or
/// its style chain? (Resolved once per paragraph.)
pub(super) fn para_inherits_lang_bidi(styles: &StyleSheet, para_style: Option<&str>) -> bool {
styles.para_style_props(para_style.unwrap_or("Normal")).1.lang_bidi.is_some()
}
/// Does `c`'s character style chain give it a bidi language?
pub(super) fn char_style_has_lang_bidi(styles: &StyleSheet, c: &CharProps) -> bool {
c.style.as_deref().is_some_and(|s| styles.chain(s).iter().any(|st| st.chr.lang_bidi.is_some()))
}
/// Add `rtl` and, when given, the bidi language `lang` to `c` where it doesn't say them.
fn direct(c: &mut CharProps, lang: Option<&String>) {
c.rtl.get_or_insert(true);
if let Some(l) = lang
&& c.lang_bidi.is_none()
{
c.lang_bidi = Some(l.clone());
}
}
/// `runs` (byte ranges of `text` with their props), split where the bidi level of `text` in a
/// paragraph of direction `rtl_para` changes parity; right-to-left pieces get `rtl: Some(true)`
/// and, unless they have one or `inherits_lang` says it comes from a style, a bidi language.
pub(super) fn direction_spans<'a>(
text: &str,
rtl_para: bool,
runs: impl Iterator<Item = (Range<usize>, &'a CharProps)>,
inherits_lang: impl Fn(&CharProps) -> bool,
) -> Vec<(Range<usize>, Cow<'a, CharProps>)> {
if !wordcraft_doc::bidi::has_rtl(text) {
return runs.map(|(r, p)| (r, Cow::Borrowed(p))).collect();
}
let level = if rtl_para { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() };
let info = unicode_bidi::ParagraphBidiInfo::new(text, Some(level));
let odd = |at: usize| info.levels.get(at).is_some_and(|l| l.is_rtl());
let para_lang = text_lang(text);
let mut out = Vec::new();
for (range, props) in runs {
let Some(run) = text.get(range.clone()).filter(|_| props.rtl.is_none()) else {
out.push((range, Cow::Borrowed(props)));
continue;
};
let named = props.lang_bidi.is_some() || inherits_lang(props);
let piece = |r: Range<usize>, rtl: bool| {
if !rtl {
return (r, Cow::Borrowed(props));
}
let lang = if named {
None
} else {
let own = props.lang.clone().filter(|l| is_rtl_lang(l));
own.or_else(|| text.get(r.clone()).and_then(text_lang).or(para_lang).map(str::to_string))
};
let mut p = props.clone();
direct(&mut p, lang.as_ref());
// A tracked formatting change's "before" copy gets the same, so the change lists
// nothing the author didn't do.
if let Some(ch) = p.fmt_change.as_mut() {
direct(&mut ch.old, lang.as_ref());
}
(r, Cow::Owned(p))
};
let mut start = range.start;
let mut cur: Option<bool> = None;
for (i, _) in run.char_indices() {
let at = range.start.saturating_add(i);
let rtl = odd(at);
match cur {
Some(c) if c != rtl => {
out.push(piece(start..at, c));
start = at;
}
_ => {}
}
cur = Some(rtl);
}
out.push(piece(start..range.end, cur.unwrap_or(false)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_ranges_never_panic() {
let p = CharProps::default();
let text = "עברית WordCraft";
let runs = vec![(0..3, &p), (3..1000, &p), (usize::MAX - 1..usize::MAX, &p), (2..text.len(), &p)];
let out = direction_spans(text, true, runs.into_iter(), |_| false);
assert_eq!(out.len(), 5, "bad ranges pass through unsplit: {out:?}");
}
#[test]
fn languages_by_script() {
assert_eq!(text_lang("(שלום"), Some("he-IL"));
assert_eq!(text_lang("سلام"), Some("ar-SA"));
assert_eq!(text_lang("ܫܠܡܐ"), Some("syr-SY"));
assert_eq!(text_lang(". ,"), None);
assert!(is_rtl_lang("he-IL") && is_rtl_lang("yi") && is_rtl_lang("FA_ir") && !is_rtl_lang("en-US") && !is_rtl_lang(""));
}
}
+1
View File
@@ -1,5 +1,6 @@
//! [`Document`] → DOCX.
mod bidi;
mod embed;
mod glossary;
mod settings_merge;
+10 -1
View File
@@ -247,7 +247,16 @@ impl Writer<'_> {
// Split into pieces with their props.
let mut pieces: Vec<(Piece, &CharProps)> = Vec::new();
let mut k = 0usize;
for (range, props) in p.run_ranges() {
// Runs of mixed direction are split so Word orders them as WordCraft does (#1543).
let rtl_para = self.doc.styles.resolve_para(&p.props).bidi;
let styles = &self.doc.styles;
let para_style = p.props.style.as_deref();
let para_lang = super::bidi::para_inherits_lang_bidi(styles, para_style);
let spans =
super::bidi::direction_spans(&p.text, rtl_para, p.run_ranges(), |c| para_lang || super::bidi::char_style_has_lang_bidi(styles, c));
for (range, props) in &spans {
let props: &CharProps = props;
let range = range.clone();
let base = range.start;
let Some(text) = p.text.get(range) else { continue };
let mut buf = String::new();
+194
View File
@@ -0,0 +1,194 @@
//! Runs of mixed right-to-left and left-to-right text (#1543). WordCraft lays a paragraph out with
//! the Unicode Bidirectional Algorithm (UAX #9) from its text and direction alone, while Word
//! orders a run's characters by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run
//! without it is laid out left to right, so Hebrew words around a Latin name, and the punctuation
//! between them, change places. The writer therefore splits such a run where the resolved
//! direction changes and marks the right-to-left pieces `w:rtl`, as Word does with text typed
//! in a right-to-left paragraph.
use std::io::Read;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
/// `word/document.xml` of a written package.
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
}
/// The runs of the first paragraph of `xml`: (has `w:rtl`, text with `w:br` as `\n`).
fn runs(xml: &str) -> Vec<(bool, String)> {
let body = xml.split("<w:body>").nth(1).unwrap();
let para = body.split("</w:p>").next().unwrap();
para.split("<w:r>")
.skip(1)
.map(|r| {
let r = r.split("</w:r>").next().unwrap();
let rtl = r.contains("<w:rtl/>");
let mut text = String::new();
for part in r.split('<').skip(1) {
if part.starts_with("w:br/>") {
text.push('\n');
} else if part.starts_with("w:t ") || part.starts_with("w:t>") {
text.push_str(part.split_once('>').unwrap().1);
}
}
(rtl, text)
})
.collect()
}
fn arial() -> CharProps {
CharProps { font: Some("Arial".into()), font_cs: Some("Arial".into()), size: Some(16.0), size_cs: Some(16.0), ..Default::default() }
}
fn doc_of(text: &str, rtl_para: bool, props: CharProps) -> Document {
let mut p = Paragraph::with_text(text, props);
if rtl_para {
p.props.bidi = Some(true);
}
let mut d = Document::new();
d.body = vec![para_block(p)];
d
}
fn written_runs(d: &Document) -> Vec<(bool, String)> {
runs(&document_xml(&wordcraft_docx::write(d).unwrap()))
}
fn owned(v: &[(bool, &str)]) -> Vec<(bool, String)> {
v.iter().map(|(r, t)| (*r, (*t).to_string())).collect()
}
#[test]
fn rtl_paragraph_splits_latin_out_of_rtl_runs() {
let d = doc_of("עברית WordCraft 2026 ואחריה עוד עברית.", true, arial());
assert_eq!(written_runs(&d), owned(&[(true, "עברית "), (false, "WordCraft 2026"), (true, " ואחריה עוד עברית.")]));
}
#[test]
fn rtl_paragraph_brackets_email_and_final_stop_take_the_paragraph_direction() {
let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial());
assert_eq!(written_runs(&d), owned(&[(true, "לפני ("), (false, "ABC 123"), (true, ") אחרי; דוא״ל "), (false, "test@example.com"), (true, ".")]));
}
#[test]
fn line_breaks_stay_in_the_split_runs() {
let d = doc_of("שלום עולם.\nעברית WordCraft 2026 ואחריה.", true, arial());
assert_eq!(written_runs(&d), owned(&[(true, "שלום עולם.\nעברית "), (false, "WordCraft 2026"), (true, " ואחריה.")]));
}
#[test]
fn split_runs_keep_their_formatting() {
let d = doc_of("עברית WordCraft עברית", true, arial());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
let body = xml.split("<w:body>").nth(1).unwrap();
assert_eq!(body.matches("<w:r>").count(), 3);
assert_eq!(body.matches(r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial"/>"#).count(), 4, "three runs and the mark: {body}");
assert_eq!(body.matches(r#"<w:szCs w:val="32"/>"#).count(), 4, "{body}");
}
#[test]
fn hebrew_inside_an_ltr_paragraph_is_marked_right_to_left() {
let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default());
assert_eq!(written_runs(&d), owned(&[(false, "English with "), (true, "עברית מעורבת"), (false, " inside.")]));
}
#[test]
fn text_without_rtl_characters_is_one_run() {
let d = doc_of("Plain English, 2026.", true, arial());
assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")]));
let d = doc_of("Plain English, 2026.", false, arial());
assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")]));
}
#[test]
fn explicit_run_direction_is_kept() {
// A run that already says its direction is written as it is, unsplit.
let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(true), ..arial() });
assert_eq!(written_runs(&d), owned(&[(true, "עברית WordCraft")]));
let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(false), ..arial() });
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(xml.contains(r#"<w:rtl w:val="0"/>"#), "{xml}");
assert_eq!(runs(&xml).len(), 1);
}
#[test]
fn mixed_paragraph_round_trips_text_and_direction() {
let text = "לפני (ABC 123) אחרי; דוא״ל test@example.com.\nעברית WordCraft 2026 ואחריה עוד עברית.";
let d = doc_of(text, true, arial());
let bytes = wordcraft_docx::write(&d).unwrap();
let r = wordcraft_docx::read(&bytes).unwrap();
let p = r.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.text, text);
assert_eq!(p.props.bidi, Some(true));
for run in &p.runs {
assert_eq!(run.props.font.as_deref(), Some("Arial"));
assert_eq!(run.props.size, Some(16.0));
}
assert!(p.runs.iter().any(|r| r.props.rtl == Some(true)));
// Saving again writes the same runs: the read-back direction is now explicit.
assert_eq!(written_runs(&r), written_runs(&d));
}
// Word gives right-to-left text without a `w:lang w:bidi` its default complex-script language
// (Arabic), so its status bar, proofing and hyphenation call Hebrew "Arabic". Right-to-left
// pieces the writer marks say their language (§17.3.2.20), unless the run or a style already does.
/// `w:bidi` attribute of each run's `w:lang` in the first paragraph: (has `w:rtl`, bidi language).
fn run_langs(xml: &str) -> Vec<(bool, Option<String>)> {
let body = xml.split("<w:body>").nth(1).unwrap();
let para = body.split("</w:p>").next().unwrap();
para.split("<w:r>")
.skip(1)
.map(|r| {
let r = r.split("</w:r>").next().unwrap();
let lang = r.split("<w:lang ").nth(1).and_then(|l| l.split("w:bidi=\"").nth(1)).map(|v| v.split('"').next().unwrap().to_string());
(r.contains("<w:rtl/>"), lang)
})
.collect()
}
fn langs(d: &Document) -> Vec<(bool, Option<String>)> {
run_langs(&document_xml(&wordcraft_docx::write(d).unwrap()))
}
fn he(rtl: bool) -> (bool, Option<String>) {
(rtl, rtl.then(|| "he-IL".to_string()))
}
#[test]
fn hebrew_pieces_say_they_are_hebrew() {
let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial());
// The final stop has no letters of its own; it takes the paragraph's language.
assert_eq!(langs(&d), vec![he(true), he(false), he(true), he(false), he(true)]);
let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default());
assert_eq!(langs(&d), vec![he(false), he(true), he(false)]);
}
#[test]
fn arabic_pieces_say_they_are_arabic() {
let d = doc_of("مرحبا WordCraft بالعالم", true, arial());
let ar = Some("ar-SA".to_string());
assert_eq!(langs(&d), vec![(true, ar.clone()), (false, None), (true, ar)]);
}
#[test]
fn a_language_the_run_already_has_is_kept() {
let d = doc_of("سلام WordCraft دنیا", true, CharProps { lang_bidi: Some("fa-IR".into()), ..arial() });
let fa = Some("fa-IR".to_string());
assert_eq!(langs(&d), vec![(true, fa.clone()), (false, fa.clone()), (true, fa)]);
// A right-to-left proofing language on the run (w:val) names the right-to-left text too.
let d = doc_of("שלום WordCraft", true, CharProps { lang: Some("yi".into()), ..arial() });
assert_eq!(langs(&d), vec![(true, Some("yi".into())), (false, None)]);
}
#[test]
fn an_inherited_bidi_language_is_not_repeated() {
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
}
@@ -2,7 +2,8 @@
//! Synthetic packages, written here; no external documents or installed fonts are needed.
use std::io::Write;
use wordcraft_doc::{Document, Paragraph};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, Run};
fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec<u8> {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
@@ -29,13 +30,41 @@ fn paragraph(d: &Document) -> &Paragraph {
d.body.iter().find_map(|b| b.as_para()).unwrap()
}
/// `runs` without run direction and bidi language (in the formatting change too), adjacent equal
/// runs merged.
fn undirected(runs: &[Run]) -> Vec<Run> {
let strip = |c: &mut CharProps| {
c.rtl = None;
c.lang_bidi = None;
};
let mut out: Vec<Run> = Vec::new();
for r in runs {
let mut props = r.props.clone();
strip(&mut props);
if let Some(ch) = props.fmt_change.as_mut() {
strip(&mut ch.old);
}
match out.last_mut() {
Some(last) if last.props == props => last.len += r.len,
_ => out.push(Run { len: r.len, props }),
}
}
out
}
fn check_generations(bytes: &[u8], check: impl Fn(&Document)) {
let mut d = wordcraft_docx::read(bytes).unwrap();
for _ in 0..3 {
for generation in 0..3 {
check(&d);
let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(paragraph(&next).text, paragraph(&d).text);
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
if generation == 0 {
// The first save splits the mixed Hebrew/Latin run by direction and names the
// Hebrew's language (#1543); apart from that nothing changes.
assert_eq!(undirected(&paragraph(&next).runs), undirected(&paragraph(&d).runs));
} else {
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
}
assert_eq!(paragraph(&next).props.bidi, Some(true));
d = next;
}
@@ -133,3 +162,39 @@ fn tracked_formatting_keeps_independent_complex_script_values() {
assert_eq!(overlaid.fmt_change, c.fmt_change);
});
}
/// `(w:rtl, w:lang)` elements of a run's current `w:rPr` and of its `w:rPrChange` "before" copy.
fn direction_markup(run: &str) -> (String, String) {
let pick = |rpr: &str| -> String {
rpr.split('<').filter(|t| t.starts_with("w:rtl") || t.starts_with("w:lang")).map(|t| format!("<{t}")).collect::<Vec<_>>().join("")
};
let (now, change) = run.split_once("<w:rPrChange").unwrap_or((run, ""));
(pick(now), pick(change))
}
#[test]
fn split_runs_add_no_phantom_tracked_formatting() {
// #1543: direction and language the writer adds to a piece of a run that has a tracked
// formatting change go into the change's "before" copy too, so Word lists no change the
// author never made ("Formatted: Right-to-left, Hebrew").
let run = r#"<w:sz w:val="24"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/></w:rPr></w:rPrChange>"#;
let d = wordcraft_docx::read(&package(run, "", "", "")).unwrap();
let bytes = wordcraft_docx::write(&d).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap(), &mut xml)
.unwrap();
let runs: Vec<&str> = xml.split("<w:r>").skip(1).map(|r| r.split("</w:r>").next().unwrap()).collect();
assert!(runs.len() >= 2, "the mixed run is split: {xml}");
assert!(runs.iter().any(|r| r.contains("<w:rtl/>")), "{xml}");
for r in runs {
assert!(r.contains("<w:rPrChange"), "every piece keeps the tracked change: {r}");
let (now, before) = direction_markup(r);
assert_eq!(now, before, "no direction or language difference to report: {r}");
}
// And WordCraft reads no difference back either.
let r = wordcraft_docx::read(&bytes).unwrap();
for run in &paragraph(&r).runs {
let old = &run.props.fmt_change.as_ref().unwrap().old;
assert_eq!((old.rtl, old.lang_bidi.as_deref()), (run.props.rtl, run.props.lang_bidi.as_deref()));
}
}
+2 -2
View File
@@ -46,11 +46,11 @@ fn para_props(s: &Session) -> ParaProps {
fn char_props(s: &Session) -> CharProps {
let (a, b) = s.sel.ordered();
if a == b {
return s.typing_props();
return s.caret_props();
}
match s.doc.para_at(&a) {
Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(),
_ => s.typing_props(),
_ => s.caret_props(),
}
}
+4
View File
@@ -290,6 +290,10 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
delete_selection(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
+11 -1
View File
@@ -1354,8 +1354,18 @@ impl Session {
self.sel.focus.story
}
/// Formatting typing would use at the caret.
/// Formatting typing would use at the caret: the caret's, except run direction. New text
/// takes its direction from its own letters, as on screen, so Latin typed or inserted into
/// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces
/// itself (#1543). The bidi language stays (it only applies to complex-script text).
pub fn typing_props(&self) -> CharProps {
let mut c = self.caret_props();
c.rtl = None;
c
}
/// The caret's formatting, as Reveal Formatting shows it.
pub fn caret_props(&self) -> CharProps {
if let Some(p) = &self.pending {
return p.clone();
}
+53
View File
@@ -269,3 +269,56 @@ fn typing_kurdish_in_new_document_advances_caret_smoothly() {
assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall");
assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left");
}
/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text).
fn saved_runs(s: &Session) -> Vec<(bool, String)> {
use std::io::Read;
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let mut xml = String::new();
zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let para = xml.split("<w:body>").nth(1).unwrap().split("</w:p>").next().unwrap().to_string();
para.split("<w:r>")
.skip(1)
.map(|r| {
let r = r.split("</w:r>").next().unwrap();
let text: String = r.split("<w:t xml:space=\"preserve\">").skip(1).filter_map(|t| t.split("</w:t>").next()).collect();
(r.contains("<w:rtl/>"), text)
})
.collect()
}
#[test]
fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() {
// #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening
// takes its direction from its own letters, as on screen, so it's saved in a run of its own
// without `w:rtl` (Word would otherwise lay it out right to left again).
let s = session("לפני אחרי", true);
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap());
assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true)));
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len()));
run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "}));
let runs = saved_runs(&s);
assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}");
assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}");
assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::<String>(), "לפני Foo Bar 2026 אחרי");
}
#[test]
fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() {
// Other insertions start from the caret's formatting too (#1543): a link's Latin text
// inserted between reopened Hebrew words isn't saved as right-to-left.
let s = session("לפני אחרי", true);
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap());
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len()));
run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"}));
let runs = saved_runs(&s);
assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}");
// The caret's own formatting, as Reveal Formatting shows it, still says right to left.
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len()));
s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting
assert_eq!(s.caret_props().rtl, Some(true));
assert_eq!(s.typing_props().rtl, None);
assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays");
}
+5 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (`w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -35,7 +35,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| Format | Weight | Word | WordCraft read | WordCraft write | Fidelity | Hours | How it's tested | Notes |
|---|---|---|---|---|---|---|---|---|
| `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read |
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). |
@@ -65,6 +65,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Feature | Read | Write | Notes |
|---|---|---|---|
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
@@ -330,3 +332,4 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-12 | trivial | Charts of types WordCraft doesn't draw (radar, stock…) show a placeholder with their title and type (#998) |
| 2026-10-13 | trivial | DOCX styles without any `w:qFormat` (LibreOffice) get Word's usual Styles gallery: matching built-in styles are marked quick and missing quick built-ins added (#1238) |
| 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too |
| 2026-10-15 | trivial | DOCX: runs mixing right-to-left and left-to-right text are split by direction on save; right-to-left pieces get `w:rtl` and `w:lang w:bidi`, so Word shows the order WordCraft shows (#1543) |
+9 -1
View File
@@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@@ -194,6 +194,13 @@ Word.
right), as in Word, so Tab moves to the cell on the left (the next one in reading order) and
Shift+Tab back; in a left-to-right table Tab still moves right (#2634,
`tab_in_a_table_inserted_in_right_to_left_text_moves_left`).
- Text typed or inserted (links, fields, symbols, pasted text with merged formatting) takes its
direction from its own letters, not from the run around the caret, keeping only that run's
bidi language: Latin typed into Hebrew from a reopened file is saved as left-to-right text, and
the saved file marks the right-to-left pieces `w:rtl` itself (#1543,
`latin_typed_into_reopened_hebrew_is_saved_left_to_right`). Known display difference: spaces,
digits and punctuation typed into reopened Hebrew text draw in the Latin font, not the
complex-script font, until the file is saved and reopened.
## Layout
@@ -299,3 +306,4 @@ Word.
| 2026-10-10 | trivial | Status line, revision history and cross-links added (progress-docs standard) |
| 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) |
| 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) |
| 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) |