mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
* #1379: WIP * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1379: master pages on table styles start sections too; spec tidy-ups Co-Authored-By: Claude Opus 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> 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
3d2002b699
commit
b1e8ae07e7
+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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: continued paragraphs wrap around the next column's shapes, #1374; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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.
|
||||
|
||||
@@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
|
||||
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
|
||||
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
|
||||
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
|
||||
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
|
||||
@@ -323,6 +324,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
|
||||
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
|
||||
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
|
||||
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
|
||||
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
|
||||
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! #1379: an ODT file with several page styles and a column section converts to DOCX with one
|
||||
//! `w:sectPr` per section. The ODT is original and synthetic, built from ODF 1.3: paragraphs
|
||||
//! whose automatic styles name a master page (`style:master-page-name`), one restarting page
|
||||
//! numbering (`style:page-number`), and a two-column `text:section`.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_engine::io;
|
||||
|
||||
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
|
||||
fn odt() -> Vec<u8> {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS}><office:automatic-styles>
|
||||
<style:page-layout style:name="pU"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" style:print-orientation="portrait" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></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" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></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"/>
|
||||
<style:master-page style:name="Back" style:page-layout-name="pU"/>
|
||||
</office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS}><office:automatic-styles>
|
||||
<style:style style:name="PWide" style:family="paragraph" style:master-page-name="Wide"><style:paragraph-properties fo:break-before="page" style:page-number="1"/></style:style>
|
||||
<style:style style:name="PBack" style:family="paragraph" style:master-page-name="Back"><style:paragraph-properties fo:break-before="page" style:page-number="1"/></style:style>
|
||||
<style:style style:name="Cols" style:family="section"><style:section-properties><style:columns fo:column-count="2" fo:column-gap="1.25cm"><style:column-sep style:width="0.02cm"/></style:columns></style:section-properties></style:style>
|
||||
</office:automatic-styles><office:body><office:text>
|
||||
<text:p>Annual summary</text:p><text:p>Opening text of the first part.</text:p>
|
||||
<text:p text:style-name="PWide">Volumes table</text:p>
|
||||
<table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell><text:p>A</text:p></table:table-cell><table:table-cell><text:p>B</text:p></table:table-cell></table:table-row></table:table>
|
||||
<text:section text:name="S" text:style-name="Cols"><text:p>Column text one.</text:p><text:p>Column text two.</text:p></text:section>
|
||||
<text:p text:style-name="PBack">Closing words</text:p><text:p>Last line.</text:p>
|
||||
</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", &content), ("styles.xml", &styles)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odt_sections_convert_to_one_docx_section_each() {
|
||||
let doc = io::open_bytes("sections.odt", &odt()).unwrap();
|
||||
assert_eq!(doc.sections().len(), 4);
|
||||
let xml = document_xml(&io::save_bytes("sections.docx", &doc).unwrap());
|
||||
let secs: Vec<&str> = xml.split("<w:sectPr").skip(1).map(|s| s.split("</w:sectPr>").next().unwrap_or("")).collect();
|
||||
assert_eq!(secs.len(), 4, "{xml}");
|
||||
let orient: Vec<bool> = secs.iter().map(|s| s.contains(r#"w:orient="landscape""#)).collect();
|
||||
assert_eq!(orient, [false, true, true, false]);
|
||||
// A4 both ways.
|
||||
assert!(secs[0].contains(r#"w:w="11906""#) && secs[0].contains(r#"w:h="16838""#), "{}", secs[0]);
|
||||
assert!(secs[1].contains(r#"w:w="16838""#) && secs[1].contains(r#"w:h="11906""#), "{}", secs[1]);
|
||||
// Section 3: continuous, two columns with a line between; the others one column.
|
||||
assert!(secs[2].contains(r#"<w:type w:val="continuous""#), "{}", secs[2]);
|
||||
assert!(secs[2].contains(r#"w:num="2""#) && secs[2].contains(r#"w:sep="1""#), "{}", secs[2]);
|
||||
for s in [secs[0], secs[1], secs[3]] {
|
||||
assert!(!s.contains(r#"w:num="2""#) && !s.contains("continuous"), "{s}");
|
||||
}
|
||||
// Numbering restarts at 1 in sections 2 and 4 only.
|
||||
let restarts: Vec<bool> = secs.iter().map(|s| s.contains(r#"w:start="1""#)).collect();
|
||||
assert_eq!(restarts, [false, true, false, true]);
|
||||
}
|
||||
+378
-19
@@ -16,7 +16,8 @@ use std::io::{Cursor, Read, Write};
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
use wordcraft_doc::section::{Columns, SectionStart};
|
||||
use wordcraft_doc::{Align, Document, Rgb, SectionProps};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img, mime_of};
|
||||
|
||||
@@ -24,6 +25,8 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo
|
||||
const MAX_ENTRY: u64 = 256 << 20;
|
||||
/// Deepest element nesting handled in content.xml.
|
||||
const MAX_XML_DEPTH: usize = 512;
|
||||
/// Most sections an import makes (page-style changes and column sections); later ones are ignored.
|
||||
const MAX_SECTIONS: usize = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
@@ -633,6 +636,32 @@ struct StyleInfo {
|
||||
border_bottom: bool,
|
||||
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
||||
cell_background: Option<Rgb>,
|
||||
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
|
||||
/// page of that master page. Empty: explicitly none, which stops the lookup up the chain.
|
||||
master_page: Option<String>,
|
||||
/// `style:page-number` of a paragraph or table style (applied only with a master page): `Some(Some(n))` restarts page numbering at n,
|
||||
/// `Some(None)` ("auto") continues it.
|
||||
page_number: Option<Option<u32>>,
|
||||
/// `style:columns` of a section style.
|
||||
columns: Option<Columns>,
|
||||
}
|
||||
|
||||
/// Columns of a `style:columns` element (ODF 1.3 §17.12): count and gap, clamped as the RTF and
|
||||
/// DOCX readers do; equal widths only (`style:column` widths are not read).
|
||||
fn columns_of(a: &Attrs) -> Columns {
|
||||
let mut c = Columns::default();
|
||||
if let Some(n) = get(a, "fo:column-count").and_then(|v| v.trim().parse::<u64>().ok()) {
|
||||
c.count = n.clamp(1, 45) as u32;
|
||||
}
|
||||
if let Some(g) = get(a, "fo:column-gap").and_then(length).filter(|v| *v >= 0.0) {
|
||||
c.space = g.min(1584.0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A `style:column-sep` (ODF 1.3 §17.13) draws a line between columns unless its style is "none".
|
||||
fn column_sep(a: &Attrs) -> bool {
|
||||
get(a, "style:style") != Some("none")
|
||||
}
|
||||
|
||||
/// Length → points.
|
||||
@@ -742,6 +771,7 @@ impl Styles {
|
||||
display: get(&a, "style:display-name").map(String::from),
|
||||
auto: in_auto && auto_section_is_auto,
|
||||
outline: get(&a, "style:default-outline-level").and_then(|v| v.parse::<u8>().ok()),
|
||||
master_page: get(&a, "style:master-page-name").map(String::from),
|
||||
..Default::default()
|
||||
};
|
||||
cur = Some((name.to_string(), info));
|
||||
@@ -788,6 +818,13 @@ impl Styles {
|
||||
}
|
||||
}
|
||||
}
|
||||
"table-properties" => {
|
||||
if let Some((_, s)) = &mut cur
|
||||
&& let Some(v) = get(&a, "style:page-number")
|
||||
{
|
||||
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
||||
}
|
||||
}
|
||||
"table-cell-properties" => {
|
||||
if let Some((_, s)) = &mut cur
|
||||
&& let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color)
|
||||
@@ -807,11 +844,26 @@ impl Styles {
|
||||
if get(&a, "fo:break-before") == Some("page") {
|
||||
s.page_break = true;
|
||||
}
|
||||
if let Some(v) = get(&a, "style:page-number") {
|
||||
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
||||
}
|
||||
if get(&a, "fo:border-bottom").is_some_and(|b| b != "none") {
|
||||
s.border_bottom = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"columns" if q.starts_with("style:") => {
|
||||
if let Some((_, s)) = &mut cur {
|
||||
s.columns = Some(columns_of(&a));
|
||||
}
|
||||
}
|
||||
"column-sep" => {
|
||||
if let Some((_, s)) = &mut cur
|
||||
&& let Some(c) = &mut s.columns
|
||||
{
|
||||
c.separator = column_sep(&a);
|
||||
}
|
||||
}
|
||||
"list-style" => list = get(&a, "style:name").map(|n| (n.to_string(), vec![false; 10])),
|
||||
"list-level-style-number" | "list-level-style-bullet" | "list-level-style-image" => {
|
||||
if let Some((_, v)) = &mut list {
|
||||
@@ -870,6 +922,19 @@ impl Styles {
|
||||
f
|
||||
}
|
||||
|
||||
/// The page a paragraph style starts (`style:master-page-name` and `style:page-number`,
|
||||
/// ODF 1.3 §16.2 and §17.6): the nearest master page and page-number setting in its chain.
|
||||
fn page_start<'a>(&'a self, name: &'a str) -> (Option<&'a str>, Option<Option<u32>>) {
|
||||
let chain = self.chain(name);
|
||||
let master = chain.iter().find_map(|(_, s)| s.master_page.as_deref()).filter(|m| !m.is_empty());
|
||||
(master, chain.iter().find_map(|(_, s)| s.page_number))
|
||||
}
|
||||
|
||||
/// Columns of a section style (whole chain).
|
||||
fn columns(&self, name: &str) -> Option<Columns> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.columns.clone())
|
||||
}
|
||||
|
||||
/// Background colour of a table-cell style (whole chain).
|
||||
fn cell_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
||||
@@ -945,6 +1010,16 @@ struct Body<'a> {
|
||||
depth: usize,
|
||||
frame: Option<(Option<f32>, Option<f32>, Option<Vec<u8>>, String)>,
|
||||
in_desc: bool,
|
||||
/// The master pages' page setups.
|
||||
pages: &'a Pages,
|
||||
/// The current master page's setup (a column section's page, without its columns).
|
||||
page: SectionProps,
|
||||
/// The open section; where its blocks start in the body; how many sections have ended.
|
||||
sec: SectionProps,
|
||||
sec_from: usize,
|
||||
sections: usize,
|
||||
/// Element depth of the open columned `text:section`.
|
||||
cols_at: Option<usize>,
|
||||
}
|
||||
|
||||
impl Body<'_> {
|
||||
@@ -960,9 +1035,67 @@ impl Body<'_> {
|
||||
self.fmt.last().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reading the body itself, not a table cell or a frame.
|
||||
fn in_body(&self) -> bool {
|
||||
self.containers.len() == 1 && self.tables.is_empty() && self.frame.is_none()
|
||||
}
|
||||
|
||||
/// End the open section before what comes next, which starts section `next`. An open section
|
||||
/// with nothing in it yet takes `next`'s place instead. Only in the body, and at most
|
||||
/// [`MAX_SECTIONS`]. True when what comes next follows a section break.
|
||||
fn break_section(&mut self, next: SectionProps) -> bool {
|
||||
if !self.in_body() {
|
||||
return false;
|
||||
}
|
||||
let Some(body) = self.containers.first_mut() else { return false };
|
||||
if body.len() <= self.sec_from {
|
||||
let restart = self.sec.page_num_start;
|
||||
self.sec = next;
|
||||
self.sec.page_num_start = self.sec.page_num_start.or(restart);
|
||||
return self.sections > 0;
|
||||
}
|
||||
if self.sections + 1 >= MAX_SECTIONS {
|
||||
return false;
|
||||
}
|
||||
// A section ends on a paragraph: after a table, an empty one (as Word writes it).
|
||||
if !matches!(body.last(), Some(FBlock::Para(p)) if p.section.is_none()) {
|
||||
body.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
if let Some(FBlock::Para(p)) = body.last_mut() {
|
||||
p.section = Some(Box::new(std::mem::replace(&mut self.sec, next)));
|
||||
}
|
||||
self.sec_from = body.len();
|
||||
self.sections += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// A body paragraph or table of style `name` that names a master page starts a new page with
|
||||
/// that page's setup and, when the style says so, page numbering restarted (ODF 1.3 applies
|
||||
/// `style:page-number` only with a master page). The section that starts there, if any.
|
||||
///
|
||||
/// Rare cases left simple: a master-page paragraph first inside a columned `text:section`
|
||||
/// replaces that (still empty) column section, so the columns are lost; a columned section
|
||||
/// nested in another is read as part of the outer one.
|
||||
fn page_section(&mut self, name: &str) -> Option<SectionProps> {
|
||||
if !self.in_body() {
|
||||
return None;
|
||||
}
|
||||
let (master, number) = self.styles.page_start(name);
|
||||
let restart = number.flatten();
|
||||
let page = master.and_then(|m| self.pages.masters.get(m))?.clone();
|
||||
self.page = page.clone();
|
||||
Some(SectionProps { start: SectionStart::NextPage, page_num_start: restart, ..page })
|
||||
}
|
||||
|
||||
fn start_para(&mut self, style: Option<&str>, heading: Option<u8>) {
|
||||
self.end_para();
|
||||
let (mut kind, align, pb, rule, base) = style.map(|s| self.styles.para(s)).unwrap_or((Kind::Normal, None, false, false, Fmt::default()));
|
||||
let (mut kind, align, mut pb, rule, base) = style.map(|s| self.styles.para(s)).unwrap_or((Kind::Normal, None, false, false, Fmt::default()));
|
||||
// The section break starts the new page; no page break besides.
|
||||
if let Some(next) = style.and_then(|s| self.page_section(s))
|
||||
&& self.break_section(next)
|
||||
{
|
||||
pb = false;
|
||||
}
|
||||
if let Some(h) = heading {
|
||||
kind = Kind::Heading(h.clamp(1, 6));
|
||||
}
|
||||
@@ -1154,6 +1287,10 @@ impl Body<'_> {
|
||||
}
|
||||
"table" if q.starts_with("table:") => {
|
||||
self.end_para();
|
||||
// A body table whose style names a master page starts that page (ODF 1.3 §19.509).
|
||||
if let Some(next) = get(a, "table:style-name").and_then(|s| self.page_section(s)) {
|
||||
self.break_section(next);
|
||||
}
|
||||
if self.tables.len() >= model::MAX_DEPTH {
|
||||
self.skip = 1;
|
||||
return;
|
||||
@@ -1202,6 +1339,18 @@ impl Body<'_> {
|
||||
}
|
||||
}
|
||||
"desc" | "title" if self.frame.is_some() => self.in_desc = true,
|
||||
// A columned section (ODF 1.3 §5.4) is a continuous section with its columns.
|
||||
"section" if q.starts_with("text:") => {
|
||||
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 next = SectionProps { start: SectionStart::Continuous, page_num_start: None, columns, ..self.page.clone() };
|
||||
self.break_section(next);
|
||||
self.cols_at = Some(self.depth);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1274,6 +1423,12 @@ impl Body<'_> {
|
||||
}
|
||||
}
|
||||
"desc" | "title" => self.in_desc = false,
|
||||
"section" if q.starts_with("text:") && self.cols_at == Some(self.depth + 1) => {
|
||||
self.end_para();
|
||||
self.cols_at = None;
|
||||
let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, ..self.page.clone() };
|
||||
self.break_section(next);
|
||||
}
|
||||
"frame" => {
|
||||
if let Some((w, h, Some(data), alt)) = self.frame.take()
|
||||
&& let Some(img) = make_img(data, w, h, alt.trim())
|
||||
@@ -1330,6 +1485,11 @@ fn read_meta(xml: &[u8]) -> Meta {
|
||||
|
||||
/// Parse an `.odt` package into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
parse_full(bytes).map(|(flow, _)| flow)
|
||||
}
|
||||
|
||||
/// [`parse`], with the last section's properties (the others are on the paragraphs that end them).
|
||||
fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;
|
||||
if let Some(mt) = read_entry(&mut z, "mimetype") {
|
||||
let mt = String::from_utf8_lossy(&mt);
|
||||
@@ -1339,9 +1499,12 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
}
|
||||
let content = read_entry(&mut z, "content.xml").ok_or("ODT: content.xml is missing")?;
|
||||
let mut styles = Styles::default();
|
||||
let mut pages = Pages::default();
|
||||
if let Some(s) = read_entry(&mut z, "styles.xml") {
|
||||
styles.read(&s, false);
|
||||
pages = read_pages(&s);
|
||||
}
|
||||
let page = pages.first.clone().unwrap_or_default();
|
||||
styles.read(&content, true);
|
||||
let meta = read_entry(&mut z, "meta.xml").map(|m| read_meta(&m)).unwrap_or_default();
|
||||
let mut total = 0u64;
|
||||
@@ -1367,6 +1530,12 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
depth: 0,
|
||||
frame: None,
|
||||
in_desc: false,
|
||||
pages: &pages,
|
||||
sec: page.clone(),
|
||||
page,
|
||||
sec_from: 0,
|
||||
sections: 0,
|
||||
cols_at: None,
|
||||
};
|
||||
events(&content, |ev| match ev {
|
||||
Ev::Start(q, a) => {
|
||||
@@ -1396,37 +1565,66 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false }));
|
||||
}
|
||||
}
|
||||
// An open section with nothing in it (after a column section at the end) isn't one: the
|
||||
// section before it ends the document.
|
||||
let mut last = body.sec;
|
||||
if blocks.len() <= body.sec_from
|
||||
&& let Some(FBlock::Para(p)) = blocks.last_mut()
|
||||
&& let Some(s) = p.section.take()
|
||||
{
|
||||
last = *s;
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
Ok(Flow { blocks, meta })
|
||||
Ok((Flow { blocks, meta }, last))
|
||||
}
|
||||
|
||||
/// Parse an `.odt` package into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
let mut doc = model::to_doc(&parse(bytes)?);
|
||||
if let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
&& let Some(s) = read_entry(&mut z, "styles.xml")
|
||||
{
|
||||
apply_page_layout(&s, &mut doc.last_section);
|
||||
}
|
||||
let (flow, last) = parse_full(bytes)?;
|
||||
let mut doc = model::to_doc(&flow);
|
||||
doc.last_section = last;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Read the page size, orientation and margins of the first master page in `styles.xml` into `sec`.
|
||||
fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) {
|
||||
let mut layouts: HashMap<String, Attrs> = HashMap::new();
|
||||
/// The page setup of each master page in `styles.xml` (ODF 1.3 §16.9), and of the first one,
|
||||
/// which pages use until a paragraph names another.
|
||||
#[derive(Default)]
|
||||
struct Pages {
|
||||
masters: HashMap<String, SectionProps>,
|
||||
first: Option<SectionProps>,
|
||||
}
|
||||
|
||||
fn read_pages(xml: &[u8]) -> Pages {
|
||||
let mut layouts: HashMap<String, (Attrs, Option<Columns>)> = HashMap::new();
|
||||
let mut cur: Option<String> = None;
|
||||
let mut master: Option<String> = None;
|
||||
let mut masters: Vec<(String, String)> = Vec::new();
|
||||
events(xml, |ev| match ev {
|
||||
Ev::Start(q, a) => match local(&q) {
|
||||
"page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from),
|
||||
"page-layout-properties" => {
|
||||
if let Some(n) = &cur {
|
||||
layouts.insert(n.clone(), a);
|
||||
if let Some(n) = &cur
|
||||
&& layouts.len() < MAX_SECTIONS
|
||||
{
|
||||
layouts.insert(n.clone(), (a, None));
|
||||
}
|
||||
}
|
||||
"columns" if q.starts_with("style:") => {
|
||||
if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) {
|
||||
l.1 = Some(columns_of(&a));
|
||||
}
|
||||
}
|
||||
"column-sep" => {
|
||||
if let Some(c) = cur.as_ref().and_then(|n| layouts.get_mut(n)).and_then(|l| l.1.as_mut()) {
|
||||
c.separator = column_sep(&a);
|
||||
}
|
||||
}
|
||||
"master-page" if masters.len() < MAX_SECTIONS => {
|
||||
if let (Some(n), Some(l)) = (get(&a, "style:name"), get(&a, "style:page-layout-name")) {
|
||||
masters.push((n.to_string(), l.to_string()));
|
||||
}
|
||||
}
|
||||
"master-page" if master.is_none() => master = get(&a, "style:page-layout-name").map(String::from),
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(q) => {
|
||||
@@ -1436,8 +1634,34 @@ fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) {
|
||||
}
|
||||
Ev::Text(_) => {}
|
||||
});
|
||||
let Some(a) = master.and_then(|m| layouts.remove(&m)) else { return };
|
||||
let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0);
|
||||
let mut pages = Pages::default();
|
||||
for (name, layout) in masters {
|
||||
let Some((a, cols)) = layouts.get(&layout) else { continue };
|
||||
let mut sec = SectionProps::default();
|
||||
apply_layout(a, &mut sec);
|
||||
// A one-column layout keeps the default columns (and their gap).
|
||||
if let Some(c) = cols.as_ref().filter(|c| c.count > 1) {
|
||||
sec.columns = c.clone();
|
||||
}
|
||||
if pages.first.is_none() {
|
||||
pages.first = Some(sec.clone());
|
||||
}
|
||||
pages.masters.entry(name).or_insert(sec);
|
||||
}
|
||||
pages
|
||||
}
|
||||
|
||||
/// Replace `sec` with the page setup of the first master page in `styles.xml`, if any.
|
||||
#[cfg(test)]
|
||||
fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) {
|
||||
if let Some(first) = read_pages(xml).first {
|
||||
*sec = first;
|
||||
}
|
||||
}
|
||||
|
||||
/// A `style:page-layout-properties` element's page size, orientation and margins into `sec`.
|
||||
fn apply_layout(a: &Attrs, sec: &mut SectionProps) {
|
||||
let len = |k: &str| get(a, k).and_then(length).filter(|v| *v >= 0.0);
|
||||
// Like the DOCX reader: a page is at least half an inch; at most 22 inches (Word's limit), so a hostile
|
||||
// `1e30pt` can't reach layout.
|
||||
let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(1584.0));
|
||||
@@ -1445,7 +1669,7 @@ fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) {
|
||||
sec.page_w = w;
|
||||
sec.page_h = h;
|
||||
}
|
||||
sec.landscape = match get(&a, "style:print-orientation") {
|
||||
sec.landscape = match get(a, "style:print-orientation") {
|
||||
Some("landscape") => true,
|
||||
Some("portrait") => false,
|
||||
_ => sec.page_w > sec.page_h,
|
||||
@@ -1593,4 +1817,139 @@ mod tests {
|
||||
let row: Vec<_> = t.rows[0].iter().map(|c| (c.text(), c.shading)).collect();
|
||||
assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]);
|
||||
}
|
||||
|
||||
/// #1379: three master pages (A4 upright; A4 wide with page numbering restarting at 1; A4
|
||||
/// upright again with numbering continuing) and a two-column `text:section` in between.
|
||||
fn sections_package() -> Vec<u8> {
|
||||
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:print-orientation="portrait" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2.5cm" fo:margin-right="2.5cm"/></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" fo:margin-top="1.5cm" fo:margin-bottom="1.5cm" fo:margin-left="1.5cm" fo:margin-right="1.5cm"/></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"/>
|
||||
<style:master-page style:name="Closing" style:page-layout-name="pU"/>
|
||||
</office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles>
|
||||
<style:style style:name="PStart" style:family="paragraph" style:master-page-name="Upright"/>
|
||||
<style:style style:name="PWide" style:family="paragraph" style:master-page-name="Wide"><style:paragraph-properties fo:break-before="page" style:page-number="1"/></style:style>
|
||||
<style:style style:name="PClose" style:family="paragraph" style:master-page-name="Closing"><style:paragraph-properties fo:break-before="page" style:page-number="auto"/></style:style>
|
||||
<style:style style:name="Two" style:family="section"><style:section-properties><style:columns fo:column-count="2" fo:column-gap="1cm"><style:column-sep style:width="0.02cm" style:style="solid"/></style:columns></style:section-properties></style:style>
|
||||
</office:automatic-styles><office:body><office:text>
|
||||
<text:p text:style-name="PStart">Opening words</text:p><text:p>More opening words</text:p>
|
||||
<text:h text:style-name="PWide" text:outline-level="1">Wide part</text:h>
|
||||
<table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell><text:p>A</text:p></table:table-cell><table:table-cell><text:p>B</text:p></table:table-cell></table:table-row></table:table>
|
||||
<text:section text:name="Cols" text:style-name="Two"><text:p>Left column text</text:p><text:p>Right column text</text:p></text:section>
|
||||
<text:p text:style-name="PClose">Closing words</text:p><text:p>Last line</text:p>
|
||||
</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
package(&content, Some(&styles))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_and_column_sections_become_sections() {
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
let doc = import(§ions_package()).unwrap();
|
||||
let secs = doc.sections();
|
||||
let summary: Vec<_> =
|
||||
secs.iter().map(|(_, s)| (s.start, s.landscape, s.page_w.round(), s.page_h.round(), s.columns.count, s.page_num_start)).collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
(SectionStart::NextPage, false, 595.0, 842.0, 1, None),
|
||||
(SectionStart::NextPage, true, 842.0, 595.0, 1, Some(1)),
|
||||
(SectionStart::Continuous, true, 842.0, 595.0, 2, None),
|
||||
(SectionStart::NextPage, false, 595.0, 842.0, 1, None),
|
||||
]
|
||||
);
|
||||
// Margins come from each section's page layout; the columns from the section style.
|
||||
assert!((secs[0].1.margin_left - 70.87).abs() < 0.01 && (secs[1].1.margin_left - 42.52).abs() < 0.01);
|
||||
let cols = &secs[2].1.columns;
|
||||
assert!((cols.space - 28.35).abs() < 0.01 && cols.separator, "{cols:?}");
|
||||
// Each section ends on its own last paragraph; the wide one ends after its table.
|
||||
let text = |i: usize| match doc.body.get(i).map(|b| &**b) {
|
||||
Some(wordcraft_doc::Block::Para(p)) => p.plain_text().to_string(),
|
||||
_ => String::from("<not a paragraph>"),
|
||||
};
|
||||
assert_eq!(text(secs[0].0), "More opening words");
|
||||
assert!(matches!(doc.body.get(secs[1].0 - 1).map(|b| &**b), Some(wordcraft_doc::Block::Table(_))));
|
||||
assert_eq!(text(secs[2].0), "Right column text");
|
||||
assert_eq!(text(secs[3].0), "Last line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_master_page_stays_one_section() {
|
||||
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></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pU"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:master-page-name="Standard"/></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&styles))).unwrap();
|
||||
assert_eq!(doc.sections().len(), 1);
|
||||
assert_eq!(doc.last_section.page_w.round(), 595.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sections_are_capped_and_clamped() {
|
||||
// 20,000 master-page paragraphs give at most 10,000 sections; absurd column counts and
|
||||
// gaps are clamped; a column section at the very end leaves no empty section after it.
|
||||
let mut body = String::new();
|
||||
for _ in 0..20_000 {
|
||||
body.push_str(r#"<text:p text:style-name="PM">x</text:p>"#);
|
||||
}
|
||||
body.push_str(r#"<text:section text:style-name="Many"><text:p>cols</text:p></text:section>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="PM" style:family="paragraph" style:master-page-name="Standard"/><style:style style:name="Many" style:family="section"><style:section-properties><style:columns fo:column-count="99999999999" fo:column-gap="1e30in"/></style:section-properties></style:style></office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||
);
|
||||
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></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pU"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let doc = import(&package(&content, Some(&styles))).unwrap();
|
||||
assert_eq!(doc.sections().len(), 10_000);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="Many" style:family="section"><style:section-properties><style:columns fo:column-count="1000" fo:column-gap="1e30in"/></style:section-properties></style:style></office:automatic-styles><office:body><office:text><text:p>before</text:p><text:section text:style-name="Many"><text:p>cols</text:p></text:section></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = import(&package(&content, None)).unwrap();
|
||||
let secs = doc.sections();
|
||||
assert_eq!(secs.len(), 2);
|
||||
assert_eq!(secs[1].1.columns.count, 45);
|
||||
assert!(secs[1].1.columns.space <= 1584.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_page_on_a_table_style_starts_a_section() {
|
||||
// Review of #1379: a table style can name a master page too (ODF 1.3 §19.509), with its
|
||||
// page-number restart on `style:table-properties`. An empty name on a child style means
|
||||
// "no page style here", and a page-number restart without a master page starts nothing.
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="WideParent" style:family="paragraph" style:master-page-name="Wide"/></office:styles><office:automatic-styles>
|
||||
<style:page-layout style:name="pU"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"><style:columns fo:column-count="1" fo:column-gap="0cm"/></style:page-layout-properties></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="NoPage" style:family="paragraph" style:parent-style-name="WideParent" style:master-page-name=""/>
|
||||
<style:style style:name="Restart" style:family="paragraph"><style:paragraph-properties fo:break-before="page" style:page-number="3"/></style:style>
|
||||
<style:style style:name="WideTable" style:family="table" style:master-page-name="Wide"><style:table-properties style:page-number="1"/></style:style>
|
||||
</office:automatic-styles><office:body><office:text>
|
||||
<text:p>Intro</text:p><text:p text:style-name="NoPage">Not a new page style</text:p><text:p text:style-name="Restart">Page break only</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 secs = doc.sections();
|
||||
let summary: Vec<_> = secs.iter().map(|(i, s)| (*i, s.start, s.landscape, s.page_num_start)).collect();
|
||||
assert_eq!(summary, [(2, SectionStart::NextPage, false, None), (4, SectionStart::NextPage, true, Some(1))]);
|
||||
assert!(matches!(doc.body.get(3).map(|b| &**b), Some(wordcraft_doc::Block::Table(_))));
|
||||
// A one-column page layout keeps the default column gap.
|
||||
assert_eq!(secs[0].1.columns, wordcraft_doc::section::Columns::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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;
|
||||
@@ -25,8 +25,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. 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 |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer |
|
||||
| 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). No footnotes, comments, tracked changes, 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). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). 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, revisions, section export, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, 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). 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) |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
@@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
| 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) |
|
||||
| 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing |
|
||||
| 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `<math>` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) |
|
||||
| 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) |
|
||||
| 2026-10-14 | trivial | HTML: `<th>` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) |
|
||||
| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) |
|
||||
| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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-14 · **Change:** trivial (#19 narrowed: ODT sections on import, #1379; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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.
|
||||
|
||||
@@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions, 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 | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; 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) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
|
||||
@@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word |
|
||||
| 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages |
|
||||
| 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain |
|
||||
| 2026-10-14 | trivial | #19 narrowed: ODT import keeps sections (master-page changes, columned `text:section`s, page-numbering restarts) (#1379) |
|
||||
| 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections |
|
||||
| 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps |
|
||||
| 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain |
|
||||
|
||||
Reference in New Issue
Block a user