Files
wordcraft/crates/formats/src/model/from_doc.rs
T
92eac2aa7c ODT: text can turn off what its style turns on (gap 19) (#5724)
* ODT gap 19: text can turn off what its style turns on

A word not bold in a bold heading showed bold on screen, and came back bold in
LibreOffice after a save: an "off" on a run was dropped because the run's own
formatting said nothing, so the paragraph's or character style's showed through.
Keep it on the run for bold, italic, underline, strike-through, capitals and
small capitals, and write it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: an Asian or complex normal says nothing of Latin text (gap 19)

With "off" now kept on the text, `style:font-weight-complex="normal"` alone took a
word's bold in a bold heading, and saved it as `fo:font-weight="normal"`. Those
attributes stay fallbacks only to turn it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: strike-through turned off over a double one stays off (gap 19)

A style or span saying line-through none over a double strike still showed the double strike, on screen and after a save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: a missing full stop in the ODT revision row (gap 19)

Docs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT: a span's character style wins over its paragraph turning off (gap 19)

A paragraph saying not bold made a span in a bold character style not bold too, on screen and after a save. The span's style now wins, for bold, italic, underline, strike-through and capitals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:33:58 +01:00

1242 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::types::{
BoxLook, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_GROUP_DEPTH, Member, Meta,
Para, RevMark, Spacing,
};
use super::{
Arc, Block, Blocks, CharProps, Document, InlineObject, NumFormat, OBJ, PAGE_BREAK, ParaProps, Paragraph, StyleKind, Table, TextColor, Underline,
VMerge, VertAlign,
};
pub(super) fn uses_kind(blocks: &[FBlock], k: Kind, depth: usize) -> bool {
depth <= MAX_DEPTH
&& blocks.iter().any(|b| match b {
FBlock::Para(p) => p.kind == k,
FBlock::Table(t) => t.rows.iter().flatten().any(|c| uses_kind(&c.blocks, k, depth + 1)),
})
}
// ---------------------------------------------------------------------------------------------
// Document → Flow
/// Is this a monospace family name?
pub fn is_mono(font: &str) -> bool {
let f = font.to_ascii_lowercase();
f.contains("courier") || f.contains("mono") || f.contains("consolas") || f.contains("menlo") || f.contains("code") || f == "monaco"
}
fn is_code_style(doc: &Document, id: &str) -> bool {
let names = |s: &str| {
let l = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
l == "code" || l.contains("preformatted") || l == "plaintext" || l == "codeblock" || l == "sourcecode"
};
doc.styles.chain(id).iter().any(|s| names(&s.id) || names(&s.name)) || names(id)
}
/// Character formatting from a run's character style chain plus direct formatting (the
/// paragraph style is left out: it is expressed by the paragraph kind).
fn effective(doc: &Document, cp: &CharProps) -> CharProps {
let mut out = CharProps::default();
if let Some(cs) = cp.style.as_deref() {
for s in doc.styles.chain(cs) {
if s.kind == StyleKind::Character {
out.overlay(&s.chr);
}
}
}
let mut direct = cp.clone();
direct.style = None;
out.overlay(&direct);
out
}
fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
let e = effective(doc, cp);
let code = !in_code && e.font.as_deref().is_some_and(is_mono);
Fmt {
bold: e.bold.unwrap_or(false),
italic: e.italic.unwrap_or(false),
underline: e.underline.is_some_and(|u| u != Underline::None) && e.link.is_none(),
strike: e.strike.unwrap_or(false) || e.double_strike.unwrap_or(false),
code,
sup: e.vert_align == Some(VertAlign::Superscript),
sub: e.vert_align == Some(VertAlign::Subscript),
link: e.link.as_deref().map(Arc::from),
color: match e.color {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
},
size: e.size,
font: if code || in_code { None } else { e.shown_font().map(Arc::from) },
background: e.highlight.and_then(|h| h.rgb()).or(e.shading),
ins: None,
del: None,
fmt_change: None,
extra: None,
}
}
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
/// its custom character style by name (only its direct formatting then its own) and a
/// monospaced font by its name.
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
let mut f = fmt_of(doc, props, in_code);
if !opts.chars {
return f;
}
let custom = props.style.as_deref().filter(|id| is_custom_char_style(doc, id));
let direct;
let props_now = match custom {
Some(_) => {
direct = CharProps { style: None, ..props.clone() };
f = fmt_of(doc, &direct, in_code);
&direct
}
None => props,
};
let e = effective(doc, props_now);
f.extra = extra_of(&e);
// Kerning on or off where it differs from the document's default.
let kerns = |k: Option<f32>| k.is_some_and(|k| k.is_finite() && k > 0.0);
let (kern, default) = (kerns(e.kern.or(doc.styles.default_chr.kern)), kerns(doc.styles.default_chr.kern));
if kern != default {
f.extra.get_or_insert_with(Default::default).kern = Some(if kern { 1.0 } else { 0.0 });
}
if let Some(id) = custom {
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
}
if f.code {
f.code = false;
f.font = e.shown_font().map(Arc::from);
}
f
}
/// The formatting in `e` (effective character properties) that only [`Fmt::extra`] holds;
/// `None` when it sets none of it.
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
let set = |v: Option<bool>| v.filter(|b| *b);
let x = CharProps {
// What it turns off, over what the style of its paragraph or span turns on, too.
bold: e.bold.filter(|b| !*b),
italic: e.italic.filter(|b| !*b),
strike: e.strike.filter(|b| !*b),
underline: e.underline.filter(|u| *u != Underline::Single),
underline_color: e.underline_color,
double_strike: set(e.double_strike),
caps: e.caps,
small_caps: e.small_caps,
hidden: set(e.hidden),
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
emphasis: e.emphasis.filter(|m| *m != wordcraft_doc::props::EmphasisMark::None),
position: e.position.filter(|v| *v != 0.0 && v.is_finite()),
outline: set(e.outline),
shadow: set(e.shadow),
emboss: set(e.emboss),
engrave: set(e.engrave),
border: e.border.filter(|b| b.is_visible()),
lang: e.lang.clone().filter(|l| !l.is_empty()),
font_cs: e.font_cs.clone().filter(|f| !f.is_empty()),
font_ea: e.font_ea.clone().filter(|f| !f.is_empty()),
size_cs: e.size_cs.filter(|s| s.is_finite()),
bold_cs: e.bold_cs,
italic_cs: e.italic_cs,
lang_bidi: e.lang_bidi.clone().filter(|l| !l.is_empty()),
lang_ea: e.lang_ea.clone().filter(|l| !l.is_empty()),
..Default::default()
};
(x != CharProps::default()).then(|| Box::new(x))
}
/// Whether character style `id` of `doc` is one of its own rather than one WordCraft gives links,
/// note and comment marks or placeholders.
pub fn is_custom_char_style(doc: &Document, id: &str) -> bool {
let builtin =
["DefaultParagraphFont", "Hyperlink", "FollowedHyperlink", "FootnoteReference", "EndnoteReference", "CommentReference", "PlaceholderText"];
!builtin.contains(&id) && doc.styles.get(id).is_some_and(|s| s.kind == wordcraft_doc::styles::StyleKind::Character)
}
/// Whether paragraph style `id` of `doc` is one of its own rather than one the flow model's
/// paragraph kinds, lists and notes stand for (Normal, the headings, Title, Quote, Code, List
/// Paragraph, the note texts).
pub fn is_custom_style(doc: &Document, id: &str) -> bool {
// The styles the kinds stand for, and the names formats write fixed styles under.
let kinds =
["Normal", "Title", "Quote", super::types::CODE_STYLE, "FootnoteText", "EndnoteText", "Standard", "Footnote", "Endnote", "Quotations"];
let heading = id.strip_prefix("Heading").is_some_and(|n| n.len() == 1 && n.chars().all(|c| c.is_ascii_digit()));
!heading && !kinds.contains(&id) && doc.styles.get(id).is_some_and(|s| s.kind == wordcraft_doc::styles::StyleKind::Paragraph)
}
/// Whether `doc`'s tab stops are measured from each paragraph's indent (an ODF document whose
/// file says so): [`Para::extra`]'s tab stops then are too.
pub fn tabs_from_indent(doc: &Document) -> bool {
doc.settings.odf.is_some_and(|o| o.tabs_from_indent)
}
/// The paragraph formatting only [`Para::extra`] holds that `p` sets itself; `None` when none.
fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box<wordcraft_doc::props::ParaProps>> {
let tabs = p.tabs.clone().filter(|t| !t.is_empty()).map(|mut tabs| {
// Measured from the paragraph's indent where the document measures them so (ODT).
if tabs_from_indent(doc) {
let indent = doc.styles.resolve_para(p).indent_left;
for t in &mut tabs {
t.pos = (t.pos - indent).clamp(-1584.0, 1584.0);
}
}
tabs
});
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
// heading style's where the style is based on one).
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
let x = wordcraft_doc::props::ParaProps {
tabs,
shading: p.shading,
style,
contextual_spacing: p.contextual_spacing,
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),
outline_level,
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))
}
/// Revision `i` as a flow mark (unknown author and date when there is none).
fn rev_mark(doc: &Document, i: u32) -> RevMark {
doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone(), id: Some(i) }).unwrap_or_default()
}
/// The insertion and deletion `cp` tracks, as flow marks.
fn rev_marks(doc: &Document, cp: &CharProps) -> (Option<RevMark>, Option<RevMark>) {
(cp.ins.map(|i| rev_mark(doc, i)), cp.del.map(|i| rev_mark(doc, i)))
}
/// Paragraph kind and list membership of a document paragraph.
pub fn para_kind(doc: &Document, p: &Paragraph) -> (Kind, Option<ListInfo>) {
let blank = p.plain_text().trim().is_empty() && p.objects.iter().all(|o| o.is_marker());
props_kind(doc, &p.props, blank)
}
/// Paragraph kind and list membership of paragraph properties; `blank` = the paragraph shows
/// nothing (a bottom border then makes it a rule).
fn props_kind(doc: &Document, props: &ParaProps, blank: bool) -> (Kind, Option<ListInfo>) {
let rp = doc.styles.resolve_para(props);
let style = props.style.as_deref().unwrap_or("Normal");
// A heading's kind is its style's (Heading 2 looks as Heading 2 whatever its outline level,
// which the paragraph may set apart); else its outline level's.
let style_level =
doc.styles.chain(style).iter().find_map(|s| s.id.strip_prefix("Heading").and_then(|n| n.parse::<u8>().ok()).filter(|n| (1..=9).contains(n)));
let kind = if let Some(l) = style_level.filter(|_| rp.outline_level.is_some()) {
Kind::Heading(l.min(6))
} else if let Some(l) = rp.outline_level {
Kind::Heading((l + 1).min(6))
} else if doc.styles.chain(style).iter().any(|s| s.id == "Title") {
Kind::Title
} else if doc.styles.chain(style).iter().any(|s| s.id == "Quote" || s.id == "IntenseQuote") {
Kind::Quote
} else if is_code_style(doc, style) {
Kind::Code
} else if blank && rp.borders.and_then(|b| b.bottom).is_some_and(|b| b.is_visible()) {
Kind::Rule
} else {
Kind::Normal
};
let list = rp.numbering.and_then(|n| {
let lv = doc.numbering.level(n.num, n.level)?;
Some(ListInfo {
ordered: !matches!(lv.format, NumFormat::Bullet | NumFormat::None),
level: n.level.min(8),
start: lv.start,
list: n.num,
label: Some(super::types::Label::of_level(lv.format, &lv.text).placed(lv.indent, lv.hanging, lv.align)),
})
});
(kind, list)
}
fn media_ext(key: &str, data: &[u8]) -> String {
match image::guess_format(data) {
Ok(image::ImageFormat::Png) => "png".into(),
Ok(image::ImageFormat::Jpeg) => "jpeg".into(),
Ok(image::ImageFormat::Gif) => "gif".into(),
Ok(image::ImageFormat::WebP) => "webp".into(),
Ok(image::ImageFormat::Bmp) => "bmp".into(),
_ => key.rsplit('.').next().filter(|e| e.len() <= 5 && *e != key).unwrap_or("png").to_ascii_lowercase(),
}
}
/// The flow inlines for a note reference, comment mark or field `o` of `doc`, when `opts` asks
/// for them; `None` for other objects. `f` is the formatting of the run `o` is in.
fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Option<Vec<Inline>> {
Some(match o {
InlineObject::NoteRef { kind, id, custom } if opts.notes => {
// The note's content without its own mark (a note holds no notes).
let story = doc.parts.get(id).map(|part| &part.blocks);
let mut blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, ..opts })).unwrap_or_default();
// The space after the mark is the mark's (see `to_doc`'s `note`); a tab or more
// spaces there are the note's own.
if let Some(FBlock::Para(first)) = blocks.first_mut() {
let k = first.inlines.iter().take_while(|i| matches!(i, Inline::Text(t, _) if t.is_empty())).count();
first.inlines.drain(..k);
if let Some(Inline::Text(t, _)) = first.inlines.first_mut()
&& t.starts_with(' ')
{
t.remove(0);
}
}
let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote;
vec![Inline::Note { endnote, custom: custom.clone(), blocks, fmt: f.clone() }]
}
// The comment, then the replies in its thread (replies to replies too, each answering the
// comment that starts the thread), which have no place of their own.
InlineObject::CommentStart { id } if opts.comments => {
let Some(c) = doc.comments.get(id) else { return Some(Vec::new()) };
let mut out = vec![Inline::Comment { id: *id, comment: Box::new(flow_comment(doc, c, None, opts)) }];
let mut thread = std::collections::BTreeSet::from([*id]);
let mut k = 0;
while let Some(Inline::Comment { id: at, .. }) = out.get(k) {
let at = *at;
k += 1;
for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(at)) {
if thread.insert(*rid) {
out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) });
}
}
}
out
}
InlineObject::CommentEnd { id } if opts.comments => {
if doc.comments.contains_key(id) {
vec![Inline::CommentEnd(*id)]
} else {
Vec::new()
}
}
// A text box's content, without notes, comments or boxes of its own.
// A drawing shape, text box or group (pictures have their own place below).
InlineObject::Shape { .. } | InlineObject::Group { .. } if opts.boxes => vec![drawing_inline(doc, o, opts, 0, (1.0, 1.0))?],
InlineObject::FieldStart { instr, .. } if opts.fields => vec![Inline::FieldStart(instr.clone())],
InlineObject::FieldEnd if opts.fields => vec![Inline::FieldEnd],
// Where a bookmark ends, so it covers its text (Word's `_GoBack` is the last edit's spot).
InlineObject::BookmarkEnd { name } if opts.fields && name != "_GoBack" => vec![Inline::AnchorEnd(name.clone())],
InlineObject::Field { instr, result, locked, .. } if opts.fields => {
vec![Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone(), locked: *locked }]
}
_ => return None,
})
}
/// Comment `c` (a reply to `parent`) of `doc` as a flow comment: its text holds no comments or
/// notes of its own.
/// The flow inline for picture, shape or group `o` of `doc` (a member of groups `depth` deep),
/// its size scaled by `scale` (a group's members are in its own coordinates). A shape or text
/// box keeps its text, but not notes, comments or boxes of its own. `None` for what a flow has
/// no drawing for: a freeform's paths, a picture whose data is missing.
fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize, scale: (f32, f32)) -> Option<Inline> {
use wordcraft_doc::para::ShapeKind;
let (sx, sy) = scale;
match o {
// A freeform needs its paths.
InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, extra, freeform, .. }
if *kind != ShapeKind::Freeform || freeform.is_some() =>
{
// A connector stays one in formats that glue them (ODT, with boxes); elsewhere it is a
// straight line.
let kind = match kind {
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector if !opts.boxes => ShapeKind::Line,
k => *k,
};
let inner = FlowOpts { notes: false, comments: false, boxes: false, ..opts };
let part = story.and_then(|id| doc.parts.get(&id));
let blocks = part.map(|p| flow_blocks(doc, &p.blocks, 0, inner)).unwrap_or_default();
let look = BoxLook {
fill: fill.clone().filter(|_| kind != ShapeKind::Line),
line: stroke.filter(|_| stroke_width.is_finite() && *stroke_width > 0.0).map(|c| (c, stroke_width.min(12.0))),
dash: extra.dash.clone(),
arrows: (extra.arrow_start, extra.arrow_end),
inset: part.and_then(|p| p.body.inset.is_some().then(|| p.body.insets_pt())),
auto_fit: part.is_some_and(|p| p.body.auto_fit),
vert: part.map(|p| p.body.vert).unwrap_or_default(),
// Kept only while it is still the freeform it was read as.
geometry: extra.odf_geometry.clone().filter(|_| (kind == ShapeKind::Freeform && freeform.is_some()) || kind == ShapeKind::Rectangle),
conn: (opts.boxes && (extra.id != 0 || extra.start.is_some() || extra.end.is_some()))
.then(|| Box::new(crate::model::Conn { id: extra.id, start: extra.start, end: extra.end })),
// A text box is named after its story; a linked one names the box it continues in.
chain: story.filter(|_| opts.boxes).map(|id| {
let next = part.and_then(|p| p.body.next).map(|n| format!("Frame{n}"));
Box::new(crate::model::Chain { name: format!("Frame{id}"), next })
}),
};
let path = freeform.clone().filter(|_| kind == ShapeKind::Freeform);
Some(Inline::Shape { kind, w: w * sx, h: h * sy, float: kept_float(float), blocks, look, path })
}
InlineObject::Image { media, w, h, alt, float, ole, crop, .. } => {
let data = doc.media.get(media)?;
let alt = if o.is_decorative() { "" } else { alt.as_str() };
Some(Inline::Image(Img {
data: data.clone(),
ext: media_ext(media, data),
w: w * sx,
h: h * sy,
alt: alt.to_string(),
float: kept_float(float),
object: ole.as_deref().filter(|_| opts.boxes).and_then(|e| super::odf_object::load(doc, e)),
crop: if opts.paras { *crop } else { [0.0; 4] },
}))
}
InlineObject::Group { w, h, float, ch_w, ch_h, children, .. } if depth < MAX_GROUP_DEPTH => {
let ratio = |outer: f32, inner: f32| {
let r = outer / inner;
if r.is_finite() && r > 0.0 { r } else { 1.0 }
};
let inner = (sx * ratio(*w, *ch_w), sy * ratio(*h, *ch_h));
let members: Vec<Member> = children
.iter()
.filter_map(|c| {
drawing_inline(doc, &c.obj, opts, depth + 1, inner).map(|inline| Member { x: c.x * inner.0, y: c.y * inner.1, inline })
})
.collect();
(!members.is_empty()).then(|| Inline::Group { w: w * sx, h: h * sy, float: kept_float(float), members })
}
_ => None,
}
}
/// The placement a flow keeps for a drawing: when it floats, or turns or mirrors it in line.
fn kept_float(f: &wordcraft_doc::para::Float) -> Option<Box<wordcraft_doc::para::Float>> {
let turned = f.rot.is_finite() && f.rot.rem_euclid(360.0) > 0.001 && f.rot.rem_euclid(360.0) < 359.999;
(f.wrap != wordcraft_doc::para::Wrap::Inline || turned || f.flip_h || f.flip_v).then(|| Box::new(f.clone()))
}
fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>, opts: FlowOpts) -> FComment {
let story = doc.parts.get(&c.part).map(|p| &p.blocks);
let blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, comments: false, ..opts })).unwrap_or_default();
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
}
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
/// style's list, not a list of its own: it states that level, and the heading style of that level
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
use wordcraft_doc::props::NumRef;
p.props.outline_level == Some(li.level)
&& li.level < 6
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
}
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
/// unless `opts` asks for them, and none for a heading numbered only through its style or
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
/// numbers instead, in its first six levels.
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
let mut li = list?;
if !opts.lists {
(li.list, li.label) = (0, None);
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
return None;
}
Some(li)
}
/// The spacing and indents a paragraph sets itself (its style's are not included).
fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing {
Spacing {
before: p.space_before,
after: p.space_after,
line: p.line_spacing,
left: p.indent_left,
right: p.indent_right,
first: p.indent_first,
..Default::default()
}
}
/// A document paragraph as flow paragraphs: a page break character inside the text ends the
/// paragraph, and the text after it starts a new one that carries `page_break`.
/// An object's text in the flow: ASK and SET show nothing, whatever result they hold.
fn shown_text(o: &InlineObject) -> &str {
match o {
InlineObject::Field { instr, .. } if super::shows_nothing(instr) => "",
o => o.plain_text(),
}
}
pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
flow_paras_in(doc, p, FlowOpts::default())
}
/// [`flow_paras`]; `tracked` keeps tracked changes: deleted text stays, and text carries its
/// insertion and deletion marks.
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
let tracked = opts.tracked;
let (kind, list) = para_kind(doc, p);
let list = flow_list(doc, kind, list, p, opts);
let mut done: Vec<Para> = Vec::new();
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
let mut out = Para {
kind,
list,
align: p.props.align,
page_break: p.props.page_break_before.unwrap_or(false),
keep_next: p.props.keep_next.unwrap_or(false),
inlines: Vec::new(),
spacing: direct_spacing(&p.props),
borders,
section: None,
mark_ins: None,
mark_del: None,
fmt_change: None,
extra: None,
mark: None,
kept: None,
wrap: None,
index: None,
group: None,
};
if opts.paras {
out.extra = para_extra(doc, &p.props);
}
let in_code = kind == Kind::Code;
let mut k = 0usize;
let mut control = OpenControl::default();
for (range, props) in p.run_ranges() {
let Some(text) = p.text.get(range.clone()) else { continue };
// Deleted objects and page breaks stay out even when tracking: the flow has no marks for them.
let hidden = props.hidden.unwrap_or(false) || props.del.is_some();
// Formats that keep hidden text get it, marked hidden.
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
let mut f = run_fmt(doc, props, in_code, opts);
if tracked {
(f.ins, f.del) = rev_marks(doc, props);
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
}
let mut buf = String::new();
for c in text.chars() {
if c == PAGE_BREAK {
if !hidden {
out.push_text(&std::mem::take(&mut buf), &f);
let next = Para {
kind,
list: None,
align: p.props.align,
page_break: true,
keep_next: p.props.keep_next.unwrap_or(false),
inlines: Vec::new(),
spacing: direct_spacing(&p.props),
borders,
section: None,
mark_ins: None,
mark_del: None,
fmt_change: None,
extra: None,
mark: None,
kept: None,
wrap: None,
index: None,
group: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
out.page_break |= prev.page_break;
out.list = prev.list;
} else {
done.push(prev);
}
}
continue;
}
if c != OBJ {
buf.push(c);
continue;
}
let obj = p.objects.get(k);
k += 1;
control.interrupt(obj, &mut out, &f);
// Hidden text a format leaves out, a deleted object, or a hidden drawing: not in the
// copy (a format keeping hidden text keeps its objects too, as it keeps the text).
if hidden_obj || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) {
continue;
}
let from = out.inlines.len();
if let Some(items) = obj.and_then(|o| rich_inlines(doc, o, &f, opts)) {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
out.inlines.extend(items);
raise_drawings(&mut out.inlines, from, props.position.filter(|_| opts.paras));
continue;
}
match obj {
Some(o @ InlineObject::Image { media, w, h, alt, float, ole, crop, .. }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
// A picture marked decorative keeps its description in the document, but
// exports with none (`alt=""`), so screen readers skip it.
let alt = if o.is_decorative() { "" } else { alt.as_str() };
match doc.media.get(media) {
Some(data) => out.inlines.push(Inline::Image(Img {
data: data.clone(),
ext: media_ext(media, data),
w: *w,
h: *h,
alt: alt.to_string(),
float: kept_float(float).filter(|_| opts.paras),
object: ole.as_deref().filter(|_| opts.boxes).and_then(|e| super::odf_object::load(doc, e)),
crop: if opts.paras { *crop } else { [0.0; 4] },
})),
None if !alt.is_empty() => buf.push_str(alt),
None => {}
}
}
Some(InlineObject::Graphic { alt, w, h, float, graphic }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
out.inlines.extend(graphic_inline(doc, (alt, *w, *h), float, graphic, opts));
}
Some(InlineObject::Equation { linear, display, math }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
// The linear format is the shared form; an equation known only by its
// structure gets it from there.
let linear = if linear.is_empty() { wordcraft_doc::math::to_linear(&math.nodes) } else { linear.clone() };
out.inlines.push(Inline::Equation { linear, display: *display });
}
Some(InlineObject::Ruby { base, ruby, .. }) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
// The base text and reading, in the run's formatting; the guide's own
// attributes (alignment, size, raise) stay in the document model.
out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() });
}
Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => {
if !buf.is_empty() {
out.push_text(&std::mem::take(&mut buf), &f);
}
out.inlines.push(Inline::Anchor(name.clone()));
}
Some(o @ (InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) if control.object(o, opts, &mut buf, &mut out, &f) => {}
Some(o) => buf.push_str(shown_text(o)),
None => {}
}
raise_drawings(&mut out.inlines, from, props.position.filter(|_| opts.paras));
}
if !hidden_text {
control.run_end(&buf, &mut out, &f);
}
}
control.finish(&mut out);
if tracked {
let (ins, del) = rev_marks(doc, &p.mark);
(out.mark_ins, out.mark_del) = (ins.map(Box::new), del.map(Box::new));
// The formatting change belongs to the paragraph the mark ends.
out.fmt_change = p.props.fmt_change.as_deref().map(|c| {
let blank = out.inlines.is_empty();
let (kind, mut list) = props_kind(doc, &c.old, blank);
if !opts.lists
&& let Some(li) = &mut list
{
(li.list, li.label) = (0, None);
}
let old = Para { kind, list, align: c.old.align, page_break: c.old.page_break_before.unwrap_or(false), ..Default::default() };
Box::new(FmtChange { mark: rev_mark(doc, c.rev), old })
});
}
// The paragraph that ends a section carries its properties (the last piece of it does).
out.section.clone_from(&p.section);
out.mark = empty_mark(&out, p, opts);
// Still what the file it came from had (a whole paragraph): its markup, for its format. Not
// for one starting a page: the break may be its element's or the one before's (a break after
// it), and only the model says which is written where.
if opts.paras && done.is_empty() && p.props.page_break_before != Some(true) {
// With its comments and its text boxes' text, while they're as read.
out.kept = doc
.kept
.of("odt")
.and_then(|k| {
k.para(p).filter(|xml| (!xml.contains("<office:annotation") || k.comments_as_read(doc, p, xml)) && k.stories_as_read(doc, p))
})
.cloned();
// With the group it is written as read with, if any (see `model::Group`).
out.group =
out.kept.as_ref().and_then(|_| doc.kept.of("odt")?.group(p)).map(|id| Box::new(crate::model::Group { links: Vec::new(), id: Some(id) }));
}
// The sections kept as read that it opens or closes.
if opts.paras {
out.wrap = doc.kept.of("odt").and_then(|k| k.wrap(p)).map(|w| Box::new(w.clone()));
}
done.push(out);
done
}
/// A chart or diagram in a flow: an editable chart for formats that keep charts (ODT), a
/// diagram's drawing as a group for formats that keep shapes, else its alt text.
fn graphic_inline(
doc: &Document,
(alt, w, h): (&str, f32, f32),
float: &wordcraft_doc::para::Float,
graphic: &wordcraft_doc::graphic::Graphic,
opts: FlowOpts,
) -> Option<Inline> {
if let Some(spec) = graphic.chart.as_ref().filter(|_| opts.boxes) {
return Some(Inline::Chart(crate::model::Chart {
spec: spec.clone(),
w,
h,
alt: alt.to_string(),
float: kept_float(float).filter(|_| opts.paras),
source: graphic.source.as_deref().and_then(|e| super::odf_object::load(doc, e)),
}));
}
// A diagram (SmartArt) for formats without one: its drawing as a group of shapes, text boxes
// and pictures every reader draws.
if opts.boxes
&& let Some(group) = graphic_group(doc, (w, h), float, graphic)
{
return Some(group);
}
(!alt.trim().is_empty()).then(|| Inline::Figure(alt.to_string()))
}
/// A graphic's drawing items ([`wordcraft_doc::graphic::GraphicItem`]) as a group `w` × `h`
/// points: its shapes as shapes, its text as borderless text boxes centred on their places, its
/// pictures as pictures. `None` when nothing in it can be drawn so (paths are left out).
fn graphic_group(
doc: &Document,
(w, h): (f32, f32),
float: &wordcraft_doc::para::Float,
graphic: &wordcraft_doc::graphic::Graphic,
) -> Option<Inline> {
use wordcraft_doc::graphic::{GraphicItem, TextAlign};
use wordcraft_doc::para::ShapeKind;
let ratio = |outer: f32, inner: f32| {
let r = outer / inner;
if r.is_finite() && r > 0.0 { r } else { 1.0 }
};
let (sx, sy) = (ratio(w, graphic.w), ratio(h, graphic.h));
let finite = |r: &[f32; 4]| r.iter().all(|v| v.is_finite()) && r[2] >= 0.0 && r[3] >= 0.0;
let plain = BoxLook { fill: None, line: None, inset: Some([0.0; 4]), ..BoxLook::default() };
let members: Vec<Member> = graphic
.items
.iter()
.take(10_000)
.filter_map(|item| match item {
GraphicItem::Shape { rect, kind, fill, stroke, stroke_width } if finite(rect) => {
let look = BoxLook {
fill: fill.map(wordcraft_doc::fill::Fill::solid).filter(|_| *kind != ShapeKind::Line),
line: stroke.filter(|_| stroke_width.is_finite() && *stroke_width > 0.0).map(|c| (c, stroke_width.min(12.0))),
..plain.clone()
};
let inline = Inline::Shape { kind: *kind, w: rect[2] * sx, h: rect[3] * sy, float: None, blocks: Vec::new(), look, path: None };
Some(Member { x: rect[0] * sx, y: rect[1] * sy, inline })
}
GraphicItem::Text { rect, text, size, color, bold, align, font } if finite(rect) && !text.trim().is_empty() => {
let size = if size.is_finite() { size.clamp(1.0, 400.0) } else { 11.0 };
// One line, centred vertically in its place as the graphic draws it.
let line = size * 1.2;
let (bw, bh) = (rect[2] * sx, line.max(1.0));
let y = rect[1] * sy + (rect[3] * sy - bh) / 2.0;
let fmt = Fmt { bold: *bold, color: Some(*color), size: Some(size), font: font.as_deref().map(Arc::from), ..Fmt::default() };
let para = Para {
align: Some(match align {
TextAlign::Left => wordcraft_doc::Align::Left,
TextAlign::Right => wordcraft_doc::Align::Right,
_ => wordcraft_doc::Align::Center,
}),
inlines: vec![Inline::Text(text.clone(), fmt)],
spacing: Spacing { before: Some(0.0), after: Some(0.0), ..Spacing::default() },
..Para::default()
};
let inline = Inline::Shape {
kind: ShapeKind::Rectangle,
w: bw,
h: bh,
float: None,
blocks: vec![FBlock::Para(para)],
look: plain.clone(),
path: None,
};
Some(Member { x: rect[0] * sx, y, inline })
}
GraphicItem::Image { rect, media } if finite(rect) => {
let data = doc.media.get(media)?;
let img = Img {
data: data.clone(),
ext: media_ext(media, data),
w: rect[2] * sx,
h: rect[3] * sy,
alt: String::new(),
float: None,
object: None,
crop: [0.0; 4],
};
Some(Member { x: rect[0] * sx, y: rect[1] * sy, inline: Inline::Image(img) })
}
_ => None,
})
.collect();
if members.is_empty() {
return None;
}
// A group is as big as its members together (ODF 1.3 §10.3.15): an invisible rectangle the
// size of the graphic keeps the room around its drawing.
let frame = Inline::Shape { kind: ShapeKind::Rectangle, w, h, float: None, blocks: Vec::new(), look: plain, path: None };
let all = std::iter::once(Member { x: 0.0, y: 0.0, inline: frame }).chain(members).collect();
Some(Inline::Group { w, h, float: kept_float(float), members: all })
}
/// The mark formatting of `out`, the last piece of `p`, when it holds no text (its height, its
/// drawings' formatting), for
/// formats that keep it (flows asked for paragraph formatting).
fn empty_mark(out: &Para, p: &Paragraph, opts: FlowOpts) -> Option<Box<CharProps>> {
let m = CharProps { ins: None, del: None, fmt_change: None, ..p.mark.clone() };
(opts.paras && out.textless() && !m.is_empty()).then(|| Box::new(m))
}
/// The drawings from `from` on (just added) raised by their run's position `raise` (points): a
/// drawing in line keeps it as its float's `y` (see [`crate::model::Img::float`]).
fn raise_drawings(inlines: &mut [Inline], from: usize, raise: Option<f32>) {
let Some(r) = raise.filter(|r| r.is_finite() && *r != 0.0) else { return };
for i in inlines.iter_mut().skip(from) {
let float = match i {
Inline::Image(img) => &mut img.float,
Inline::Chart(c) => &mut c.float,
Inline::Shape { float, .. } | Inline::Group { float, .. } => float,
_ => continue,
};
let f = float.get_or_insert_with(Default::default);
if f.wrap == wordcraft_doc::para::Wrap::Inline {
f.y = r;
}
}
}
/// A content control of plain text being read (formats with form controls): it, its text so
/// far and that text's formatting.
#[derive(Default)]
struct OpenControl(Option<(Box<wordcraft_doc::control::ContentControl>, String, Option<Fmt>)>);
impl OpenControl {
/// Object `obj` comes: anything but the control's end makes the control its text after all.
fn interrupt(&mut self, obj: Option<&InlineObject>, out: &mut Para, f: &Fmt) {
if !matches!(obj, Some(InlineObject::ControlEnd))
&& let Some((_, text, fmt)) = self.0.take()
{
out.push_text(&text, &fmt.unwrap_or_else(|| f.clone()));
}
}
/// A content control starts or ends (`o`): whether it is one read here (a plain-text
/// control, in a flow that keeps them).
fn object(&mut self, o: &InlineObject, opts: FlowOpts, buf: &mut String, out: &mut Para, f: &Fmt) -> bool {
match o {
InlineObject::ControlStart { control: c } if opts.boxes && !c.block && plain_control(&c.kind) => {
if !buf.is_empty() {
out.push_text(&std::mem::take(buf), f);
}
self.0 = Some((c.clone(), String::new(), None));
true
}
InlineObject::ControlEnd if self.0.is_some() => {
self.close(buf, out, f);
true
}
_ => false,
}
}
/// The control ends: it, with its text (the rest in `buf`), goes into `out`.
fn close(&mut self, buf: &mut String, out: &mut Para, f: &Fmt) {
if let Some((c, mut text, fmt)) = self.0.take() {
text.push_str(&std::mem::take(buf));
out.inlines.push(Inline::Control { control: c, text, fmt: fmt.unwrap_or_else(|| f.clone()) });
}
}
/// A run ends with `buf`: the control's text when one is open, else the paragraph's.
fn run_end(&mut self, buf: &str, out: &mut Para, f: &Fmt) {
match &mut self.0 {
Some((_, text, fmt)) => {
text.push_str(buf);
fmt.get_or_insert_with(|| f.clone());
}
None => out.push_text(buf, f),
}
}
/// The paragraph ends: a control it doesn't close is its text.
fn finish(&mut self, out: &mut Para) {
if let Some((_, text, fmt)) = self.0.take() {
out.push_text(&text, &fmt.unwrap_or_default());
}
}
}
/// A content control that holds plain text, as a form control does.
fn plain_control(kind: &wordcraft_doc::control::ControlKind) -> bool {
use wordcraft_doc::control::ControlKind as K;
matches!(kind, K::Text { .. } | K::CheckBox { .. } | K::DropDown { .. } | K::ComboBox { .. } | K::Date { .. })
}
/// The index kept as read starting at block `i` of `bl`, while the paragraphs it takes are all
/// as read: how many blocks, and its markup.
fn kept_index(doc: &Document, bl: &Blocks, i: usize) -> Option<(usize, std::sync::Arc<str>)> {
let k = doc.kept.of("odt")?;
let p = bl.get(i)?.as_para()?;
let (n, run, xml) = k.index(p)?;
let paras: Vec<&Paragraph> = bl.get(i..i + n)?.iter().filter_map(|b| b.as_para()).collect();
(paras.len() == *n && wordcraft_doc::kept::run_fingerprint(paras.into_iter()) == *run).then(|| (*n, xml.clone()))
}
fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec<FBlock> {
let mut out = Vec::new();
let mut skip = 0usize;
for (i, b) in bl.iter().enumerate() {
if skip > 0 {
skip -= 1;
continue;
}
// An index kept as read, while every paragraph it takes still is what was read: its
// flow paragraphs, the first carrying its markup and how many there are.
if tracked.paras
&& depth == 0
&& let Some(ix) = kept_index(doc, bl, i)
{
let from = out.len();
for b in bl.get(i..i + ix.0).unwrap_or(&[]) {
if let Block::Para(p) = &**b {
out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para));
}
}
let n = out.len() - from;
if let Some(FBlock::Para(first)) = out.get_mut(from) {
first.index = Some(Box::new((ix.1, n)));
}
skip = ix.0.saturating_sub(1);
continue;
}
match &**b {
Block::Para(p) => out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)),
Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth, tracked))),
Block::Table(t) => {
for r in &t.rows {
for c in &r.cells {
out.extend(flow_blocks(doc, &c.blocks, depth, tracked));
}
}
}
}
}
out
}
/// Word's default cell margins (top, left, bottom, right), points.
const WORD_CELL_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4];
/// What a table's style and borders give one cell, worked out as the layout does, for formats
/// that keep cell formatting (ODT) but have no table styles of Word's kind: its shading, its four
/// edges (a side without a line is a "none" border), its vertical alignment, and its text's
/// character formatting and alignment.
struct StyledCell {
fill: Option<wordcraft_doc::props::Rgb>,
borders: wordcraft_doc::props::Borders,
v_align: Option<wordcraft_doc::props::VAlign>,
chr: CharProps,
align: Option<wordcraft_doc::Align>,
/// The style's paragraph formatting (spacing, line spacing) under each paragraph's own.
para: ParaProps,
}
fn styled_cell(t: &Table, style: Option<&wordcraft_doc::styles::TableStyleProps>, (ri, ci): (usize, usize), ncols: usize) -> Option<StyledCell> {
use wordcraft_doc::props::{Border, BorderStyle, Borders};
use wordcraft_doc::styles::{PartSet, TablePart};
let row = t.rows.get(ri)?;
let cell = row.cells.get(ci)?;
let nrows = t.rows.len();
let g = t.grid_col(ri, ci);
let span = cell.span();
let mut out = StyledCell {
fill: None,
borders: Borders::default(),
v_align: None,
chr: CharProps::default(),
align: None,
para: style.map(|s| s.para.clone()).unwrap_or_default(),
};
let mut part_borders = Vec::new();
if let Some(st) = style {
let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0));
for part in PartSet::of_cell(&t.props.look, sizes, (ri, nrows), (g, span, ncols)).iter() {
let f = st.part(part);
out.fill = f.fill.or(out.fill);
out.v_align = f.v_align.or(out.v_align);
out.chr.overlay(&f.chr);
out.align = f.align.or(out.align);
if part != TablePart::WholeTable
&& let Some(b) = f.borders
{
part_borders.push((part, b));
}
}
}
let mut tb = style.and_then(|s| s.parts.borders).unwrap_or_default();
if let Some(own) = t.props.borders {
tb.overlay(&own);
}
let cb = cell.props.borders.unwrap_or_default();
let rowb = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default();
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.grid_after()));
let rb = wordcraft_doc::styles::part_borders(&part_borders, (ri, nrows), (first_g, last_g));
let edge = |own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 };
let top = edge(cb.top.or(if ri == 0 { rowb.top } else { rowb.between }).or(rb.top), ri == 0, tb.top, tb.between);
let bottom = edge(cb.bottom.or(if ri + 1 == nrows { rowb.bottom } else { rowb.between }).or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between);
let left = edge(
cb.left.or(if first_g { rowb.left } else { rowb.inside_v }).or(if first_g { rb.left } else { rb.inside_v }),
first_g,
tb.left,
tb.inside_v,
);
let right = edge(
cb.right.or(if last_g { rowb.right } else { rowb.inside_v }).or(if last_g { rb.right } else { rb.inside_v }),
last_g,
tb.right,
tb.inside_v,
);
out.borders = Borders {
top: top.or(Some(none)),
bottom: bottom.or(Some(none)),
left: left.or(Some(none)),
right: right.or(Some(none)),
between: None,
inside_v: None,
};
Some(out)
}
/// `blocks` with what a table style's character and paragraph formatting changes (see
/// [`wordcraft_doc::styles::StyleSheet::table_para_patch`]) stated on each paragraph and run.
fn with_cell_text(doc: &Document, blocks: &Blocks, chr: &CharProps, align: Option<wordcraft_doc::Align>, para: &ParaProps) -> Blocks {
if chr.is_empty() && align.is_none() && para.is_empty() {
return blocks.clone();
}
blocks
.iter()
.map(|b| match &**b {
Block::Para(p) => {
let mut p = p.clone();
let pstyle = p.props.style.clone();
if !chr.is_empty() {
for r in &mut p.runs {
let mut props = doc.styles.table_char_patch(pstyle.as_deref(), r.props.style.as_deref(), chr);
props.overlay(&r.props);
r.props = props;
}
let mut mark = doc.styles.table_char_patch(pstyle.as_deref(), p.mark.style.as_deref(), chr);
mark.overlay(&p.mark);
p.mark = mark;
}
// A part's alignment is table style paragraph formatting too, under the paragraph's styles.
let table = ParaProps { align: align.or(para.align), ..para.clone() };
if !table.is_empty() {
let mut props = doc.styles.table_para_patch(&p.props, &table);
props.overlay(&p.props);
p.props = props;
}
Arc::new(Block::Para(p))
}
_ => b.clone(),
})
.collect()
}
fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTable {
let mut rows = Vec::new();
// Formats that keep cell formatting get the table style's and the table's borders cell by cell.
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
let bake = tracked.paras;
let ncols =
t.rows.iter().map(|r| r.first_grid() + r.cells.iter().map(|c| c.span()).sum::<usize>() + r.grid_after()).max().unwrap_or(0).max(t.grid.len());
for (ri, r) in t.rows.iter().enumerate() {
let header = r.props.header || (ri == 0 && t.props.look.header_row && t.rows.len() > 1);
let mut cells = Vec::new();
for (ci, c) in r.cells.iter().enumerate() {
let styled = if bake { styled_cell(t, style.as_ref(), (ri, ci), ncols) } else { None };
let mut rowspan = 1;
if c.props.vmerge == VMerge::Restart {
let g = t.grid_col(ri, ci);
for (k, below) in t.rows.iter().enumerate().skip(ri + 1) {
let cont = t.cell_at_grid(k, g).and_then(|i| below.cells.get(i)).is_some_and(|bc| bc.props.vmerge == VMerge::Continue);
if !cont {
break;
}
rowspan += 1;
}
}
let blocks = match &styled {
Some(st) => flow_blocks(doc, &with_cell_text(doc, &c.blocks, &st.chr, st.align, &st.para), depth + 1, tracked),
None => flow_blocks(doc, &c.blocks, depth + 1, tracked),
};
cells.push(Cell {
blocks,
colspan: c.span() as u32,
rowspan,
covered: c.props.vmerge == VMerge::Continue,
header,
shading: c.props.shading.or(styled.as_ref().and_then(|s| s.fill)),
borders: styled.as_ref().map(|s| s.borders).or(c.props.borders),
valign: c.props.valign.or(styled.as_ref().and_then(|s| s.v_align)),
// Formats that keep cell padding get each cell's, its table's or Word's default.
margins: tracked.paras.then(|| c.props.resolved_margins(t.props.cell_margins.unwrap_or(WORD_CELL_MARGINS))),
direction: c.props.text_direction,
// Its background picture, for formats that keep cell formatting (ODT).
picture: c.props.picture.as_deref().filter(|_| tracked.paras).and_then(|p| {
let data = doc.media.get(&p.media)?;
let img = Img {
data: data.clone(),
ext: media_ext(&p.media, data),
w: p.size[0],
h: p.size[1],
alt: String::new(),
float: None,
object: None,
crop: [0.0; 4],
};
Some(Box::new(crate::model::CellPic { img, repeat: p.repeat, align: p.align }))
}),
width: None,
});
}
rows.push(cells);
}
// Without a table style and without visible table or cell borders the table shows no grid.
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));
// 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,
keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(),
fixed: t.props.fixed,
space: [t.props.space_before, t.props.space_after],
header_rows: t.rows.iter().take_while(|r| r.props.header).count(),
}
});
// Kept whole for formats that ask for it: its header rows count even without the rest.
let extra = extra.map(Box::new);
let kept = (tracked.paras && depth == 0).then(|| doc.kept.of("odt").and_then(|k| k.table(t)).cloned()).flatten();
let wrap = (tracked.paras && depth == 0).then(|| doc.kept.of("odt").and_then(|k| k.table_wrap(t)).cloned().map(Box::new)).flatten();
FTable { rows, widths: t.grid.clone(), borderless, extra, kept, wrap }
}
/// The document body as a flow.
pub fn from_doc(doc: &Document) -> Flow {
flow_of(doc, FlowOpts::default())
}
/// [`from_doc`] keeping tracked changes: deleted text stays, and text carries its insertion and
/// deletion marks ([`Fmt::ins`], [`Fmt::del`]), for formats that can write revisions (RTF).
pub fn from_doc_tracked(doc: &Document) -> Flow {
flow_of(doc, FlowOpts { tracked: true, ..Default::default() })
}
/// [`from_doc`] keeping what `opts` asks for.
pub fn from_doc_with(doc: &Document, opts: FlowOpts) -> Flow {
// Paragraphs kept as read are checked against the document's comments and page setup:
// worked out once for the whole document, not once per paragraph.
wordcraft_doc::kept::while_writing(doc, || flow_of(doc, opts))
}
/// `blocks` of `doc` (a header, footer or note) as flow blocks, keeping what `opts` asks for.
pub fn part_flow(doc: &Document, blocks: &Blocks, opts: FlowOpts) -> Vec<FBlock> {
flow_blocks(doc, blocks, 0, opts)
}
fn flow_of(doc: &Document, tracked: FlowOpts) -> Flow {
let c = &doc.core;
Flow {
blocks: flow_blocks(doc, &doc.body, 0, tracked),
meta: Meta {
title: c.title.clone(),
author: c.creator.clone(),
subject: c.subject.clone(),
keywords: c.keywords.clone(),
description: c.description.clone(),
created: c.created.clone(),
},
}
}
/// List labels in document order for list paragraphs of a flow ("1.", "a.", "•"), computed by a
/// simple per-level counter (what text exports show).
#[derive(Default)]
pub struct ListCounter {
counts: [u32; 9],
ordered: [bool; 9],
}
impl ListCounter {
pub fn reset(&mut self) {
*self = ListCounter::default();
}
/// Advance for `li` and return its number (1-based; bullets also count).
pub fn next(&mut self, li: ListInfo) -> u32 {
let lv = li.level.min(8) as usize;
if self.ordered.get(lv).copied() != Some(li.ordered)
&& let (Some(o), Some(c)) = (self.ordered.get_mut(lv), self.counts.get_mut(lv))
{
*o = li.ordered;
*c = 0;
}
let n = match self.counts.get_mut(lv) {
Some(c) => {
if *c == 0 {
*c = li.start.saturating_sub(1);
}
*c = c.saturating_add(1);
*c
}
None => 1,
};
for d in self.counts.iter_mut().skip(lv + 1) {
*d = 0;
}
n
}
pub fn label(&mut self, li: ListInfo) -> String {
let n = self.next(li);
if !li.ordered {
return ["•", "◦", "▪"].get(li.level as usize % 3).copied().unwrap_or("•").to_string();
}
match li.level % 3 {
0 => format!("{n}."),
1 => format!("{}.", wordcraft_doc::section::letters(n)),
_ => format!("{}.", wordcraft_doc::section::roman(n)),
}
}
}