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
2026-11-01 18:30:41 +00:00
parent b87c80cdc7
commit 6925818110
17 changed files with 376 additions and 26 deletions
+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 }));
}
}
}
File diff suppressed because one or more lines are too long