Gap 19: ODT page breaks after, kept lines, widows, hyphenation, table placement

Six features of LibreOffice documents were lost on open and save. A
two-page document came back as one page, because the page break after
a paragraph was dropped. Now read and written:
- a page break after a paragraph (`fo:break-after="page"`), as a page
  break before the next paragraph, as Word has it;
- lines kept together (`fo:keep-together`);
- widow and orphan control (`fo:widows`/`fo:orphans`): on with any line
  count, off with none;
- hyphenation (`fo:hyphenate`): a paragraph's in ODF, the document's in
  WordCraft. A document with a hyphenated paragraph hyphenates, and its
  other paragraphs opt out;
- a table's alignment and width (`table:align`, `style:rel-width`);
- its rows' heights (`style:row-height` exact, `style:min-row-height` at
  least).

Flow model: `FTable::extra` (alignment, width, row heights), filled by
formats that keep them.

A LibreOffice document with all of these round-trips at 573 px
difference instead of 4,407, keeps its two pages, and validates
against the ODF 1.3 schema. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-01 18:30:41 +00:00
1 parent b87c80cdc7
commit 6925818110
17 files changed
+376 -26

No files matched your search

+1 -1
View File
@@ -479,7 +479,7 @@ impl<'r> Builder<'r> {
// Keep at most MAX_ROWS rows (the model drops the rest anyway): bounds the
// O(rows × rowspan) work of `insert_covered` on a table of millions of `<tr>`.
let rows: Vec<_> = t.rows.into_iter().filter(|r| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
let mut ft = FTable { rows, widths: Vec::new(), borderless };
let mut ft = FTable { rows, widths: Vec::new(), borderless, extra: None };
if !ft.rows.is_empty() {
ft.insert_covered();
self.container().push(FBlock::Table(ft));
+1 -1
View File
@@ -98,7 +98,7 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
if !row.is_empty() {
rows.push(row);
}
let mut out = FTable { rows: Vec::new(), widths: Vec::new(), borderless: !has_rules };
let mut out = FTable { rows: Vec::new(), widths: Vec::new(), borderless: !has_rules, extra: None };
// Rows still covered by a `\multirow` above, per grid column.
let mut covering: Vec<u32> = Vec::new();
for r in rows {
+2 -2
View File
@@ -324,7 +324,7 @@ fn special_characters_round_trip() {
#[test]
fn exported_tables_size_their_columns() {
let mut t = FTable { rows: Vec::new(), widths: vec![100.0, 200.0], borderless: false };
let mut t = FTable { rows: Vec::new(), widths: vec![100.0, 200.0], borderless: false, extra: None };
let cell = |s: &str| Cell {
blocks: vec![FBlock::Para({
let mut p = Para::default();
@@ -359,7 +359,7 @@ fn urls_preserve_tildes() {
#[test]
fn exported_borderless_tables_omit_rules() {
let mut t = FTable { rows: Vec::new(), widths: vec![100.0, 200.0], borderless: true };
let mut t = FTable { rows: Vec::new(), widths: vec![100.0, 200.0], borderless: true, extra: None };
let cell = |s: &str| Cell {
blocks: vec![FBlock::Para({
let mut p = Para::default();
+8 -3
View File
@@ -28,7 +28,7 @@ pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
pub use types::{
Affix, BoxLook, CODE_STYLE, Cell, Chart, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH,
MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text,
MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra, clean_text,
equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
};
pub(crate) use types::{Dropped, begin_import, dropped};
@@ -56,8 +56,12 @@ mod tests {
fn insert_covered_is_bounded_on_hostile_rowspan() {
// Many rows each carrying the maximum rowspan would be O(rows × rowspan) covered cells;
// the work cap must keep insertion bounded (and quick) instead of exhausting memory.
let mut t =
FTable { rows: (0..20_000).map(|_| vec![Cell { rowspan: 1000, ..Default::default() }]).collect(), widths: Vec::new(), borderless: false };
let mut t = FTable {
rows: (0..20_000).map(|_| vec![Cell { rowspan: 1000, ..Default::default() }]).collect(),
widths: Vec::new(),
borderless: false,
extra: None,
};
t.insert_covered();
assert!(t.rows.iter().all(|r| r.len() <= 64), "a row grew past the column cap");
}
@@ -107,6 +111,7 @@ mod tests {
rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]],
widths: vec![],
borderless: false,
extra: None,
};
t.insert_covered();
assert_eq!(t.rows[1].len(), 2);
+21 -1
View File
@@ -167,6 +167,10 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
bidi: p.bidi.filter(|b| *b),
drop_cap: p.drop_cap.filter(|l| *l > 1),
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
keep_lines: p.keep_lines.filter(|k| *k),
widow_control: p.widow_control.filter(|w| !*w),
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
..Default::default()
};
(x != wordcraft_doc::props::ParaProps::default()).then(|| Box::new(x))
@@ -710,7 +714,23 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa
let visible = |b: &Option<wordcraft_doc::props::Borders>| b.as_ref().is_some_and(|b| b.any_visible());
let borderless =
t.props.style.is_none() && !visible(&t.props.borders) && t.rows.iter().flat_map(|r| &r.cells).all(|c| !visible(&c.props.borders));
FTable { rows, widths: t.grid.clone(), borderless }
// Formats that keep them get the table's alignment, width and row heights.
let extra = tracked.paras.then(|| {
use wordcraft_doc::props::HeightRule;
let heights = t
.rows
.iter()
.map(|r| match (r.props.height, r.props.height_rule) {
(Some(h), HeightRule::Exact) => Some((h, true)),
(Some(h), HeightRule::AtLeast) => Some((h, false)),
_ => None,
})
.collect::<Vec<_>>();
super::types::TableExtra { align: t.props.align, width: t.props.width, width_pct: t.props.width_pct, heights }
});
let extra =
extra.filter(|x| x.align.is_some() || x.width.is_some() || x.width_pct.is_some() || x.heights.iter().any(Option::is_some)).map(Box::new);
FTable { rows, widths: t.grid.clone(), borderless, extra }
}
/// The document body as a flow.
+19 -2
View File
@@ -165,6 +165,11 @@ fn para_props(p: &Para, list_num: Option<u32>) -> ParaProps {
if let Some(bg) = p.extra.as_ref().and_then(|x| x.shading) {
out.shading = Some(bg);
}
if let Some(x) = &p.extra {
out.keep_lines = x.keep_lines.or(out.keep_lines);
out.widow_control = x.widow_control.or(out.widow_control);
out.suppress_hyphens = x.suppress_hyphens.or(out.suppress_hyphens);
}
// Tab stops a format gives the paragraph itself: finite, on the page, at most 64.
if let Some(tabs) = p.extra.as_ref().and_then(|x| x.tabs.as_ref()) {
let tabs: Vec<_> = tabs
@@ -708,7 +713,7 @@ impl<'a> Builder<'a> {
}
fn table(&mut self, t: &FTable, depth: usize) -> Option<Table> {
let rows: Vec<&Vec<Cell>> = t.rows.iter().filter(|r| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
let rows: Vec<(usize, &Vec<Cell>)> = t.rows.iter().enumerate().filter(|(_, r)| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
if rows.is_empty() {
return None;
}
@@ -725,9 +730,21 @@ impl<'a> Builder<'a> {
tb.grid = t.widths.iter().map(|w| w.clamp(6.0, 1584.0)).collect();
}
tb.rows.clear();
for r in rows {
if let Some(x) = &t.extra {
tb.props.align = x.align;
tb.props.width_pct = x.width_pct.filter(|p| p.is_finite() && *p > 0.0).map(|p| p.min(100.0));
tb.props.width = x.width.filter(|w| w.is_finite() && *w > 0.0).map(|w| w.min(1584.0));
}
for (ri, r) in rows {
let mut row = wordcraft_doc::Row { props: RowProps::default(), cells: Vec::new(), controls: Default::default() };
row.props.header = !r.is_empty() && r.iter().all(|c| c.header);
if let Some(&Some((h, exact))) = t.extra.as_ref().and_then(|x| x.heights.get(ri))
&& h.is_finite()
&& h > 0.0
{
row.props.height = Some(h.min(1584.0));
row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast };
}
let mut g = 0usize;
for c in r.iter().take(wordcraft_doc::table::MAX_COLS) {
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
+14
View File
@@ -776,6 +776,20 @@ pub struct FTable {
pub widths: Vec<f32>,
/// The source table had no borders (HTML default); the document table gets no grid style.
pub borderless: bool,
/// Its alignment, width and row heights, for formats that keep them (ODT).
pub extra: Option<Box<TableExtra>>,
}
/// A table's placement and row heights ([`FTable::extra`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TableExtra {
pub align: Option<wordcraft_doc::Align>,
/// Width, points.
pub width: Option<f32>,
/// Width, percent of the text width.
pub width_pct: Option<f32>,
/// Each row's height (points) and whether it is exact (else at least), by row.
pub heights: Vec<Option<(f32, bool)>>,
}
impl FTable {
+48 -8
View File
@@ -28,6 +28,8 @@ pub(super) struct Body<'a> {
zip: &'a mut dyn FnMut(&str) -> Option<Vec<u8>>,
/// The package's entry names and its manifest's media types, for embedded objects.
pub(super) entries: &'a [String],
/// The paragraph before had a page break after it: the next one starts a page.
break_next: bool,
/// The form controls of `office:forms`, placed where a `draw:control` names them.
forms: super::forms::FormsReader,
pub(super) manifest: Option<&'a HashMap<String, String>>,
@@ -125,6 +127,7 @@ impl<'a> Body<'a> {
zip,
entries: &[],
forms: Default::default(),
break_next: false,
manifest: None,
containers: vec![Vec::new()],
fmt: Vec::new(),
@@ -324,14 +327,20 @@ impl Body<'_> {
let look = style.map(|s| self.styles.para(s)).unwrap_or_default();
let (mut kind, align, mut pb, rule, keep_next, base) = (look.kind, look.align, look.page_break, look.rule, look.keep_next, look.fmt);
// The section break starts the new page; no page break besides.
let mut new_page = false;
if let Some(next) = style.and_then(|s| self.page_section(s))
&& self.break_section(next)
{
pb = false;
(pb, new_page) = (false, true);
}
if let Some(h) = heading {
kind = Kind::Heading(h.clamp(1, 6));
}
// The paragraph before ended with a page break after it (`fo:break-after`): this one
// starts the page.
if std::mem::take(&mut self.break_next) && !new_page {
pb = true;
}
let mut p = Para { kind, align, page_break: pb, keep_next, spacing: look.spacing, ..Default::default() };
p.borders = look.borders;
if let Some(n) = look.named {
@@ -351,6 +360,20 @@ impl Body<'_> {
let x = p.extra.get_or_insert_with(Default::default);
(x.drop_cap, x.drop_cap_distance) = (Some(lines), (distance > 0.0).then_some(distance));
}
if look.keep_lines == Some(true) {
p.extra.get_or_insert_with(Default::default).keep_lines = Some(true);
}
if look.widow_control == Some(false) {
p.extra.get_or_insert_with(Default::default).widow_control = Some(false);
}
// Hyphenated: `Some(false)` (not suppressed); the document then hyphenates (see
// `import::hyphenation`).
if look.hyphenate == Some(true) {
p.extra.get_or_insert_with(Default::default).suppress_hyphens = Some(false);
}
if look.break_after {
self.break_next = true;
}
if let Some(mut tabs) = look.tabs {
// LibreOffice measures tabs from the indent (its `TabsRelativeToIndent`); Word from
// the margin.
@@ -654,7 +677,7 @@ impl Body<'_> {
self.skip = 1;
return;
}
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new() });
self.start_table(a);
}
"table-column" => {
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
@@ -664,12 +687,7 @@ impl Body<'_> {
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
}
}
"table-row" => {
if let Some(t) = self.tables.last_mut() {
t.row.clear();
t.hcover = 0;
}
}
"table-row" => self.start_row(a),
"table-cell" => {
self.end_para();
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
@@ -749,6 +767,28 @@ impl Body<'_> {
}
}
/// A table (`table:table`) begins: its alignment and width, a relative width or a fixed
/// one when aligned (a table filling the margins, LibreOffice's default, takes the text width).
fn start_table(&mut self, a: &Attrs) {
let (align, width, rel) = get(a, "table:style-name").map(|s| self.styles.table_look(s)).unwrap_or_default();
let width_pct = rel.or_else(|| width.filter(|_| align.is_some()).map(|w| (w / self.sec.text_width().max(1.0) * 100.0).min(100.0)));
let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new() };
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
if let Some(t) = self.tables.last_mut() {
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
t.extra.heights.truncate(t.rows.len());
t.extra.heights.push(height);
}
}
}
/// A form control (`draw:control`, ODF 1.3 §10.3.13) in the paragraph: the control it names,
/// with its text, as a content control. Inside a field or a frame's text it is nothing.
fn place_control(&mut self, a: &Attrs) {
+5 -1
View File
@@ -73,6 +73,8 @@ pub(in crate::odt) struct TableB {
pub(in crate::odt) hcover: usize,
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
pub(in crate::odt) widths: Vec<f32>,
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
pub(in crate::odt) extra: crate::model::TableExtra,
}
impl TableB {
@@ -84,6 +86,8 @@ impl TableB {
// A table whose cells all say "no line" draws none.
let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some());
let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible()));
FTable { rows, widths, borderless }
let extra = self.extra;
let kept = extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some);
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)) }
}
}
+24
View File
@@ -402,6 +402,26 @@ struct Parsed {
/// The page settings that are the document's.
page_doc: PageDoc,
parts: page_parts::Parts,
/// Some paragraph hyphenates: the document hyphenates (see [`hyphenation`]).
hyphenate: bool,
}
/// Hyphenation is a paragraph's in ODF (`fo:hyphenate`) and the document's in WordCraft (as in
/// Word): when any paragraph of `blocks` hyphenates (its `suppress_hyphens` read as
/// `Some(false)`), the document does, and every other paragraph opts out; otherwise nothing is
/// marked. Whether the document hyphenates.
fn hyphenation(blocks: &mut [FBlock]) -> bool {
let mut any = false;
each_para(blocks, 0, &mut |p| any |= p.extra.as_ref().is_some_and(|x| x.suppress_hyphens == Some(false)));
each_para(blocks, 0, &mut |p| {
let hyphenates = p.extra.as_ref().is_some_and(|x| x.suppress_hyphens == Some(false));
if any && !hyphenates {
p.extra.get_or_insert_with(Default::default).suppress_hyphens = Some(true);
} else if let Some(x) = &mut p.extra {
x.suppress_hyphens = None;
}
});
any
}
/// [`parse_full`], plus what the document holds outside the flow.
@@ -463,6 +483,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
}
let hyphenate = hyphenation(&mut blocks);
let track = body.track;
Ok(Parsed {
flow: Flow { blocks, meta },
@@ -483,6 +504,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<Parsed, String> {
tabs_from_indent: styles.tabs_from_indent,
page_doc: pages.doc.clone(),
parts,
hyphenate,
})
}
@@ -508,8 +530,10 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
tabs_from_indent,
page_doc,
parts,
hyphenate,
} = parse_tracked(bytes)?;
let mut doc = model::to_doc(&flow);
doc.settings.auto_hyphenation |= hyphenate;
if font.is_some() {
doc.styles.default_chr.font = font;
}
+84
View File
@@ -208,6 +208,14 @@ struct StyleInfo {
cell_padding: [Option<f32>; 4],
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
column_width: Option<f32>,
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
/// points, or `style:rel-width`, percent of the text width).
table_align: Option<Align>,
table_width: Option<f32>,
table_rel_width: Option<f32>,
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `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>,
@@ -228,6 +236,14 @@ struct StyleInfo {
rtl: Option<bool>,
/// A drop cap (§17.9 `style:drop-cap`): lines it spans and its distance from the text.
drop_cap: Option<(u8, f32)>,
/// How its lines break across pages: `fo:keep-together` (`always`: kept together),
/// `fo:widows`/`fo:orphans` (widow and orphan control on with any, off with none), and
/// `fo:break-after` (`page`: the next paragraph starts a page).
keep_lines: Option<bool>,
widow_control: Option<bool>,
break_after: bool,
/// `fo:hyphenate` of its text properties: its words may be hyphenated.
hyphenate: Option<bool>,
/// `fo:border*` of a paragraph style, each side's padding as its distance from the text.
para_borders: Option<Borders>,
/// `fo:background-color` of a paragraph style (`Some(None)`: transparent, stated).
@@ -358,6 +374,11 @@ pub(super) struct ParaLook {
pub(super) rtl: Option<bool>,
/// A drop cap: lines it spans (2–10) and its distance from the text, points.
pub(super) drop_cap: Option<(u8, f32)>,
/// Lines kept together, widow and orphan control, a page break after it, hyphenation.
pub(super) keep_lines: Option<bool>,
pub(super) widow_control: Option<bool>,
pub(super) break_after: bool,
pub(super) hyphenate: Option<bool>,
/// Borders (when a side shows) and background.
pub(super) borders: Option<Borders>,
pub(super) shading: Option<Rgb>,
@@ -520,6 +541,28 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
if let Some(v) = get(a, "style:page-number") {
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
}
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
s.table_align = match get(a, "table:align").map(str::trim) {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
_ => s.table_align,
};
if let Some(w) = get(a, "style:width").and_then(length).filter(|w| w.is_finite() && *w > 0.0) {
s.table_width = Some(w.min(MAX_LEN));
}
if let Some(p) =
get(a, "style:rel-width").and_then(|v| v.trim().strip_suffix('%')?.trim().parse::<f32>().ok()).filter(|p| p.is_finite() && *p > 0.0)
{
s.table_rel_width = Some(p.min(100.0));
}
}
"table-row-properties" => {
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), true));
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), false));
}
}
"table-column-properties" => {
if let Some(w) = get(a, "style:column-width").and_then(length) {
@@ -681,6 +724,20 @@ fn paragraph_properties(a: &Attrs, s: &mut StyleInfo) {
if get(a, "fo:break-before").is_some_and(|b| b.trim().eq_ignore_ascii_case("page")) {
s.page_break = true;
}
if get(a, "fo:break-after").is_some_and(|b| b.trim().eq_ignore_ascii_case("page")) {
s.break_after = true;
}
match get(a, "fo:keep-together").map(str::trim) {
Some("always") => s.keep_lines = Some(true),
Some("auto") => s.keep_lines = Some(false),
_ => {}
}
let lines = |k: &str| get(a, k).and_then(|v| v.trim().parse::<u32>().ok());
if let (w, o) = (lines("fo:widows"), lines("fo:orphans"))
&& (w.is_some() || o.is_some())
{
s.widow_control = Some(w.unwrap_or(0) > 0 || o.unwrap_or(0) > 0);
}
match get(a, "fo:keep-with-next") {
Some("always") => s.keep_next = Some(true),
Some("auto") => s.keep_next = Some(false),
@@ -885,6 +942,9 @@ impl Styles {
"text-properties" => {
if let Some((_, s)) = &mut cur {
text_properties(&a, s, &self.faces);
if let Some(h) = get(&a, "fo:hyphenate") {
s.hyphenate = Some(h.trim() == "true");
}
}
}
name if cur.as_mut().is_some_and(|(_, s)| style_child(name, &q, &a, s)) => {}
@@ -1057,6 +1117,21 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.column_width)
}
/// A table style's alignment, width (points) and relative width (percent), whole chain.
pub(super) fn table_look(&self, name: &str) -> (Option<Align>, Option<f32>, Option<f32>) {
let chain = self.chain(name);
(
chain.iter().find_map(|(_, s)| s.table_align),
chain.iter().find_map(|(_, s)| s.table_width),
chain.iter().find_map(|(_, s)| s.table_rel_width),
)
}
/// A table-row style's height (points) and whether it is exact, whole chain.
pub(super) fn row_height(&self, name: &str) -> Option<(f32, bool)> {
self.chain(name).iter().find_map(|(_, s)| s.row_height)
}
/// Background colour of a table-cell style (whole chain).
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
@@ -1239,6 +1314,11 @@ impl Styles {
let contextual = own().find_map(|(_, s)| s.contextual);
let rtl = own().find_map(|(_, s)| s.rtl);
let drop_cap = own().find_map(|(_, s)| s.drop_cap);
let keep_lines = own().find_map(|(_, s)| s.keep_lines);
let widow_control = own().find_map(|(_, s)| s.widow_control);
// A page break after the paragraph: its own style's (automatic or common).
let break_after = own().take(1).any(|(_, s)| s.break_after);
let hyphenate = chain.iter().find_map(|(_, s)| s.hyphenate);
// Where LibreOffice measures the tab stops from: the indent the paragraph ends up with,
// whichever style in the chain gives it.
let tab_origin = chain.iter().find_map(|(_, s)| s.spacing.left).filter(|v| v.is_finite()).unwrap_or(0.0);
@@ -1258,6 +1338,10 @@ impl Styles {
rtl,
tab_origin,
drop_cap,
keep_lines,
widow_control,
break_after,
hyphenate,
}
}
}
+1
View File
@@ -11,6 +11,7 @@ mod equations;
mod fields;
mod flat;
mod floats;
mod flow_breaks;
mod fonts;
mod format_changes;
mod forms;
@@ -0,0 +1,90 @@
//! How text flows across pages and tables sit on the page: a page break after a paragraph
//! (`fo:break-after`), lines kept together (`fo:keep-together`), widow and orphan control
//! (`fo:widows`, `fo:orphans`), hyphenation (`fo:hyphenate`), a table's alignment and width
//! (`table:align`, `style:rel-width`) and its rows' heights (`style:row-height`,
//! `style:min-row-height`), read into WordCraft's document and written back.
use crate::odt::tests::{NS_T, content_of, package};
use crate::odt::{export, import};
use wordcraft_doc::Align;
use wordcraft_doc::props::HeightRule;
fn doc(styles: &str, body: &str) -> wordcraft_doc::Document {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
);
import(&package(&content, None)).unwrap()
}
fn para_style(name: &str, para: &str, text: &str) -> String {
format!(
r#"<style:style style:name="{name}" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties {para}/><style:text-properties {text}/></style:style>"#
)
}
fn paras(d: &wordcraft_doc::Document) -> Vec<&wordcraft_doc::props::ParaProps> {
d.body.iter().filter_map(|b| b.as_para()).map(|p| &p.props).collect()
}
#[test]
fn a_page_break_after_starts_the_next_paragraph_on_a_page() {
let d = doc(
&para_style("P1", r#"fo:break-after="page""#, ""),
r#"<text:p text:style-name="P1">One.</text:p><text:p>Two.</text:p><text:p>Three.</text:p>"#,
);
let breaks: Vec<bool> = paras(&d).iter().map(|p| p.page_break_before == Some(true)).collect();
assert_eq!(breaks, [false, true, false]);
// Written back as a page break before the second paragraph.
let c = content_of(&export(&d).unwrap());
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 1, "{c}");
}
#[test]
fn kept_lines_and_widow_control_are_read_and_written() {
let styles = format!(
"{}{}",
para_style("P1", r#"fo:keep-together="always" fo:widows="3" fo:orphans="3""#, ""),
para_style("P2", r#"fo:widows="0" fo:orphans="0""#, "")
);
let d = doc(&styles, r#"<text:p text:style-name="P1">Kept.</text:p><text:p text:style-name="P2">Loose.</text:p>"#);
let p = paras(&d);
assert_eq!((p[0].keep_lines, p[0].widow_control), (Some(true), None));
assert_eq!((p[1].keep_lines, p[1].widow_control), (None, Some(false)));
let c = content_of(&export(&d).unwrap());
assert!(c.contains(r#"fo:keep-together="always""#) && c.contains(r#"fo:widows="0" fo:orphans="0""#), "{c}");
}
#[test]
fn a_hyphenated_paragraph_makes_the_document_hyphenate() {
// Hyphenation is a paragraph's in ODF, the document's in WordCraft: the document hyphenates
// and the other paragraphs opt out.
let d = doc(&para_style("P1", "", r#"fo:hyphenate="true""#), r#"<text:p text:style-name="P1">Hyphenated.</text:p><text:p>Not.</text:p>"#);
assert!(d.settings.auto_hyphenation);
let p = paras(&d);
assert_eq!((p[0].suppress_hyphens, p[1].suppress_hyphens), (None, Some(true)));
let c = content_of(&export(&d).unwrap());
assert_eq!(c.matches(r#"fo:hyphenate="true""#).count(), 1, "{c}");
// No hyphenated paragraph: nothing changes.
let plain = doc("", "<text:p>Plain.</text:p>");
assert!(!plain.settings.auto_hyphenation && paras(&plain)[0].suppress_hyphens.is_none());
}
#[test]
fn a_tables_alignment_width_and_row_heights_are_kept() {
let styles = r#"<style:style style:name="T1" style:family="table"><style:table-properties style:width="3in" style:rel-width="50%" table:align="center"/></style:style><style:style style:name="R1" style:family="table-row"><style:table-row-properties style:row-height="0.5in"/></style:style><style:style style:name="R2" style:family="table-row"><style:table-row-properties style:min-row-height="0.25in"/></style:style>"#;
let body = r#"<table:table table:style-name="T1"><table:table-column table:number-columns-repeated="2"/><table:table-row table:style-name="R1"><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-row table:style-name="R2"><table:table-cell><text:p>c</text:p></table:table-cell><table:table-cell><text:p>d</text:p></table:table-cell></table:table-row><table:table-row><table:table-cell><text:p>e</text:p></table:table-cell><table:table-cell><text:p>f</text:p></table:table-cell></table:table-row></table:table>"#;
let d = doc(styles, body);
let t = d.body.iter().find_map(|b| b.as_table()).expect("a table");
assert_eq!((t.props.align, t.props.width_pct), (Some(Align::Center), Some(50.0)));
let heights: Vec<(Option<f32>, HeightRule)> = t.rows.iter().map(|r| (r.props.height, r.props.height_rule)).collect();
assert_eq!(heights, [(Some(36.0), HeightRule::Exact), (Some(18.0), HeightRule::AtLeast), (None, HeightRule::Auto)]);
let c = content_of(&export(&d).unwrap());
for want in [r#"style:rel-width="50%" table:align="center""#, r#"style:row-height="36pt""#, r#"style:min-row-height="18pt""#] {
assert!(c.contains(want), "{want} in {c}");
}
// A table filling the margins stays so.
let plain = doc("", r#"<table:table><table:table-row><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row></table:table>"#);
let pt = plain.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!((pt.props.align, pt.props.width_pct), (None, None));
assert!(content_of(&export(&plain).unwrap()).contains(r#"table:align="margins""#));
}
+38 -3
View File
@@ -132,6 +132,10 @@ pub(super) struct Writer {
list_xml: HashMap<u32, String>,
/// Table styles: (name, column widths).
tables: Vec<Vec<f32>>,
/// Each table's alignment and relative width (percent), by table.
table_looks: Vec<(Option<Align>, Option<f32>)>,
/// Row styles: their `style:table-row-properties` attributes.
row_styles: Vec<String>,
/// The master page (and page-number restart) each table in `tables` starts.
table_starts: Vec<Option<SecStart>>,
/// Automatic styles of the columned `text:section`s written.
@@ -258,11 +262,23 @@ impl Writer {
if p.extra.as_ref().and_then(|x| x.bidi) == Some(true) {
props.push_str(" style:writing-mode=\"rl-tb\"");
}
// The children go after a separator in the key.
// How its lines break across pages (§20.247, §20.251, §20.250).
if p.extra.as_ref().and_then(|x| x.keep_lines) == Some(true) {
props.push_str(" fo:keep-together=\"always\"");
}
if p.extra.as_ref().and_then(|x| x.widow_control) == Some(false) {
props.push_str(" fo:widows=\"0\" fo:orphans=\"0\"");
}
// The children go after a separator in the key; text properties (hyphenation, a text
// property in ODF, §20.217) after another.
if !children.is_empty() {
props.push('\u{1}');
props.push_str(&children);
}
if p.extra.as_ref().and_then(|x| x.suppress_hyphens) == Some(false) {
props.push('\u{2}');
props.push_str(" fo:hyphenate=\"true\"");
}
let master = start.map(|(m, _)| m.clone()).unwrap_or_default();
if props.is_empty() && master.is_empty() {
return parent;
@@ -645,9 +661,11 @@ impl Writer {
}
for ((parent, props, master), n) in &self.para_styles {
let master = if master.is_empty() { String::new() } else { format!(" style:master-page-name=\"{}\"", x(master)) };
let (props, text) =
props.split_once('\u{2}').map_or((props.as_str(), String::new()), |(p, t)| (p, format!("<style:text-properties{t}/>")));
let (attrs, children) = props.split_once('\u{1}').unwrap_or((props, ""));
s.push_str(&format!(
"<style:style style:name=\"{n}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{master}><style:paragraph-properties{attrs}>{children}</style:paragraph-properties></style:style>"
"<style:style style:name=\"{n}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{master}><style:paragraph-properties{attrs}>{children}</style:paragraph-properties>{text}</style:style>"
));
}
for (i, kinds) in self.list_styles.iter().enumerate() {
@@ -708,8 +726,18 @@ impl Writer {
}
None => (String::new(), String::new()),
};
// Aligned, or narrower than the text: as wide as its columns, with its relative width.
let (align, pct) = self.table_looks.get(i).copied().unwrap_or_default();
let align = match align {
Some(Align::Left) => "left",
Some(Align::Center) => "center",
Some(Align::Right) => "right",
_ if pct.is_some() => "left",
_ => "margins",
};
let rel = pct.map(|p| format!(" style:rel-width=\"{}%\"", (p * 100.0).round() / 100.0)).unwrap_or_default();
s.push_str(&format!(
"<style:style style:name=\"Table{tn}\" style:family=\"table\"{master}><style:table-properties style:width=\"{}\" table:align=\"margins\"{number}/></style:style>",
"<style:style style:name=\"Table{tn}\" style:family=\"table\"{master}><style:table-properties style:width=\"{}\"{rel} table:align=\"{align}\"{number}/></style:style>",
pt(total)
));
for (c, w) in widths.iter().enumerate() {
@@ -720,6 +748,13 @@ impl Writer {
));
}
}
for (i, props) in self.row_styles.iter().enumerate() {
s.push_str(&format!(
"<style:style style:name=\"{}Row{}\" style:family=\"table-row\"><style:table-row-properties{props}/></style:style>",
self.prefix,
i + 1
));
}
for (i, props) in self.graphic_styles.iter().enumerate() {
// A text box's frame derives from LibreOffice's Frame style, without which
// LibreOffice ignores its border (`fo:border`) and outlines it as a drawing shape.
+16 -1
View File
@@ -90,6 +90,7 @@ impl Writer {
vec![468.0 / cols as f32; cols]
};
self.tables.push(widths);
self.table_looks.push(t.extra.as_ref().map(|x| (x.align, x.width_pct.filter(|p| *p < 100.0))).unwrap_or_default());
self.table_starts.push(self.master.take());
let tn = format!("{}{}", self.prefix, self.tables.len());
out.push_str(&format!("<table:table table:name=\"Table{tn}\" table:style-name=\"Table{tn}\">"));
@@ -101,7 +102,21 @@ impl Writer {
if ri == 0 && header_rows > 0 {
out.push_str("<table:table-header-rows>");
}
out.push_str("<table:table-row>");
// A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`).
match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, exact)) => {
let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) };
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
None => out.push_str("<table:table-row>"),
}
let mut g = 0usize;
for c in row {
let span = c.colspan.clamp(1, 63) as usize;
+1 -1
View File
@@ -409,7 +409,7 @@ impl Reader {
let rights = std::mem::take(&mut self.table_rights);
let widths = column_grid(&mut rows, &rights);
// Word draws only the borders the cells name: no grid style, each cell its own.
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true }));
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true, extra: None }));
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** 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; previoLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** 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,Line truncated
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;
@@ -57,7 +57,7 @@ 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); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~82% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); buttons, images, hidden and other controls aren't read, and a control's size and look aren't kept. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, charaLine truncated
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~82% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); buttons, images, hidden and other controls aren't read, and a control's size and look aren't kept. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script foLine truncated
| `.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`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). 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); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
@@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | trivial | ODT: a page break after a paragraph (lost, so a two-page document saved as one page), lines kept together, widow and orphan control, hyphenation (the document hyphenates when a paragraph does), a table's alignment and width and its rows' heights are read and written; a LibreOffice document with them round-trips at 573 px instead of 4 407 and keeps its pages (`odt::tests_more::flow_breaks`) |
| 2026-11-01 | trivial | Layout: an in-line picture, shape or chart moves with its run's raise or lowering (Word's `w:position`), the line growing to hold it. ODT: an in-line frame centred on or hanging from the baseline keeps that place (read as that lowering, written back as `from-top`) instead of standing on the baseline; leading's extra space after the last line is kept; a text box's frame derives from the Frame style, without which LibreOffice ignored its border. LibreOffice round trips: shapes 13 864 → 1 273 px, spacing 2 973 → 706 px, text box 736 → 0 px (`layout::tests::an_inline_picture_moves_with_its_runs_position`, `odt::tests_more::floats::an_inline_frame_keeps_its_place_on_the_baseline`, `odt::tests_more::text_boxes::a_text_box_frame_derives_from_the_frame_style`) |
| 2026-11-01 | trivial | ODT: form controls (`office:forms`, `draw:control`) — text fields, text areas, check boxes, radio buttons, list and combo boxes, date fields — were dropped with their values; they open as content controls (which save to DOCX as Word's) and are written back as form controls LibreOffice shows (`odt::tests_more::forms`, `odt_forms.rs`) |
| 2026-11-01 | trivial | ODT: password-protected files open with their password (AES-256-CBC, SHA-256/SHA-1 start key, PBKDF2 — LibreOffice's encryption; Blowfish refused with a message) instead of failing, and save with a password writes them encrypted as LibreOffice does, which LibreOffice opens (`odt::tests_more::encryption`, `odt_password.rs`) |