mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
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:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
82bf806118
commit
c9f3da2fc5
Generated
+1
@@ -5223,6 +5223,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zeroize",
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
//! [`Document`] → DOCX.
|
||||
|
||||
mod bidi;
|
||||
mod embed;
|
||||
mod glossary;
|
||||
mod settings_merge;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¶graph(&next).runs), undirected(¶graph(&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 ¶graph(&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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user