mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 05:17:25 -06:00
ODT export: sections are saved (gap 19) (#1777)
* odt-sect-export: WIP * Gap 19: ODT export writes sections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gap 19: ODT export writes sections — review fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT export: a list cut by a section continues with text:continue-list; drop duplicate parity rows Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
parent
4a251d4b9c
commit
da607a00ee
+4
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
**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 (Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously 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: progress log: `w:eastAsianLayout` kept on save; 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
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously 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: progress log: `w:eastAsianLayout` kept on save; 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 (ODT export writes sections (gap 19); previously 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.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -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-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
|
||||
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
|
||||
@@ -460,6 +461,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
|
||||
| 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
|
||||
@@ -930,6 +930,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {
|
||||
let (ins, del) = rev_marks(doc, &p.mark);
|
||||
(out.mark_ins, out.mark_del) = (ins.map(Box::new), del.map(Box::new));
|
||||
}
|
||||
// The paragraph that ends a section carries its properties (the last piece of it does).
|
||||
out.section.clone_from(&p.section);
|
||||
done.push(out);
|
||||
done
|
||||
}
|
||||
|
||||
+313
-25
@@ -78,16 +78,31 @@ fn pt(v: f32) -> String {
|
||||
format!("{}pt", s.trim_end_matches('0').trim_end_matches('.'))
|
||||
}
|
||||
|
||||
/// A section that starts a page: the master page it uses and where page numbering restarts.
|
||||
type SecStart = (String, Option<u32>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Writer {
|
||||
/// Automatic text styles: properties XML → name.
|
||||
text_styles: Vec<(String, String)>,
|
||||
/// Automatic paragraph styles: (parent, properties XML) → name.
|
||||
para_styles: Vec<((String, String), String)>,
|
||||
/// Automatic paragraph styles: (parent, properties XML, master page) → name.
|
||||
para_styles: Vec<((String, String, String), String)>,
|
||||
/// List styles: level kinds.
|
||||
list_styles: Vec<[bool; 9]>,
|
||||
/// Table styles: (name, column widths).
|
||||
tables: Vec<Vec<f32>>,
|
||||
/// The master page (and page-number restart) each table in `tables` starts.
|
||||
table_starts: Vec<Option<SecStart>>,
|
||||
/// Automatic styles of the columned `text:section`s written.
|
||||
section_styles: Vec<String>,
|
||||
/// The page a section starts, for the next top-level block.
|
||||
pending_start: Option<SecStart>,
|
||||
/// The next top-level list continues the one that ended the previous section.
|
||||
continue_list: bool,
|
||||
/// `xml:id` of the last top-level list written, for `text:continue-list`.
|
||||
last_list: Option<String>,
|
||||
/// The page the block being written starts.
|
||||
master: Option<SecStart>,
|
||||
/// Cell styles by shading.
|
||||
cell_styles: Vec<Option<Rgb>>,
|
||||
pictures: Vec<(String, std::sync::Arc<Vec<u8>>, String)>,
|
||||
@@ -169,6 +184,12 @@ impl Writer {
|
||||
}
|
||||
|
||||
fn para_style(&mut self, p: &Para) -> String {
|
||||
self.para_style_at(p, None)
|
||||
}
|
||||
|
||||
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
|
||||
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
|
||||
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
|
||||
let parent = parent_style(p.kind).to_string();
|
||||
let mut props = String::new();
|
||||
match p.align {
|
||||
@@ -178,16 +199,21 @@ impl Writer {
|
||||
Some(Align::Left) => props.push_str(" fo:text-align=\"start\""),
|
||||
None => {}
|
||||
}
|
||||
if p.page_break {
|
||||
// The master page starts the page itself: no page break besides.
|
||||
if p.page_break && start.is_none() {
|
||||
props.push_str(" fo:break-before=\"page\"");
|
||||
}
|
||||
if let Some(n) = start.and_then(|(_, n)| *n) {
|
||||
props.push_str(&format!(" style:page-number=\"{n}\""));
|
||||
}
|
||||
if p.kind == Kind::Rule {
|
||||
props.push_str(" fo:border-bottom=\"0.75pt solid #a0a0a0\" fo:padding-bottom=\"1pt\"");
|
||||
}
|
||||
if props.is_empty() {
|
||||
let master = start.map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
if props.is_empty() && master.is_empty() {
|
||||
return parent;
|
||||
}
|
||||
let key = (parent, props);
|
||||
let key = (parent, props, master);
|
||||
if let Some((_, n)) = self.para_styles.iter().find(|(k, _)| *k == key) {
|
||||
return n.clone();
|
||||
}
|
||||
@@ -401,9 +427,10 @@ impl Writer {
|
||||
/// is the first).
|
||||
fn para_in(&mut self, p: &Para, chain: Option<&Vec<Para>>, out: &mut String) {
|
||||
let paras: &[Para] = chain.map(|c| c.as_slice()).unwrap_or(std::slice::from_ref(p));
|
||||
let start = self.master.take();
|
||||
self.chain = chain.map(|c| c.iter().map(|q| self.para_style(q)).collect()).unwrap_or_default();
|
||||
self.part = 0;
|
||||
let style = self.para_style(p);
|
||||
let style = self.para_style_at(p, start.as_ref());
|
||||
self.cur_style = style.clone();
|
||||
let tag = match p.kind {
|
||||
Kind::Heading(n) => {
|
||||
@@ -447,6 +474,10 @@ impl Writer {
|
||||
let chain = |i: usize| chains.and_then(|c| c.get(&i));
|
||||
let mut i = 0;
|
||||
while let Some(b) = blocks.get(i) {
|
||||
// Only the first top-level block of a section starts its page.
|
||||
if depth == 0 {
|
||||
self.master = self.pending_start.take();
|
||||
}
|
||||
match b {
|
||||
FBlock::Table(t) => {
|
||||
if depth < model::MAX_DEPTH {
|
||||
@@ -494,7 +525,20 @@ impl Writer {
|
||||
*top = true;
|
||||
}
|
||||
if stack.is_empty() {
|
||||
out.push_str(&format!("<text:list text:style-name=\"{lname}\">"));
|
||||
let id = format!("lst{}", self.list_styles.len());
|
||||
// A list cut by a section boundary goes on numbering: continue-list names the
|
||||
// preceding list by its xml:id, whatever its style name (ODF 1.3 §19.743).
|
||||
let prev = self.last_list.clone();
|
||||
let cont = match prev {
|
||||
Some(prev) if depth == 0 && start == 0 && std::mem::take(&mut self.continue_list) => {
|
||||
format!(" text:continue-list=\"{prev}\"")
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if depth == 0 {
|
||||
self.last_list = Some(id.clone());
|
||||
}
|
||||
out.push_str(&format!("<text:list xml:id=\"{id}\" text:style-name=\"{lname}\"{cont}>"));
|
||||
} else {
|
||||
out.push_str("<text:list>");
|
||||
}
|
||||
@@ -546,6 +590,7 @@ impl Writer {
|
||||
vec![468.0 / cols as f32; cols]
|
||||
};
|
||||
self.tables.push(widths);
|
||||
self.table_starts.push(self.master.take());
|
||||
let tn = self.tables.len();
|
||||
out.push_str(&format!("<table:table table:name=\"Table{tn}\" table:style-name=\"Table{tn}\">"));
|
||||
for c in 0..cols {
|
||||
@@ -602,9 +647,10 @@ impl Writer {
|
||||
for (props, n) in &self.text_styles {
|
||||
s.push_str(&format!("<style:style style:name=\"{n}\" style:family=\"text\"><style:text-properties{props}/></style:style>"));
|
||||
}
|
||||
for ((parent, props), n) in &self.para_styles {
|
||||
for ((parent, props, master), n) in &self.para_styles {
|
||||
let master = if master.is_empty() { String::new() } else { format!(" style:master-page-name=\"{}\"", x(master)) };
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{n}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{props}/></style:style>"
|
||||
"<style:style style:name=\"{n}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{master}><style:paragraph-properties{props}/></style:style>"
|
||||
));
|
||||
}
|
||||
for (i, kinds) in self.list_styles.iter().enumerate() {
|
||||
@@ -631,8 +677,14 @@ impl Writer {
|
||||
for (i, widths) in self.tables.iter().enumerate() {
|
||||
let tn = i + 1;
|
||||
let total: f32 = widths.iter().sum();
|
||||
let (master, number) = match self.table_starts.get(i).and_then(Option::as_ref) {
|
||||
Some((m, n)) => {
|
||||
(format!(" style:master-page-name=\"{}\"", x(m)), n.map(|n| format!(" style:page-number=\"{n}\"")).unwrap_or_default())
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"Table{tn}\" style:family=\"table\"><style:table-properties style:width=\"{}\" table:align=\"margins\"/></style:style>",
|
||||
"<style:style style:name=\"Table{tn}\" style:family=\"table\"{master}><style:table-properties style:width=\"{}\" table:align=\"margins\"{number}/></style:style>",
|
||||
pt(total)
|
||||
));
|
||||
for (c, w) in widths.iter().enumerate() {
|
||||
@@ -650,6 +702,9 @@ impl Writer {
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
for st in &self.section_styles {
|
||||
s.push_str(st);
|
||||
}
|
||||
s.push_str("</office:automatic-styles>");
|
||||
s
|
||||
}
|
||||
@@ -761,7 +816,15 @@ fn now_unix() -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn styles_xml(sec: &wordcraft_doc::SectionProps, body_font: &str) -> String {
|
||||
/// `style:columns` (ODF 1.3 §17.12) of equal columns with their gap and, when on, the line between.
|
||||
fn columns_xml(c: &Columns) -> String {
|
||||
let sep = if c.separator { "<style:column-sep style:width=\"0.5pt\" style:style=\"solid\" style:color=\"#000000\"/>" } else { "" };
|
||||
format!("<style:columns fo:column-count=\"{}\" fo:column-gap=\"{}\">{sep}</style:columns>", c.count.clamp(1, 45), pt(c.space))
|
||||
}
|
||||
|
||||
/// One master page per entry of `pages` (name, setup), each with its own page layout `pm1`, `pm2`…
|
||||
/// (§16.5, §16.9); the first is the default one, "Standard".
|
||||
fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str) -> String {
|
||||
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
|
||||
s.push_str(&format!(
|
||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties fo:margin-bottom=\"8pt\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"12pt\"/></style:default-style>",
|
||||
@@ -781,17 +844,26 @@ fn styles_xml(sec: &wordcraft_doc::SectionProps, body_font: &str) -> String {
|
||||
s.push_str("<style:style style:name=\"Quotations\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:class=\"html\"><style:paragraph-properties fo:margin-left=\"36pt\" fo:margin-right=\"36pt\"/><style:text-properties fo:font-style=\"italic\" fo:color=\"#404040\"/></style:style>");
|
||||
s.push_str(&format!("<style:style style:name=\"Preformatted_20_Text\" style:display-name=\"Preformatted Text\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:class=\"html\"><style:paragraph-properties fo:margin-top=\"0pt\" fo:margin-bottom=\"0pt\"/><style:text-properties fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\" fo:font-size=\"10pt\"/></style:style>", model::MONO_FONT));
|
||||
s.push_str("</office:styles><office:automatic-styles>");
|
||||
s.push_str(&format!(
|
||||
"<style:page-layout style:name=\"pm1\"><style:page-layout-properties fo:page-width=\"{}\" fo:page-height=\"{}\" fo:margin-top=\"{}\" fo:margin-bottom=\"{}\" fo:margin-left=\"{}\" fo:margin-right=\"{}\" style:print-orientation=\"{}\"/></style:page-layout>",
|
||||
pt(sec.page_w),
|
||||
pt(sec.page_h),
|
||||
pt(sec.margin_top),
|
||||
pt(sec.margin_bottom),
|
||||
pt(sec.margin_left),
|
||||
pt(sec.margin_right),
|
||||
if sec.page_w > sec.page_h { "landscape" } else { "portrait" }
|
||||
));
|
||||
s.push_str("</office:automatic-styles><office:master-styles><style:master-page style:name=\"Standard\" style:page-layout-name=\"pm1\"/></office:master-styles></office:document-styles>\n");
|
||||
for (i, (_, sec)) in pages.iter().enumerate() {
|
||||
// A page of several columns carries them in its layout.
|
||||
let cols = if sec.columns.count > 1 { columns_xml(&sec.columns) } else { String::new() };
|
||||
s.push_str(&format!(
|
||||
"<style:page-layout style:name=\"pm{}\"><style:page-layout-properties fo:page-width=\"{}\" fo:page-height=\"{}\" fo:margin-top=\"{}\" fo:margin-bottom=\"{}\" fo:margin-left=\"{}\" fo:margin-right=\"{}\" style:print-orientation=\"{}\">{cols}</style:page-layout-properties></style:page-layout>",
|
||||
i + 1,
|
||||
pt(sec.page_w),
|
||||
pt(sec.page_h),
|
||||
pt(sec.margin_top),
|
||||
pt(sec.margin_bottom),
|
||||
pt(sec.margin_left),
|
||||
pt(sec.margin_right),
|
||||
if sec.page_w > sec.page_h { "landscape" } else { "portrait" }
|
||||
));
|
||||
}
|
||||
s.push_str("</office:automatic-styles><office:master-styles>");
|
||||
for (i, (name, _)) in pages.iter().enumerate() {
|
||||
s.push_str(&format!("<style:master-page style:name=\"{}\" style:page-layout-name=\"pm{}\"/>", x(name), i + 1));
|
||||
}
|
||||
s.push_str("</office:master-styles></office:document-styles>\n");
|
||||
s
|
||||
}
|
||||
|
||||
@@ -820,12 +892,73 @@ fn meta_xml(m: &Meta) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// The sections of a flow: each one's setup and the top-level blocks it holds. A paragraph that
|
||||
/// carries a section's properties ends it; the document's last section follows the last such
|
||||
/// paragraph (and is left out when nothing follows it, unless it is the only one).
|
||||
fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, std::ops::Range<usize>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
if let FBlock::Para(p) = b
|
||||
&& let Some(s) = &p.section
|
||||
{
|
||||
out.push(((**s).clone(), from..i + 1));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
if from < blocks.len() || out.is_empty() {
|
||||
out.push((last.clone(), from..blocks.len()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write an `.odt` package.
|
||||
pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let flow = model::from_doc_tracked(doc);
|
||||
let mut w = Writer::default();
|
||||
let mut body = String::new();
|
||||
w.blocks(&flow.blocks, &mut body, 0);
|
||||
let sections = flow_sections(&flow.blocks, &doc.last_section);
|
||||
// One master page per section that starts a page; the first one is "Standard".
|
||||
let mut pages: Vec<(String, SectionProps)> = Vec::new();
|
||||
// The columns of the page in force: a continuous section with others is a `text:section`.
|
||||
let mut page_cols = Columns::default();
|
||||
for (k, (sec, range)) in sections.iter().enumerate() {
|
||||
let new_page = k == 0 || sec.start != SectionStart::Continuous;
|
||||
if new_page {
|
||||
let name = if k == 0 { "Standard".to_string() } else { format!("Page{}", pages.len() + 1) };
|
||||
pages.push((name.clone(), sec.clone()));
|
||||
page_cols = sec.columns.clone();
|
||||
// The first section needs its paragraph to name the page only to restart numbering.
|
||||
if k > 0 || sec.page_num_start.is_some() {
|
||||
w.pending_start = Some((name, sec.page_num_start));
|
||||
}
|
||||
}
|
||||
// A continuous section whose columns differ from the page's is a `text:section` (§5.4)
|
||||
// with them (one column included). A restart of page numbering isn't written for it: ODF
|
||||
// applies `style:page-number` only together with a master page.
|
||||
let wrapped = k > 0
|
||||
&& sec.start == SectionStart::Continuous
|
||||
&& (sec.columns.count != page_cols.count || (sec.columns.count > 1 && sec.columns != page_cols));
|
||||
// A list cut at the boundary goes on numbering in the new section.
|
||||
let list_at = |b: Option<&FBlock>| matches!(b, Some(FBlock::Para(p)) if p.list.is_some());
|
||||
w.continue_list = k > 0 && list_at(flow.blocks.get(range.start)) && list_at(range.start.checked_sub(1).and_then(|i| flow.blocks.get(i)));
|
||||
if wrapped {
|
||||
w.close_ins(&mut body);
|
||||
let n = w.section_styles.len() + 1;
|
||||
w.section_styles.push(format!(
|
||||
"<style:style style:name=\"Sect{n}\" style:family=\"section\"><style:section-properties>{}</style:section-properties></style:style>",
|
||||
columns_xml(&sec.columns)
|
||||
));
|
||||
body.push_str(&format!("<text:section text:style-name=\"Sect{n}\" text:name=\"Section{n}\">"));
|
||||
}
|
||||
w.blocks(flow.blocks.get(range.clone()).unwrap_or(&[]), &mut body, 0);
|
||||
w.pending_start = None;
|
||||
w.continue_list = false;
|
||||
if wrapped {
|
||||
w.close_ins(&mut body);
|
||||
body.push_str("</text:section>\n");
|
||||
}
|
||||
}
|
||||
w.close_ins(&mut body);
|
||||
let tracked = if w.regions.is_empty() && !doc.settings.track_changes {
|
||||
String::new()
|
||||
@@ -837,7 +970,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
w.automatic_styles()
|
||||
);
|
||||
let body_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
|
||||
let styles = styles_xml(&doc.last_section, &body_font);
|
||||
let styles = styles_xml(&pages, &body_font);
|
||||
let meta = meta_xml(&flow.meta);
|
||||
let mut manifest = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.3\">\n<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>\n<manifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"styles.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"meta.xml\" manifest:media-type=\"text/xml\"/>\n",
|
||||
@@ -1769,7 +1902,11 @@ impl Body<'_> {
|
||||
self.end_para();
|
||||
if self.cols_at.is_none()
|
||||
&& self.in_body()
|
||||
&& let Some(columns) = get(a, "text:style-name").and_then(|s| self.styles.columns(s)).filter(|c| c.count > 1)
|
||||
&& let Some(columns) = get(a, "text:style-name")
|
||||
.and_then(|s| self.styles.columns(s))
|
||||
// A section whose columns match the open section's (a plain one-column
|
||||
// section in a one-column page) is no new section.
|
||||
.filter(|c| c.count != self.sec.columns.count || (c.count > 1 && *c != self.sec.columns))
|
||||
{
|
||||
let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, columns, ..self.page.clone() };
|
||||
self.break_section(next);
|
||||
@@ -2646,6 +2783,157 @@ mod tests {
|
||||
assert_eq!(text(secs[3].0), "Last line");
|
||||
}
|
||||
|
||||
/// (start, landscape, width, height, columns, restart, margins) of each section, and the
|
||||
/// text of the paragraph ending it.
|
||||
fn section_summary(doc: &Document) -> Vec<(wordcraft_doc::section::SectionStart, bool, f32, f32, u32, bool, Option<u32>, [f32; 4], String)> {
|
||||
doc.sections()
|
||||
.iter()
|
||||
.map(|(end, s)| {
|
||||
let text = match doc.body.get(*end).map(|b| &**b) {
|
||||
Some(wordcraft_doc::Block::Para(p)) => p.plain_text().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(
|
||||
s.start,
|
||||
s.landscape,
|
||||
s.page_w.round(),
|
||||
s.page_h.round(),
|
||||
s.columns.count,
|
||||
s.columns.separator,
|
||||
s.page_num_start,
|
||||
[s.margin_top, s.margin_bottom, s.margin_left, s.margin_right].map(f32::round),
|
||||
text,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_sections_that_import_back() {
|
||||
// Portrait; landscape on a new page restarting at 1; continuous two columns with a
|
||||
// separator; portrait again. Export, import: the same sections.
|
||||
let doc = import(§ions_package()).unwrap();
|
||||
let before = section_summary(&doc);
|
||||
assert_eq!(before.len(), 4, "{before:?}");
|
||||
let back = import(&export(&doc).unwrap()).unwrap();
|
||||
let after = section_summary(&back);
|
||||
assert_eq!(after, before);
|
||||
let (a, b) = (&doc.sections(), &back.sections());
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
assert!((x.1.columns.space - y.1.columns.space).abs() < 0.1, "{:?} vs {:?}", x.1.columns, y.1.columns);
|
||||
assert!((x.1.margin_top - y.1.margin_top).abs() < 0.1 && (x.1.margin_right - y.1.margin_right).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_starts_a_section_at_a_table_and_keeps_its_restart() {
|
||||
// A section that starts with a table names its master page on the table's style.
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pU"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout><style:page-layout style:name="pW"><style:page-layout-properties fo:page-width="29.7cm" fo:page-height="21cm" style:print-orientation="landscape"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Upright" style:page-layout-name="pU"/><style:master-page style:name="Wide" style:page-layout-name="pW"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="WideTable" style:family="table" style:master-page-name="Wide"><style:table-properties style:page-number="5"/></style:style></office:automatic-styles><office:body><office:text><text:p>Intro</text:p><table:table table:style-name="WideTable"><table:table-column/><table:table-row><table:table-cell><text:p>Cell</text:p></table:table-cell></table:table-row></table:table><text:p>After</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&styles))).unwrap();
|
||||
let before = section_summary(&doc);
|
||||
assert_eq!(before.len(), 2, "{before:?}");
|
||||
let back = import(&export(&doc).unwrap()).unwrap();
|
||||
assert_eq!(section_summary(&back), before);
|
||||
assert_eq!(back.sections()[1].1.page_num_start, Some(5));
|
||||
}
|
||||
|
||||
/// Master pages `Standard` (portrait) and `Cols` (landscape, two columns with a line) for the
|
||||
/// round-trip tests below.
|
||||
fn cols_styles() -> String {
|
||||
format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pU"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2.2cm" fo:margin-left="2.5cm" fo:margin-right="2.6cm"/></style:page-layout><style:page-layout style:name="pC"><style:page-layout-properties fo:page-width="29.7cm" fo:page-height="21cm" style:print-orientation="landscape" fo:margin-top="1cm" fo:margin-bottom="1.1cm" fo:margin-left="1.2cm" fo:margin-right="1.3cm"><style:columns fo:column-count="2" fo:column-gap="1cm"><style:column-sep style:width="0.02cm" style:style="solid"/></style:columns></style:page-layout-properties></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pU"/><style:master-page style:name="Cols" style:page-layout-name="pC"/></office:master-styles></office:document-styles>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Import `body` (with automatic `styles`) over [`cols_styles`], export, import: both summaries.
|
||||
fn round_trip(styles: &str, body: &str) -> (Vec<String>, Vec<String>) {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&cols_styles()))).unwrap();
|
||||
let back = import(&export(&doc).unwrap()).unwrap();
|
||||
let show = |d: &Document| section_summary(d).iter().map(|s| format!("{s:?}")).collect::<Vec<_>>();
|
||||
(show(&doc), show(&back))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_a_columned_page_and_the_one_column_part_after_it() {
|
||||
let styles = r#"<style:style style:name="PC" style:family="paragraph" style:master-page-name="Cols"/><style:style style:name="One" style:family="section"><style:section-properties><style:columns fo:column-count="1" fo:column-gap="0cm"/></style:section-properties></style:style>"#;
|
||||
let body = r#"<text:p>Intro</text:p><text:p text:style-name="PC">Two columns</text:p><text:section text:name="S" text:style-name="One"><text:p>One column</text:p></text:section>"#;
|
||||
let (before, after) = round_trip(styles, body);
|
||||
assert_eq!(before.len(), 3, "{before:?}");
|
||||
assert!(before[1].contains(", 2, true,") && before[2].contains("Continuous") && before[2].contains(", 1, false,"), "{before:?}");
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_one_column_text_sections_do_not_make_sections() {
|
||||
let styles = r#"<style:style style:name="One" style:family="section"><style:section-properties><style:columns fo:column-count="1" fo:column-gap="0cm"/></style:section-properties></style:style>"#;
|
||||
let body = r#"<text:p>A</text:p><text:section text:name="S" text:style-name="One"><text:p>B</text:p></text:section><text:section text:name="T"><text:p>C</text:p></text:section>"#;
|
||||
let (before, after) = round_trip(styles, body);
|
||||
assert_eq!(before.len(), 1, "{before:?}");
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_a_section_that_starts_on_a_list_item() {
|
||||
let styles = r#"<style:style style:name="PC" style:family="paragraph" style:master-page-name="Cols"/>"#;
|
||||
let body = r#"<text:p>Intro</text:p><text:list><text:list-item><text:p text:style-name="PC">First</text:p></text:list-item><text:list-item><text:p>Second</text:p></text:list-item></text:list><text:p>After</text:p>"#;
|
||||
let (before, after) = round_trip(styles, body);
|
||||
assert_eq!(before.len(), 2, "{before:?}");
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_cut_at_a_section_boundary_goes_on_numbering() {
|
||||
let styles = r#"<style:style style:name="PC" style:family="paragraph" style:master-page-name="Cols"/>"#;
|
||||
let body = r#"<text:list><text:list-item><text:p>One</text:p></text:list-item><text:list-item><text:p text:style-name="PC">Two</text:p></text:list-item></text:list>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&cols_styles()))).unwrap();
|
||||
let bytes = export(&doc).unwrap();
|
||||
let xml = String::from_utf8(read_entry(&mut archive(&bytes), "content.xml").unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("text:continue-list=\"").count(), 1, "{xml}");
|
||||
// The continuation points at the first list's xml:id.
|
||||
assert!(xml.contains("xml:id=\"lst1\"") && xml.contains("text:continue-list=\"lst1\""), "{xml}");
|
||||
assert_eq!(section_summary(&import(&bytes).unwrap()), section_summary(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_a_page_number_restart_on_the_first_section() {
|
||||
let styles = r#"<style:style style:name="PS" style:family="paragraph" style:master-page-name="Standard"><style:paragraph-properties style:page-number="7"/></style:style>"#;
|
||||
let body = r#"<text:p text:style-name="PS">First</text:p><text:p>Second</text:p>"#;
|
||||
let (before, after) = round_trip(styles, body);
|
||||
assert_eq!(before.len(), 1, "{before:?}");
|
||||
assert!(before[0].contains("Some(7)"), "{before:?}");
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_of_a_one_section_document_stays_simple() {
|
||||
let doc = import(§ions_package()).unwrap();
|
||||
let one = import(&export(&doc).unwrap()).unwrap();
|
||||
let mut single = one.clone();
|
||||
for b in single.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
p.section = None;
|
||||
}
|
||||
}
|
||||
let bytes = export(&single).unwrap();
|
||||
let mut z = archive(&bytes);
|
||||
let styles = String::from_utf8(read_entry(&mut z, "styles.xml").unwrap()).unwrap();
|
||||
let content = String::from_utf8(read_entry(&mut z, "content.xml").unwrap()).unwrap();
|
||||
assert_eq!(styles.matches("<style:master-page ").count(), 1);
|
||||
assert!(styles.contains("style:name=\"Standard\" style:page-layout-name=\"pm1\""));
|
||||
assert!(!content.contains("text:section") && !content.contains("master-page-name"), "{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_master_page_stays_one_section() {
|
||||
let styles = format!(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously 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
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously 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;
|
||||
@@ -41,8 +41,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.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). |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified) |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
@@ -223,6 +223,7 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | ODT export writes sections: each new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is a columned `text:section` (gap 19) |
|
||||
| 2026-10-17 | trivial | Paste: an HTML `<blockquote>` (LibreOffice's both-sides indent) pastes as a Normal paragraph with its margins as indents, not the centred italic Quote style; opening .html unchanged (#1270) |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -41,7 +41,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export left; ODT export done), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
@@ -94,6 +94,7 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain |
|
||||
| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | #19 narrowed: ODT export done (master page and page layout per section, columned `text:section`s) |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
|
||||
Reference in New Issue
Block a user