mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
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:
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
¶_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(¶_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""#));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user