diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index a42a0f1abf..07d9666cf7 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -421,6 +421,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { mark_del: None, fmt_change: None, extra: None, + mark: None, }; if opts.paras { out.extra = para_extra(doc, &p.props); @@ -458,6 +459,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { mark_del: None, fmt_change: None, extra: None, + mark: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { @@ -581,10 +583,18 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { } // The paragraph that ends a section carries its properties (the last piece of it does). out.section.clone_from(&p.section); + out.mark = empty_mark(&out, p, opts); done.push(out); done } +/// The mark formatting of `out`, the last piece of `p`, when it holds no text (its height), for +/// formats that keep it (flows asked for paragraph formatting). +fn empty_mark(out: &Para, p: &Paragraph, opts: FlowOpts) -> Option> { + let m = CharProps { ins: None, del: None, fmt_change: None, ..p.mark.clone() }; + (opts.paras && out.inlines.is_empty() && !m.is_empty()).then(|| Box::new(m)) +} + /// The drawings from `from` on (just added) raised by their run's position `raise` (points): a /// drawing in line keeps it as its float's `y` (see [`crate::model::Img::float`]). fn raise_drawings(inlines: &mut [Inline], from: usize, raise: Option) { diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index dc40716dfc..1901675303 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -597,6 +597,9 @@ impl<'a> Builder<'a> { } } } + if let Some(m) = p.mark.as_deref().filter(|_| out.text.is_empty()) { + out.mark = self.budget(CharProps { ins: None, del: None, fmt_change: None, ..m.clone() }); + } out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m)); out.mark.del = p.mark_del.as_deref().map(|m| self.revision(RevisionKind::Delete, m)); out.normalize(); diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 33f060c6c3..7ce1a0f8c0 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -652,6 +652,10 @@ pub struct Para { /// Paragraph formatting beyond the fields above, for formats that keep it (ODT): its own /// tab stops. Only flows asked for it ([`FlowOpts::paras`]) hold it. pub extra: Option>, + /// The paragraph mark's direct formatting (its size sets an empty paragraph's height), for + /// formats that keep it (ODT): only flows asked for paragraph formatting + /// ([`FlowOpts::paras`]) hold it, and only for paragraphs without text. + pub mark: Option>, } impl Para { diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 58ead31d68..3246ce7899 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -546,7 +546,13 @@ impl Body<'_> { self.index_marks.clear(); let Some(mut p) = self.para.take() else { return }; self.glue_connectors(&mut p); - self.fmt.pop(); + // An empty paragraph's own text formatting is its mark's: its size is its height. + if let Some(base) = self.fmt.pop() + && p.inlines.is_empty() + && base != model::Fmt::default() + { + p.mark = Some(Box::new(crate::model::char_props(&base))); + } // A paragraph ending inside an open insertion has an inserted mark. (Paragraphs that // `place_deletion` splits off keep only the marks read inside their region.) if p.mark_ins.is_none() { diff --git a/crates/formats/src/odt/frames.rs b/crates/formats/src/odt/frames.rs index a3ac3aa60e..1606667d74 100644 --- a/crates/formats/src/odt/frames.rs +++ b/crates/formats/src/odt/frames.rs @@ -305,18 +305,24 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g Some("page-start-margin") => Anchor::LeftMargin, Some("page-end-margin") => Anchor::RightMargin, Some("page-content") => Anchor::Margin, + // A page has no paragraph: LibreOffice measures a page-anchored frame's paragraph + // relations from the page, its content relations from the page's text area. + Some("paragraph-content") if page => Anchor::Margin, + Some("paragraph-start-margin") if page => Anchor::LeftMargin, + Some("paragraph-end-margin") if page => Anchor::RightMargin, Some("paragraph-content") => Anchor::Column, - Some("char") => Anchor::Character, + Some("char") if !page => Anchor::Character, + Some(_) | None if page => Anchor::Page, Some(_) => Anchor::Paragraph, - None if page => Anchor::Page, None => Anchor::Paragraph, }; let v_rel = match g.vrel.as_deref() { Some("page") => Anchor::Page, Some("page-content") => Anchor::Margin, - Some("line") | Some("char") | Some("text") | Some("baseline") => Anchor::Line, + Some("paragraph-content") if page => Anchor::Margin, + Some("line") | Some("char") | Some("text") | Some("baseline") if !page => Anchor::Line, + Some(_) | None if page => Anchor::Page, Some(_) => Anchor::Paragraph, - None if page => Anchor::Page, None => Anchor::Paragraph, }; let h_align = match g.hpos.as_deref() { diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 950a005c6b..be10aafd0a 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -113,11 +113,7 @@ fn each_para(blocks: &mut [FBlock], depth: usize, f: &mut dyn FnMut(&mut Para)) FBlock::Para(p) => { f(p); for i in &mut p.inlines { - match i { - model::Inline::Note { blocks, .. } => each_para(blocks, depth + 1, f), - model::Inline::Comment { comment, .. } => each_para(&mut comment.blocks, depth + 1, f), - _ => {} - } + each_inline_para(i, depth + 1, f); } } FBlock::Table(t) => { @@ -129,6 +125,24 @@ fn each_para(blocks: &mut [FBlock], depth: usize, f: &mut dyn FnMut(&mut Para)) } } +/// [`each_para`] in the paragraphs `i` holds: a note's, a comment's, a text box's, or those of +/// the text boxes in a group. +fn each_inline_para(i: &mut model::Inline, depth: usize, f: &mut dyn FnMut(&mut Para)) { + if depth > model::MAX_DEPTH { + return; + } + match i { + model::Inline::Note { blocks, .. } | model::Inline::Shape { blocks, .. } => each_para(blocks, depth, f), + model::Inline::Comment { comment, .. } => each_para(&mut comment.blocks, depth, f), + model::Inline::Group { members, .. } => { + for m in members { + each_inline_para(&mut m.inline, depth + 1, f); + } + } + _ => {} + } +} + /// The custom character styles the text of `blocks` names (by ODF name, in [`model::Fmt::extra`]) /// with their custom parents, parents first, each with its document style id, added to `found`; /// the text then names them by id. `para_ids` are the paragraph styles' ids, which a character diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index e78a55d0cb..6dcb2e7acf 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -280,12 +280,16 @@ fn styles_xml( first: Some(normal.indent_first), }; let kern = doc.styles.default_chr.kern.is_some_and(|k| k > 0.0); + // Normal's font size (Standard, written empty, takes the default style's). + let size = doc.styles.resolve_char(None, &Default::default()).size; + let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 }; let tab = doc.settings.default_tab.clamp(1.0, 1584.0); let mut s = format!("\n"); s.push_str(&format!( - "", + "", spacing_props(defaults), x(&body_font), + size = pt(size), tab = pt(tab), scripts = super::writer::script_attrs(&doc.styles.default_chr) )); diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 7b3d40aadb..47fdd9fbf3 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -1068,7 +1068,6 @@ impl Styles { }); } - /// The document's default font: the Standard paragraph style's, else the default style's. /// The document's default font size: the Standard paragraph style's (the document's /// defaults, as for [`Styles::doc_font`]), else the default style's. pub(super) fn doc_size(&self) -> Option { @@ -1081,6 +1080,7 @@ impl Styles { .map(|v| v.min(1638.0)) } + /// The document's default font: the Standard paragraph style's, else the default style's. pub(super) fn doc_font(&self) -> Option { self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone()) } diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index fc815c5df3..10e2114d80 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -1185,6 +1185,16 @@ fn the_default_styles_font_size_is_the_documents() { // Written back as the default style's. let s = entry(&export(&doc).unwrap(), "styles.xml"); assert!(s.contains(r#"fo:font-size="10pt""#), "{s}"); + // Standard's 11 pt too: LibreOffice's letters grew a page at 12 pt. + let s = entry(&export(&d).unwrap(), "styles.xml"); + assert!( + s.contains(r#"Text"# + ); + let doc = import(&package(&content, None)).unwrap(); + let marks = |d: &Document| d.body.iter().filter_map(|b| b.as_para()).map(|p| p.mark.size).collect::>(); + // Only the empty paragraph's: the other's text has the size itself. + assert_eq!(marks(&doc), [Some(6.0), None]); + let s = entry(&export(&doc).unwrap(), "content.xml"); + assert!(s.contains(r#"fo:font-size="6pt""#), "{s}"); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(marks(&back), [Some(6.0), None]); + let p = back.body[1].as_para().unwrap(); + assert_eq!(p.runs[0].props.size, Some(6.0)); +} diff --git a/crates/formats/src/odt/tests_more/floats.rs b/crates/formats/src/odt/tests_more/floats.rs index 02e3946116..e3d139acb4 100644 --- a/crates/formats/src/odt/tests_more/floats.rs +++ b/crates/formats/src/odt/tests_more/floats.rs @@ -56,6 +56,25 @@ fn wrap_modes_and_offsets() { assert_eq!(float(&open("", r#"text:anchor-type="as-char""#)).wrap, Wrap::Inline); } +#[test] +fn a_page_anchored_frames_paragraph_relations_are_the_pages() { + // LibreOffice's letter templates: a page-anchored frame "from the paragraph" is placed from + // the page's edge, "from the paragraph's content" from its text area. + let f = float(&open( + r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph""#, + r#"text:anchor-type="page" svg:x="2cm" svg:y="2cm""#, + )); + assert_eq!((f.h_rel, f.v_rel), (Anchor::Page, Anchor::Page)); + let f = float(&open( + r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, + r#"text:anchor-type="page" svg:x="2cm""#, + )); + assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Page)); + // Anchored to a paragraph, they stay the paragraph's. + let f = float(&open(r#"style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, r#"text:anchor-type="char""#)); + assert_eq!((f.h_rel, f.v_rel), (Anchor::Column, Anchor::Line)); +} + #[test] fn floating_pictures_round_trip() { for (graphic, frame) in [ diff --git a/crates/formats/src/odt/tests_more/text_boxes.rs b/crates/formats/src/odt/tests_more/text_boxes.rs index 3b0bd021ba..bb045a2a7e 100644 --- a/crates/formats/src/odt/tests_more/text_boxes.rs +++ b/crates/formats/src/odt/tests_more/text_boxes.rs @@ -283,3 +283,25 @@ fn boxes_of(d: &Document) -> usize { .filter(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::TextBox, .. })) .count() } + +#[test] +fn a_custom_style_only_in_a_text_box_is_kept() { + // LibreOffice's fax template: "Frame contents" (centred) names only paragraphs in a text box. + let content = format!( + r#"BeforeImportant!"# + ); + let styles = format!( + r#""# + ); + let centred = |d: &Document| { + let part = d.parts.values().find(|p| p.kind == PartKind::TextBox).expect("the box's story"); + let p = part.blocks[0].as_para().unwrap(); + let style = p.props.style.clone().expect("a style"); + assert!(d.styles.get(&style).is_some(), "{style} is defined"); + d.styles.resolve_para(&p.props).align + }; + let d = import(&package(&content, Some(&styles))).unwrap(); + assert_eq!(centred(&d), wordcraft_doc::props::Align::Center); + let back = import(&export(&d).unwrap()).unwrap(); + assert_eq!(centred(&back), wordcraft_doc::props::Align::Center); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index d3cedc1afc..d939629045 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -299,8 +299,14 @@ impl Writer { props.push('\u{1}'); props.push_str(&children); } - if p.extra.as_ref().and_then(|x| x.suppress_hyphens) == Some(false) { + // An empty paragraph's mark formatting, its size giving its height. + let mark = p.mark.as_deref().filter(|_| p.inlines.is_empty()).map(super::writer::props::props_text).unwrap_or_default(); + let hyphenate = p.extra.as_ref().and_then(|x| x.suppress_hyphens) == Some(false); + if hyphenate || !mark.is_empty() { props.push('\u{2}'); + props.push_str(&mark); + } + if hyphenate { props.push_str(" fo:hyphenate=\"true\""); } let master = start.map(|(m, _)| m.clone()).unwrap_or_default(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6c3cef6b05..9ed3f2e180 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously 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 `
` 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-11-02 · **Change:** trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously 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 `
` 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; @@ -280,6 +280,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | ODT: saving wrote the default font size as 12 pt whatever the document's was, so LibreOffice's 11 pt letters gained a page after a save; Normal's size is written. A page-anchored frame placed "from the paragraph" (LibreOffice's letter logos) was moved by the page margin; it's placed from the page, and "from the paragraph's content" from the page's text area. A custom style naming only paragraphs in text boxes (the fax template's centred "Frame contents") was dropped, leaving them Normal; it's kept. An empty paragraph's own font size (its height: the fax's 6 pt line) was dropped; it's the paragraph mark's, read and written (`odt::tests::the_default_styles_font_size_is_the_documents`, `odt::tests::an_empty_paragraph_keeps_its_marks_size`, `floats::a_page_anchored_frames_paragraph_relations_are_the_pages`, `text_boxes::a_custom_style_only_in_a_text_box_is_kept`) | | 2026-11-02 | trivial | ODT: the document's default font size (the default paragraph style's, 10 pt in LibreOffice's letters) was ignored, leaving 12 pt; it is the document's default. A date style ordered by the locale showed "2026November2"; it shows "November 2, 2026" (`odt::tests::the_default_styles_font_size_is_the_documents`, `fields::a_date_in_the_locales_order_shows_so`) | | 2026-11-02 | trivial | ODT: page background pictures (LibreOffice's report templates' world map) were dropped; they are page pictures, centred ones drawn at their own size in the middle of the page (on screen and in PDF), and written back (`page_setup::a_page_background_picture_is_read_and_written`, `render::tests::page_picture_is_painted_behind_the_text`) | | 2026-11-02 | trivial | ODT: a table starting a page (LibreOffice's agenda minutes) didn't; it does, in WordCraft and after a save, and LibreOffice's 6orange agenda round-trips with its text the same. ODT tables are fixed layout, as LibreOffice's columns never fit their content (`flow_breaks::a_table_starting_a_page_starts_it`, `a_tables_columns_keep_their_widths`) |