From baf6bd58f0217f3e251589bec0bb5d148e34ae21 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:41:34 -0500 Subject: [PATCH] Add craft-flow: the flow model the text formats map to (#1025) * Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * Add craft-flow: the flow model the text formats map to, on craft-wordmodel WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow, FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and from_doc, picture sniffing, colours, base64 and the Dropped report. Only the paths changed (wordcraft_doc is craft_wordmodel); the report helpers are public so the format crates can call them; the image re-encoding helper stays with WordCraft's RTF writer. --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 1 + crates/flow/Cargo.toml | 17 + crates/flow/README.md | 31 + crates/flow/src/color.rs | 196 +++++ crates/flow/src/from_doc.rs | 1250 +++++++++++++++++++++++++++++++ crates/flow/src/lib.rs | 187 +++++ crates/flow/src/media.rs | 147 ++++ crates/flow/src/odf_object.rs | 72 ++ crates/flow/src/to_doc.rs | 1002 +++++++++++++++++++++++++ crates/flow/src/types.rs | 1094 +++++++++++++++++++++++++++ crates/flow/tests/round_trip.rs | 101 +++ 13 files changed, 4108 insertions(+) create mode 100644 crates/flow/Cargo.toml create mode 100644 crates/flow/README.md create mode 100644 crates/flow/src/color.rs create mode 100644 crates/flow/src/from_doc.rs create mode 100644 crates/flow/src/lib.rs create mode 100644 crates/flow/src/media.rs create mode 100644 crates/flow/src/odf_object.rs create mode 100644 crates/flow/src/to_doc.rs create mode 100644 crates/flow/src/types.rs create mode 100644 crates/flow/tests/round_trip.rs diff --git a/Cargo.lock b/Cargo.lock index 1ecb7665c..57e986018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,6 +1166,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-flow" +version = "0.1.0" +dependencies = [ + "craft-wordmodel", + "image", + "log", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dfe4d53f1..d1ec4df26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } craft-gainmap = { path = "crates/gainmap" } +craft-flow = { path = "crates/flow" } craft-gcode = { path = "crates/gcode" } craft-gif = { path = "crates/gif" } craft-hdr = { path = "crates/hdr" } diff --git a/README.md b/README.md index 761074fda..63158ee33 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Rgb { + let h = (h / 360.0).rem_euclid(1.0); + let s = s.clamp(0.0, 1.0); + let l = l.clamp(0.0, 1.0); + if s == 0.0 { + let v = (l * 255.0).round().clamp(0.0, 255.0) as u8; + return Rgb(v, v, v); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let hue_to_rgb = |mut t: f64| -> f64 { + if t < 0.0 { + t += 1.0; + } + if t > 1.0 { + t -= 1.0; + } + if t < 1.0 / 6.0 { + return p + (q - p) * 6.0 * t; + } + if t < 1.0 / 2.0 { + return q; + } + if t < 2.0 / 3.0 { + return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + } + p + }; + let r = (hue_to_rgb(h + 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8; + let g = (hue_to_rgb(h) * 255.0).round().clamp(0.0, 255.0) as u8; + let b = (hue_to_rgb(h - 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8; + Rgb(r, g, b) +} + +/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0, +/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black. +fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb { + let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8; + Rgb(mix(r), mix(g), mix(b)) +} + +/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise. +fn css_alpha(after: Option<&str>) -> f32 { + let Some(a) = after else { return 1.0 }; + let a = a.trim(); + // CSS Colour 4's `none` is a missing component, and a missing alpha is transparent. + if a.eq_ignore_ascii_case("none") { + return 0.0; + } + // A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep + // NaN, and `over_white` would turn the colour black. + let parse = |t: &str| t.parse::().ok().filter(|f| f.is_finite()); + if let Some(p) = a.strip_suffix('%') { + return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0); + } + parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0) +} + +/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name. +/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands +/// on a white page. +pub fn parse_color(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + if let Some(h) = s.strip_prefix('#') { + if (h.len() == 3 || h.len() == 4) && h.is_ascii() { + let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17); + let (r, g, b) = (d(0)?, d(1)?, d(2)?); + // A 4th digit is the alpha, composited over the page like `rgba(...)`'s is. + let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 }; + return Some(over_white(r, g, b, a)); + } + if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) { + let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); + return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0)); + } + return Rgb::parse(h); + } + if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) { + let inner = inner.trim_end_matches(')').trim(); + let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b))); + // Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`). + let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() }; + let alpha = match alpha_part { + Some(a) => css_alpha(Some(a)), + None if parts.len() >= 4 => css_alpha(Some(parts[3])), + _ => 1.0, + }; + let v: Vec = parts + .iter() + .take(3) + .filter_map(|x| { + let x = x.trim(); + if let Some(p) = x.strip_suffix('%') { + p.trim().parse::().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8) + } else { + x.parse::().ok().map(|f| f.clamp(0.0, 255.0).round() as u8) + } + }) + .collect(); + if let [r, g, b] = v[..] { + return Some(over_white(r, g, b, alpha)); + } + return None; + } + if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) { + let inner = inner.trim_end_matches(')').trim(); + let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b))); + let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() }; + let alpha = match alpha_part { + Some(a) => css_alpha(Some(a)), + None if parts.len() >= 4 => css_alpha(Some(parts[3])), + _ => 1.0, + }; + if parts.len() < 3 { + return None; + } + let h_str = parts[0].trim(); + let h: f64 = if let Some(deg) = h_str.strip_suffix("deg") { + deg.trim().parse().ok()? + } else if let Some(rad) = h_str.strip_suffix("rad") { + rad.trim().parse::().ok()? * 180.0 / std::f64::consts::PI + } else if let Some(turn) = h_str.strip_suffix("turn") { + turn.trim().parse::().ok()? * 360.0 + } else { + h_str.parse().ok()? + }; + let parse_pct = |x: &str| -> Option { + let x = x.trim(); + if let Some(p) = x.strip_suffix('%') { + Some((p.trim().parse::().ok()? / 100.0).clamp(0.0, 1.0)) + } else { + Some(x.parse::().ok()?.clamp(0.0, 1.0)) + } + }; + let s = parse_pct(parts[1])?; + let l = parse_pct(parts[2])?; + let Rgb(r, g, b) = hsl_to_rgb(h, s, l); + return Some(over_white(r, g, b, alpha)); + } + Some(match s.as_str() { + "black" => Rgb(0, 0, 0), + "white" => Rgb(255, 255, 255), + "red" => Rgb(255, 0, 0), + "green" => Rgb(0, 128, 0), + "lime" => Rgb(0, 255, 0), + "blue" => Rgb(0, 0, 255), + "yellow" => Rgb(255, 255, 0), + "cyan" | "aqua" => Rgb(0, 255, 255), + "magenta" | "fuchsia" => Rgb(255, 0, 255), + "gray" | "grey" => Rgb(128, 128, 128), + "silver" => Rgb(192, 192, 192), + "maroon" => Rgb(128, 0, 0), + "olive" => Rgb(128, 128, 0), + "navy" => Rgb(0, 0, 128), + "purple" => Rgb(128, 0, 128), + "teal" => Rgb(0, 128, 128), + "orange" => Rgb(255, 165, 0), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eight_digit_hex_rejects_signs() { + assert_eq!(parse_color("#+1+2+3+4"), None); + // #11223344 is alpha 0x44 over white. + assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201))); + } + + #[test] + fn colors_with_alpha_composite_over_white() { + // The secondary text colour of dark-theme sites: solid black would read as the site's + // own emphasis, white is invisible on the page; the composite is what the eye saw. + assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140))); + assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140))); + assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255))); + assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0))); + assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140))); + assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128))); + // A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white. + assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119))); + // `none` is a missing alpha, and a missing alpha is transparent, not the opaque default + // an omitted alpha takes. + assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255))); + assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0))); + // An alpha that is not a finite number is no alpha: the colour stays opaque, not black. + assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0))); + assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0))); + assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0))); + } +} diff --git a/crates/flow/src/from_doc.rs b/crates/flow/src/from_doc.rs new file mode 100644 index 000000000..341ded449 --- /dev/null +++ b/crates/flow/src/from_doc.rs @@ -0,0 +1,1250 @@ +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, para_style: Option<&str>, 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| 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()); + } + // A size stated without a complex-script size leaves the complex size to what is above it + // (a style's, or this size where none says one): said where it isn't this size, since a + // writer that gives every script the one size would otherwise change it. + let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some())); + if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) { + let cs = doc.styles.resolve_char(para_style, props).size_cs; + if f.size != Some(cs) { + f.extra.get_or_insert_with(Default::default).size_cs = Some(cs); + } + } + 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> { + let set = |v: Option| 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 != craft_wordmodel::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 == craft_wordmodel::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 == craft_wordmodel::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: &craft_wordmodel::props::ParaProps) -> Option> { + 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 = craft_wordmodel::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 != craft_wordmodel::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, Option) { + (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) { + 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) { + 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::().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> { + 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 == craft_wordmodel::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 { + use craft_wordmodel::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::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::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 = 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: &craft_wordmodel::para::Float) -> Option> { + let turned = f.rot.is_finite() && f.rot.rem_euclid(360.0) > 0.001 && f.rot.rem_euclid(360.0) < 359.999; + (f.wrap != craft_wordmodel::para::Wrap::Inline || turned || f.flip_h || f.flip_v).then(|| Box::new(f.clone())) +} + +fn flow_comment(doc: &Document, c: &craft_wordmodel::Comment, parent: Option, 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 craft_wordmodel::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, p: &Paragraph, opts: FlowOpts) -> Option { + 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: &craft_wordmodel::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 { + 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 { + let tracked = opts.tracked; + let (kind, list) = para_kind(doc, p); + let list = flow_list(doc, kind, list, p, opts); + let mut done: Vec = 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, p.props.style.as_deref(), 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() { craft_wordmodel::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(" Option { + if let Some(spec) = graphic.chart.as_ref().filter(|_| opts.boxes) { + return Some(Inline::Chart(crate::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 ([`craft_wordmodel::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: &craft_wordmodel::para::Float, + graphic: &craft_wordmodel::graphic::Graphic, +) -> Option { + use craft_wordmodel::graphic::{GraphicItem, TextAlign}; + use craft_wordmodel::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 = 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(craft_wordmodel::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 => craft_wordmodel::Align::Left, + TextAlign::Right => craft_wordmodel::Align::Right, + _ => craft_wordmodel::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> { + 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::Img::float`]). +fn raise_drawings(inlines: &mut [Inline], from: usize, raise: Option) { + 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 == craft_wordmodel::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, String, Option)>); + +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: &craft_wordmodel::control::ControlKind) -> bool { + use craft_wordmodel::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)> { + 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 && craft_wordmodel::kept::run_fingerprint(paras.into_iter()) == *run).then(|| (*n, xml.clone())) +} + +fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec { + 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, + borders: craft_wordmodel::props::Borders, + v_align: Option, + chr: CharProps, + align: Option, + /// The style's paragraph formatting (spacing, line spacing) under each paragraph's own. + para: ParaProps, +} + +fn styled_cell(t: &Table, style: Option<&craft_wordmodel::styles::TableStyleProps>, (ri, ci): (usize, usize), ncols: usize) -> Option { + use craft_wordmodel::props::{Border, BorderStyle, Borders}; + use craft_wordmodel::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 = craft_wordmodel::styles::part_borders(&part_borders, (ri, nrows), (first_g, last_g)); + let edge = |own: Option, outer: bool, outer_b: Option, inner_b: Option| 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 +/// [`craft_wordmodel::styles::StyleSheet::table_para_patch`]) stated on each paragraph and run. +fn with_cell_text(doc: &Document, blocks: &Blocks, chr: &CharProps, align: Option, 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::() + 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::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| 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 craft_wordmodel::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::>(); + 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. + craft_wordmodel::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 { + 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!("{}.", craft_wordmodel::section::letters(n)), + _ => format!("{}.", craft_wordmodel::section::roman(n)), + } + } +} diff --git a/crates/flow/src/lib.rs b/crates/flow/src/lib.rs new file mode 100644 index 000000000..774b5ffd1 --- /dev/null +++ b/crates/flow/src/lib.rs @@ -0,0 +1,187 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// The code came from an app whose lints allow this. +#![allow(clippy::type_complexity)] +//! A small "flow" model every text format maps to and from: paragraphs with a kind (heading, +//! quote, code…), list membership, alignment and formatted inline spans; tables of cells holding +//! blocks. [`to_doc`] and [`from_doc`] convert between it and the full [`Document`]. + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeExamples; + +use std::sync::Arc; + +use craft_wordmodel::numbering::{AbstractNum, Level, ListKind, Num, levels_for}; +use craft_wordmodel::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise}; +use craft_wordmodel::props::{ + Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign, +}; +use craft_wordmodel::section::NumFormat; +use craft_wordmodel::styles::{Style, StyleKind}; +use craft_wordmodel::{Align, Block, Blocks, Document, Paragraph, Revision, RevisionKind, Rgb, Table, para_block}; + +mod color; +mod from_doc; +mod media; +pub mod odf_object; +mod to_doc; +mod types; + +pub use color::parse_color; +pub use from_doc::{ + ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc_with, is_custom_char_style, is_custom_style, is_mono, para_kind, part_flow, + tabs_from_indent, +}; +pub use media::{base64_decode, base64_encode, data_uri, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image}; +pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; +pub use types::{ + Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, 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, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object, + shows_nothing, +}; +pub use types::{Dropped, MAX_TABLE_CELLS, begin_import, dropped}; + +thread_local! { + /// The kinds of [`Dropped`] already reported for the document being imported on this thread. + /// Per thread because a document is parsed and built on one, and the parsers drop things + /// from places that have no log of their own to hand. + static REPORTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ASK and SET show nothing (layout shows no result for them): no text export writes the + /// result they hold, from the document or from a flow with fields; ODT keeps it. + #[test] + fn ask_and_set_fields_export_no_text() { + let mut doc = craft_wordmodel::Document::from_text("Before after"); + if let Ok(p) = doc.para_mut(craft_wordmodel::StoryRef::Body, &craft_wordmodel::Path::top(0)) { + for instr in ["SET name other", "ASK other \"Prompt\""] { + let field = craft_wordmodel::para::InlineObject::Field { + instr: instr.into(), + result: "SECRETVALUE".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; + p.insert_object(7, field, &Default::default()).unwrap(); + } + } + let flow = from_doc_with(&doc, FlowOpts { fields: true, ..Default::default() }); + // The flow keeps the result for ODT. + let kept = flow + .blocks + .iter() + .any(|b| matches!(b, FBlock::Para(p) if p.inlines.iter().any(|i| matches!(i, Inline::Field { result, .. } if result == "SECRETVALUE")))); + assert!(kept); + } + + #[test] + fn percent_decode_requires_hex_digits() { + assert_eq!(percent_decode("%+A"), b"%+A".to_vec()); + assert_eq!(percent_decode("%-A"), b"%-A".to_vec()); + assert_eq!(percent_decode("%G1"), b"%G1".to_vec()); + assert_eq!(percent_decode("%41"), b"A".to_vec()); + } + + #[test] + 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, + extra: None, + kept: None, + wrap: None, + }; + t.insert_covered(); + assert!(t.rows.iter().all(|r| r.len() <= 64), "a row grew past the column cap"); + } + + #[test] + fn base64_round_trip() { + for data in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar", &[0, 255, 128, 7]] { + let e = base64_encode(data); + assert_eq!(base64_decode(&e).unwrap(), data, "{e}"); + } + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + assert!(base64_decode("@@").is_none()); + assert_eq!(data_uri("data:text/plain,a%20b").unwrap(), b"a b"); + } + + #[test] + fn colors() { + assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0))); + assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0))); + assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255))); + assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255))); + // A colour with alpha is composited over the white page it lands on (#0080ff at 50%). + assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255))); + assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3))); + assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3))); + assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129))); + assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0))); + assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0))); + assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255))); + assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128))); + assert_eq!(parse_color("#zz"), None); + assert_eq!(parse_color("rgb(1)"), None); + } + + #[test] + fn list_labels() { + let mut c = ListCounter::default(); + let o = |level| ListInfo { ordered: true, level, start: 1, ..Default::default() }; + assert_eq!(c.label(o(0)), "1."); + assert_eq!(c.label(o(1)), "a."); + assert_eq!(c.label(o(1)), "b."); + assert_eq!(c.label(o(0)), "2."); + assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() }), "•"); + } + + #[test] + fn covered_cells_inserted() { + let mut t = FTable { + rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]], + widths: vec![], + borderless: false, + extra: None, + kept: None, + wrap: None, + }; + t.insert_covered(); + assert_eq!(t.rows[1].len(), 2); + assert!(t.rows[1][0].covered); + } + + #[test] + fn trim_drops_a_long_run_of_blank_spans_in_linear_time() { + // Separately formatted line breaks don't merge; each used to be removed with + // `Vec::remove(0)`, shifting all the rest (quadratic). + let mut p = Para::default(); + let (b, i) = (Fmt { bold: true, ..Default::default() }, Fmt { italic: true, ..Default::default() }); + for _ in 0..200_000 { + p.push_text("\n", &b); + p.push_text(" ", &i); + } + p.push_text(" x ", &Fmt::default()); + let t = std::time::Instant::now(); + p.trim(); + // A fraction of a second; `Vec::remove(0)` per span ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); + assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]); + // The first span left keeps its formatting and loses only its leading blanks. + let mut p = Para::default(); + p.push_text(" \t", &b); + p.push_text(" a ", &i); + p.push_text("b ", &b); + p.trim(); + assert_eq!(p.inlines, vec![Inline::Text("a ".into(), i), Inline::Text("b".into(), b)]); + } +} diff --git a/crates/flow/src/media.rs b/crates/flow/src/media.rs new file mode 100644 index 000000000..44e2a24ac --- /dev/null +++ b/crates/flow/src/media.rs @@ -0,0 +1,147 @@ +use super::Arc; +use super::types::Img; + +// --------------------------------------------------------------------------------------------- +// Shared helpers + +/// Image extension from bytes, if it's a format we can display. +pub fn sniff_image(data: &[u8]) -> Option<&'static str> { + match image::guess_format(data).ok()? { + image::ImageFormat::Png => Some("png"), + image::ImageFormat::Jpeg => Some("jpeg"), + image::ImageFormat::Gif => Some("gif"), + image::ImageFormat::WebP => Some("webp"), + image::ImageFormat::Bmp => Some("bmp"), + _ => None, + } +} + +/// Pixel size of encoded image bytes. +pub fn image_px(data: &[u8]) -> Option<(u32, u32)> { + image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok() +} + +/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is +/// used otherwise, shrunk to fit a 6.5" column. +pub fn make_img(data: impl Into>>, w: Option, h: Option, alt: &str) -> Option { + let data = data.into(); + let ext = sniff_image(&data)?; + let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)); + Some(sized_img(data, ext, natural, w, h, alt)) +} + +/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a +/// metafile, which `image` can't measure. Sized like [`make_img`]. +pub fn sized_img(data: Arc>, ext: &str, natural: Option<(f32, f32)>, w: Option, h: Option, alt: &str) -> Img { + let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0)); + let (pw, ph) = (pw.max(1.0), ph.max(1.0)); + let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, w * ph / pw), + (None, Some(h)) => (h * pw / ph, h), + (None, None) => (pw, ph), + }; + if w > 468.0 { + h *= 468.0 / w; + w = 468.0; + } + Img { + data, + ext: ext.to_string(), + w: w.clamp(1.0, 1584.0), + h: h.clamp(1.0, 1584.0), + alt: alt.to_string(), + float: None, + object: None, + crop: [0.0; 4], + } +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for ch in data.chunks(3) { + let b = [ch.first().copied().unwrap_or(0), ch.get(1).copied().unwrap_or(0), ch.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63]; + for (i, v) in idx.iter().enumerate() { + if i <= ch.len() { + out.push(B64.get(*v as usize).copied().unwrap_or(b'A') as char); + } else { + out.push('='); + } + } + } + out +} + +/// Lenient base64 (standard or URL-safe alphabet; whitespace and padding ignored). +pub fn base64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut acc = 0u32; + let mut bits = 0u32; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' => break, + c if c.is_ascii_whitespace() => continue, + _ => return None, + }; + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +/// Decode a `data:` URI (base64 or percent-encoded). +pub fn data_uri(uri: &str) -> Option> { + let rest = uri.trim().strip_prefix("data:")?; + let (head, data) = rest.split_once(',')?; + if head.ends_with(";base64") { base64_decode(data) } else { Some(percent_decode(data)) } +} + +pub fn percent_decode(s: &str) -> Vec { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c == b'%' + && let Some(h) = b.get(i + 1..i + 3) + && h.iter().all(u8::is_ascii_hexdigit) + && let Some(hex) = std::str::from_utf8(h).ok() + && let Ok(v) = u8::from_str_radix(hex, 16) + { + out.push(v); + i += 3; + continue; + } + out.push(c); + i += 1; + } + out +} + +pub fn mime_of(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "wmf" => "image/x-wmf", + "emf" => "image/x-emf", + _ => "application/octet-stream", + } +} diff --git a/crates/flow/src/odf_object.rs b/crates/flow/src/odf_object.rs new file mode 100644 index 000000000..14395bd79 --- /dev/null +++ b/crates/flow/src/odf_object.rs @@ -0,0 +1,72 @@ +//! ODF embedded objects ([`OdfObject`]) in a document: their files in +//! [`Document::passthrough`] under `odf-object//`, linked from the picture showing them by an +//! [`Embedded`] whose markup only names the object's media type, and taken back out for the ODT +//! writer. Other formats see the picture alone. + +use std::sync::Arc; + +use craft_wordmodel::Document; +use craft_wordmodel::graphic::{Embedded, EmbeddedRef}; + +use super::types::OdfObject; + +/// Relationship kind of an object's file, and of its replacement picture. +const FILE: &str = "file"; +const REPLACEMENT: &str = "replacement"; +/// Most objects a document keeps, and most files in one. +pub const MAX_OBJECTS: usize = 1000; +pub const MAX_FILES: usize = 256; + +fn esc(s: &str) -> String { + s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">") +} + +fn unesc(s: &str) -> String { + s.replace(""", "\"").replace("<", "<").replace(">", ">").replace("&", "&") +} + +/// Keep `obj` in `doc`: its files as passthrough parts, and the link to them. `None` when the +/// document already holds as many objects as it keeps. +pub fn store(doc: &mut Document, obj: &OdfObject) -> Option> { + let taken = |n: usize| { + let prefix = format!("odf-object/{n}/"); + doc.passthrough.range(prefix.clone()..).next().is_some_and(|(k, _)| k.starts_with(&prefix)) + }; + let n = (0..MAX_OBJECTS).find(|&n| !taken(n))?; + let mut refs = Vec::new(); + for (k, (path, bytes)) in obj.files.iter().take(MAX_FILES).enumerate() { + let target = format!("odf-object/{n}/{k}"); + doc.passthrough.insert(target.clone(), bytes.clone()); + refs.push(EmbeddedRef { id: path.clone(), kind: FILE.into(), target, external: false }); + } + if let Some(r) = &obj.replacement { + let target = format!("odf-object/{n}/replacement"); + doc.passthrough.insert(target.clone(), r.clone()); + refs.push(EmbeddedRef { id: String::new(), kind: REPLACEMENT.into(), target, external: false }); + } + let xml = format!("{} mime=\"{}\" ole=\"{}\"/>", Embedded::ODF_OBJECT, esc(&obj.mime), obj.ole); + Some(Arc::new(Embedded { xml, refs })) +} + +/// The object `e` links to in `doc`, when it is an ODF object whose files are all there. +pub fn load(doc: &Document, e: &Embedded) -> Option> { + if !e.is_odf_object() { + return None; + } + let attr = |k: &str| { + let pat = format!(" {k}=\""); + let from = e.xml.find(&pat)? + pat.len(); + let len = e.xml.get(from..)?.find('"')?; + e.xml.get(from..from + len).map(unesc) + }; + let mut obj = OdfObject { mime: attr("mime").unwrap_or_default(), ole: attr("ole").as_deref() == Some("true"), ..Default::default() }; + for r in e.refs.iter().take(MAX_FILES + 1) { + let bytes = doc.passthrough.get(&r.target)?.clone(); + match r.kind.as_str() { + FILE => obj.files.push((r.id.clone(), bytes)), + REPLACEMENT => obj.replacement = Some(bytes), + _ => {} + } + } + (!obj.files.is_empty()).then(|| Arc::new(obj)) +} diff --git a/crates/flow/src/to_doc.rs b/crates/flow/src/to_doc.rs new file mode 100644 index 000000000..c10be85c2 --- /dev/null +++ b/crates/flow/src/to_doc.rs @@ -0,0 +1,1002 @@ +use super::from_doc::uses_kind; +use super::types::{ + CODE_STYLE, Cell, Dropped, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_RUN_STRINGS, MONO_FONT, Para, + RUBY_BASE_SIZE, RevMark, clean_text, dropped, ruby_object, +}; +use super::{ + AbstractNum, Arc, Block, Blocks, Border, BorderStyle, Borders, CellProps, CharProps, Document, InlineObject, Level, ListKind, Num, NumRef, OBJ, + ParaProps, Paragraph, PropChange, Revision, RevisionKind, Rgb, RowProps, Run, Style, StyleKind, Table, TextColor, Underline, VMerge, VertAlign, + levels_for, para_block, +}; +use craft_wordmodel::para::{GroupChild, MAX_GROUP_CHILDREN}; + +pub fn char_props(f: &Fmt) -> CharProps { + let on = |b: bool| if b { Some(true) } else { None }; + CharProps { + bold: on(f.bold), + italic: on(f.italic), + underline: if f.underline { Some(Underline::Single) } else { None }, + strike: on(f.strike), + vert_align: if f.sup { + Some(VertAlign::Superscript) + } else if f.sub { + Some(VertAlign::Subscript) + } else { + None + }, + link: f.link.as_deref().filter(|l| !l.is_empty()).map(str::to_string), + color: f.color.map(TextColor::Rgb), + size: f.size.filter(|s| s.is_finite() && *s >= 1.0 && *s <= 1638.0), + font: if f.code { Some(MONO_FONT.to_string()) } else { f.font.as_deref().filter(|s| !s.is_empty()).map(str::to_string) }, + shading: f.background, + ..Default::default() + } + .with_extra(f) +} + +/// [`Fmt::extra`] laid over the character properties of the other [`Fmt`] fields. +trait WithExtra { + fn with_extra(self, f: &Fmt) -> CharProps; +} + +impl WithExtra for CharProps { + fn with_extra(mut self, f: &Fmt) -> CharProps { + let Some(x) = f.extra.as_deref() else { return self }; + if f.underline + && let Some(u) = x.underline + { + self.underline = Some(u); + } + if f.underline { + self.underline_color = x.underline_color; + } + // Turned off on the text, over what the style of its paragraph or span turns on. + if !f.bold && x.bold == Some(false) { + self.bold = Some(false); + } + if !f.italic && x.italic == Some(false) { + self.italic = Some(false); + } + if !f.strike && x.strike == Some(false) { + // A double strike under it goes too. + (self.strike, self.double_strike) = (Some(false), Some(false)); + } + if !f.underline && x.underline == Some(Underline::None) { + self.underline = Some(Underline::None); + } + if x.double_strike == Some(true) { + (self.strike, self.double_strike) = (None, Some(true)); + } + let finite = |v: Option, lo: f32, hi: f32| v.filter(|v| v.is_finite()).map(|v| v.clamp(lo, hi)); + self.style = x.style.as_deref().map(clean_text).filter(|s| !s.is_empty()); + self.caps = x.caps; + self.small_caps = x.small_caps; + self.hidden = x.hidden; + self.spacing = finite(x.spacing, -1584.0, 1584.0); + self.scale = finite(x.scale, 1.0, 600.0); + self.kern = finite(x.kern, 0.0, 1638.0); + self.emphasis = x.emphasis; + self.position = finite(x.position, -1584.0, 1584.0); + self.outline = x.outline; + self.shadow = x.shadow; + self.emboss = x.emboss; + self.engrave = x.engrave; + self.border = x.border.filter(|b| b.is_visible() && b.width.is_finite()).map(|mut b| { + (b.width, b.space) = (b.width.clamp(0.0, 12.0), if b.space.is_finite() { b.space.clamp(0.0, 31.0) } else { 0.0 }); + b + }); + self.lang = x.lang.as_deref().map(clean_text).filter(|l| !l.is_empty() && l.len() <= 64); + let name = |s: &Option, max: usize| s.as_deref().map(clean_text).filter(|v| !v.is_empty() && v.len() <= max); + self.font_cs = name(&x.font_cs, super::types::MAX_FONT_NAME); + self.font_ea = name(&x.font_ea, super::types::MAX_FONT_NAME); + self.size_cs = x.size_cs.filter(|s| s.is_finite()).map(|s| s.clamp(1.0, 1638.0)); + self.bold_cs = x.bold_cs; + self.italic_cs = x.italic_cs; + self.lang_bidi = name(&x.lang_bidi, 64); + self.lang_ea = name(&x.lang_ea, 64); + self + } +} + +fn code_style() -> Style { + Style { + id: CODE_STYLE.into(), + name: CODE_STYLE.into(), + kind: StyleKind::Paragraph, + based_on: Some("Normal".into()), + para: ParaProps { space_after: Some(0.0), space_before: Some(0.0), line_spacing: Some(Default::default()), ..Default::default() }, + chr: CharProps { font: Some(MONO_FONT.into()), size: Some(10.0), ..Default::default() }, + quick: false, + ..Default::default() + } +} + +fn rule_borders() -> Borders { + Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.75, color: Some(Rgb(0xA0, 0xA0, 0xA0)), space: 1.0 }), ..Default::default() } +} + +/// Append text (without object characters) or one inline object to the end of `p`. +fn append(p: &mut Paragraph, text: &str, props: &CharProps, obj: Option) { + let text: std::borrow::Cow = if text.contains(OBJ) { text.chars().filter(|c| *c != OBJ).collect::().into() } else { text.into() }; + let piece = match obj { + Some(o) => { + p.objects.push(o); + OBJ.to_string() + } + None => text.into_owned(), + }; + if piece.is_empty() { + return; + } + p.text.push_str(&piece); + match p.runs.last_mut() { + Some(last) if last.props == *props => last.len += piece.len(), + _ => p.runs.push(Run { len: piece.len(), props: props.clone() }), + } +} + +/// The paragraph properties of a flow paragraph (its list as instance `list_num`, when given). +fn para_props(p: &Para, list_num: Option) -> ParaProps { + let mut out = ParaProps { + style: match p.kind { + Kind::Heading(n) => Some(format!("Heading{}", n.clamp(1, 6))), + Kind::Title => Some("Title".into()), + Kind::Quote => Some("Quote".into()), + Kind::Code => Some(CODE_STYLE.into()), + Kind::Rule | Kind::Normal => None, + }, + ..Default::default() + }; + if p.kind == Kind::Rule { + out.borders = Some(rule_borders()); + } else if p.borders.is_some() { + out.borders = p.borders; + } + if let (Some(li), Some(num)) = (p.list, list_num) { + if out.style.is_none() { + out.style = Some("ListParagraph".into()); + } + out.numbering = Some(NumRef { num, level: li.level.min(8) }); + } + out.align = p.align; + let sp = p.spacing; + out.space_before = sp.before; + out.space_after = sp.after; + out.line_spacing = sp.line; + out.indent_left = sp.left; + out.indent_right = sp.right; + out.indent_first = sp.first; + if sp.before_auto { + out.space_before_auto = Some(true); + } + if sp.after_auto { + out.space_after_auto = Some(true); + } + if p.page_break { + out.page_break_before = Some(true); + } + if let Some(st) = p.extra.as_ref().and_then(|x| x.style.clone()) { + out.style = Some(st); + } + if let Some(c) = p.extra.as_ref().and_then(|x| x.contextual_spacing) { + out.contextual_spacing = Some(c); + } + if p.extra.as_ref().and_then(|x| x.bidi) == Some(true) { + out.bidi = Some(true); + } + if let Some(x) = p.extra.as_ref().filter(|x| x.drop_cap.is_some_and(|l| l > 1)) { + (out.drop_cap, out.drop_cap_distance) = + (x.drop_cap.map(|l| l.min(10)), x.drop_cap_distance.filter(|d| d.is_finite()).map(|d| d.clamp(0.0, 1584.0))); + } + 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); + // An outline level apart from its heading style's. + out.outline_level = x.outline_level.map(|l| l.min(8)).or(out.outline_level); + } + // 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 + .iter() + .filter(|t| t.pos.is_finite()) + .map(|t| craft_wordmodel::props::TabStop { pos: t.pos.clamp(-1584.0, 1584.0), ..*t }) + .take(64) + .collect(); + out.tabs = Some(tabs); + } + out +} + +struct Builder<'a> { + doc: &'a mut Document, + /// Revisions already in `doc` by (kind, author, date), so each change is added once. + revs: std::collections::HashMap<(u8, RevMark), u32>, + /// Link and font bytes given to runs so far ([`MAX_RUN_STRINGS`]). + run_strings: usize, + /// The document's comment for each flow comment id. + comments: std::collections::HashMap, + /// The document's list instance for each flow list number ([`ListInfo::list`]). + lists: std::collections::HashMap, + /// Bookmarks that cover text ([`super::types::ranged_anchors`]), those started, and those + /// ended: each started once and ended once, after its start. + ranged: std::collections::HashSet, + started: std::collections::HashSet, + ended: std::collections::HashSet, + /// Text boxes by name, and the boxes whose text continues in a box named so (linked text + /// boxes), linked once all are made ([`Builder::link_boxes`]). + boxes: std::collections::HashMap, + chains: Vec<(u32, String)>, + /// Tables made at the top of the flow so far (see `craft_wordmodel::kept`). + top_tables: usize, +} + +fn rev_key(kind: RevisionKind) -> u8 { + match kind { + RevisionKind::Insert => 0, + RevisionKind::Delete => 1, + RevisionKind::Format => 2, + } +} + +impl<'a> Builder<'a> { + /// A builder for `blocks` into `doc`. + fn new(doc: &'a mut Document, blocks: &[FBlock]) -> Builder<'a> { + let mut revs = std::collections::HashMap::new(); + for (i, r) in doc.revisions.iter().enumerate() { + if r.move_name.is_none() { + let mark = RevMark { author: r.author.clone(), date: r.date.clone(), id: None }; + revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); + } + } + Builder { + doc, + revs, + run_strings: 0, + comments: Default::default(), + lists: Default::default(), + ranged: super::types::ranged_anchors(blocks), + started: Default::default(), + ended: Default::default(), + boxes: Default::default(), + chains: Vec::new(), + top_tables: 0, + } + } + + /// The document revision for a tracked change, added the first time it is seen. + fn revision(&mut self, kind: RevisionKind, m: &RevMark) -> u32 { + let key = (rev_key(kind), m.clone()); + if let Some(i) = self.revs.get(&key) { + return *i; + } + let i = u32::try_from(self.doc.revisions.len()).unwrap_or(u32::MAX); + self.doc.revisions.push(Revision::new(kind, m.author.clone(), m.date.clone())); + self.revs.insert(key, i); + i + } + + /// `cp` as a run may keep it: without its link and font once the runs' copies of them are + /// over budget. + fn budget(&mut self, mut cp: CharProps) -> CharProps { + let n = cp.link.as_ref().map_or(0, String::len).saturating_add(cp.font.as_ref().map_or(0, String::len)); + match self.run_strings.checked_add(n).filter(|&t| t <= MAX_RUN_STRINGS) { + Some(t) => self.run_strings = t, + None => { + if cp.link.is_some() || cp.font.is_some() { + dropped(Dropped::RunStrings); + } + (cp.link, cp.font) = (None, None); + } + } + cp + } + + /// Comment `c` (flow id `id`) added to the document with its text, as a reply when its parent + /// was added before it; its document id, or `None` when the document has no room for it or + /// the flow already used `id`. + fn comment(&mut self, id: u32, c: &super::types::FComment) -> Option { + if self.comments.contains_key(&id) { + return None; + } + let blocks = self.blocks(&c.blocks, 1); + let part = self.doc.try_add_part(craft_wordmodel::PartKind::Comment, blocks).ok()?; + let ids = &self.doc.comments; + let Some(doc_id) = craft_wordmodel::free_id(ids.keys().next_back().copied(), ids.keys().copied(), 0) else { + self.doc.parts.remove(&part); + return None; + }; + let parent = c.parent.and_then(|p| self.comments.get(&p).copied()); + let comment = craft_wordmodel::Comment { + author: clean_text(&c.author), + initials: clean_text(&c.initials), + date: clean_text(&c.date), + parent, + resolved: c.resolved, + part, + ..Default::default() + }; + self.doc.comments.insert(doc_id, comment); + self.comments.insert(id, doc_id); + Some(doc_id) + } + + /// A note story holding `blocks`, as Word's: its paragraphs in the note text style, the first + /// starting with the note's mark and a space. The reference to put in the text, or `None` + /// when the document has no room for another story. + fn note(&mut self, endnote: bool, custom: &str, blocks: &[FBlock]) -> Option { + use craft_wordmodel::para::NoteKind; + let (kind, part, text, mark) = if endnote { + (NoteKind::Endnote, craft_wordmodel::PartKind::Endnote, "EndnoteText", "EndnoteReference") + } else { + (NoteKind::Footnote, craft_wordmodel::PartKind::Footnote, "FootnoteText", "FootnoteReference") + }; + let custom: String = clean_text(custom).chars().filter(|c| !c.is_control()).take(10).collect(); + let id = self.doc.try_add_part(part, Vec::new()).ok()?; + let mut story = self.blocks(blocks, 1); + if story.is_empty() { + story.push(para_block(Paragraph::new())); + } + for (i, b) in story.iter_mut().enumerate() { + if let Block::Para(p) = Arc::make_mut(b) { + if p.props.style.is_none() { + p.props.style = Some(text.into()); + } + if i == 0 { + let starts_blank = p.text.starts_with([' ', '\t']); + let reference = InlineObject::NoteRef { kind, id, custom: custom.clone() }; + if p.insert_object(0, reference, &CharProps { style: Some(mark.into()), ..Default::default() }).is_ok() && !starts_blank { + let at = p.text.len().min(craft_wordmodel::para::OBJ.len_utf8()); + let _ = p.insert_text(at, " ", &CharProps::default()); + } + } + } + } + if let Some(part) = self.doc.parts.get_mut(&id) { + part.blocks = story; + } + Some(InlineObject::NoteRef { kind, id, custom }) + } + + /// The document object for picture, shape or group `i` (members of groups `depth` deep). + fn drawing(&mut self, i: &Inline, depth: usize) -> Option { + // Offsets and distances on the page. + let fit_float = |float: &Option>| { + float.as_deref().cloned().map(|mut f| { + let fit = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 }; + (f.x, f.y) = (fit(f.x), fit(f.y)); + (f.dist, f.dist_top, f.dist_bottom) = (fit(f.dist).max(0.0), fit(f.dist_top).max(0.0), fit(f.dist_bottom).max(0.0)); + f + }) + }; + match i { + Inline::Image(img) => { + let key = self.doc.add_media(img.data.to_vec(), &img.ext); + let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0)); + let float = fit_float(&img.float); + let ole = img.object.as_deref().and_then(|o| super::odf_object::store(self.doc, o)); + // A crop of finite fractions that leaves something of the picture. + let crop = img.crop.map(|c| if c.is_finite() { c.clamp(0.0, 0.99) } else { 0.0 }); + let crop = if crop[0] + crop[2] < 0.99 && crop[1] + crop[3] < 0.99 { crop } else { [0.0; 4] }; + Some(InlineObject::Image { + media: key, + w, + h, + alt: img.alt.clone(), + float: float.unwrap_or_default(), + crop, + fx: Default::default(), + ole, + }) + } + // Its drawing is made from the chart by the program that opens the document + // (`wordcraft_engine`'s chart drawing), which has the theme's colours. + Inline::Chart(c) => { + let (w, h) = (finite_or(c.w, 360.0).clamp(1.0, 1584.0), finite_or(c.h, 216.0).clamp(1.0, 1584.0)); + let mut spec = (*c.spec).clone(); + spec.sanitize(); + let source = c.source.as_deref().and_then(|o| super::odf_object::store(self.doc, o)); + let graphic = craft_wordmodel::graphic::Graphic { + kind: craft_wordmodel::graphic::GraphicKind::Chart, + w, + h, + chart: Some(Arc::new(spec)), + source, + ..Default::default() + }; + Some(InlineObject::Graphic { + w, + h, + alt: clean_text(&c.alt), + float: fit_float(&c.float).unwrap_or_default(), + graphic: Arc::new(graphic), + }) + } + Inline::Shape { kind, w, h, float, blocks, look, path } => { + // A text box always has its story; a shape only when it holds text. + let story = if *kind == craft_wordmodel::para::ShapeKind::TextBox || !blocks.is_empty() { + let story = self.blocks(blocks, 1); + self.doc.try_add_part(craft_wordmodel::PartKind::TextBox, story).ok() + } else { + None + }; + // A named box, and the box its text continues in. + if let (Some(id), Some(c)) = (story, look.chain.as_deref()) { + if !c.name.is_empty() && self.boxes.len() < MAX_NAMED_BOXES { + self.boxes.entry(c.name.clone()).or_insert(id); + } + if let Some(next) = c.next.as_ref().filter(|n| !n.is_empty() && **n != c.name) { + self.chains.push((id, next.clone())); + } + } + if let Some(part) = story.and_then(|id| self.doc.parts.get_mut(&id)) { + let emu = |v: f32| if v.is_finite() { (v.clamp(0.0, 1584.0) * 12_700.0).round() as u32 } else { 0 }; + part.body.inset = look.inset.map(|i| i.map(emu)); + part.body.auto_fit = look.auto_fit; + part.body.vert = look.vert; + } + let (stroke, stroke_width) = match look.line { + Some((c, w)) if w.is_finite() && w > 0.0 => (Some(c), w.min(12.0)), + _ => (None, 0.0), + }; + // A line may be flat; anything else has some size. + let least = if *kind == craft_wordmodel::para::ShapeKind::Line { 0.0 } else { 1.0 }; + let fit = |v: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 }; + let obj = InlineObject::Shape { + kind: *kind, + w: fit(*w), + h: fit(*h), + fill: look.fill.clone().and_then(|f| f.sanitized()), + stroke, + stroke_width, + float: float.as_deref().cloned().unwrap_or_default(), + story, + freeform: path + .clone() + .filter(|_| *kind == craft_wordmodel::para::ShapeKind::Freeform) + .map(|f| std::sync::Arc::new((*f).clone().sanitized())), + effects: Default::default(), + extra: craft_wordmodel::connector::ShapeExtra { + dash: look.dash.clone(), + arrow_start: look.arrows.0, + arrow_end: look.arrows.1, + // Its drawing id, and what a connector's ends are glued to (sites bounded). + id: look.conn.as_ref().map_or(0, |c| c.id), + start: look.conn.as_ref().and_then(|c| c.start).filter(|e| e.site <= craft_wordmodel::connector::MAX_SITE), + end: look.conn.as_ref().and_then(|c| c.end).filter(|e| e.site <= craft_wordmodel::connector::MAX_SITE), + odf_geometry: look.geometry.clone().filter(|_| { + (*kind == craft_wordmodel::para::ShapeKind::Freeform && path.is_some()) + || *kind == craft_wordmodel::para::ShapeKind::Rectangle + }), + ..Default::default() + }, + }; + Some(obj) + } + Inline::Group { w, h, float, members } if depth < MAX_GROUP_DEPTH => { + let fit = |v: f32| if v.is_finite() { v.clamp(1.0, 1584.0) } else { 72.0 }; + let (w, h) = (fit(*w), fit(*h)); + let off = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 }; + let children: Vec = members + .iter() + .take(MAX_GROUP_CHILDREN) + .filter_map(|m| self.drawing(&m.inline, depth + 1).map(|obj| GroupChild { x: off(m.x), y: off(m.y), obj })) + .collect(); + (!children.is_empty()).then(|| InlineObject::Group { + w, + h, + float: float.as_deref().cloned().unwrap_or_default(), + ch_w: w, + ch_h: h, + children, + canvas: None, + }) + } + _ => None, + } + } + + /// Keeps a paragraph's markup read from the file (its own, its frame wrap, the index it + /// starts) pending until the document is sealed. + fn keep_pending(&mut self, p: &Para, rev: u64) { + if p.kept.is_none() && p.wrap.is_none() && p.index.is_none() { + return; + } + let group = p.group.as_ref().and_then(|g| g.id).filter(|_| p.kept.is_some()); + if self.doc.kept.get().is_none() { + self.doc.kept = craft_wordmodel::kept::Kept::new(Default::default()); + } + if let Some(k) = self.doc.kept.get_mut() { + if let Some(xml) = &p.kept { + k.keep_pending(rev, xml.clone()); + } + if let Some(w) = &p.wrap { + k.keep_pending_wrap(rev, (**w).clone()); + } + if let Some(ix) = &p.index { + k.keep_pending_index(rev, ix.1, ix.0.clone()); + } + if let Some(g) = group { + k.keep_pending_group(rev, g); + } + } + } + + fn para(&mut self, p: &Para, list_num: Option) -> Paragraph { + let mut out = Paragraph::new(); + out.props = para_props(p, list_num); + if let Some(c) = &p.fmt_change { + // A list item stays in its list instance (only its level may differ); an old list on a + // paragraph that is no list now gets an instance of its own, so Reject can restore it. + // The same goes for an item whose list kind changed (numbered to bulleted or back). + let old_num = match (c.old.list, p.list, list_num) { + (Some(old), Some(now), Some(num)) if old.ordered == now.ordered => Some(num), + (Some(old), _, _) => Some(self.new_list(&[old])), + _ => None, + }; + let old = para_props(&c.old, old_num); + out.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old); + } + if p.keep_next { + out.props.keep_next = Some(true); + } + out.section.clone_from(&p.section); + // Inlines are appended in order, so build the runs directly: inserting each one through + // `Paragraph::insert_text` rescans every earlier run and is quadratic in their number. + for i in &p.inlines { + match i { + Inline::Text(t, f) => { + let t = clean_text(t); + let mut cp = self.budget(char_props(f)); + cp.ins = f.ins.as_ref().map(|m| self.revision(RevisionKind::Insert, m)); + cp.del = f.del.as_ref().map(|m| self.revision(RevisionKind::Delete, m)); + if let Some(c) = &f.fmt_change { + let old = self.budget(char_props(&c.old)); + cp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old); + } + if p.kind == Kind::Code { + // Code's font comes from its style, before a change as after it. + if cp.font.as_deref() == Some(MONO_FONT) { + cp.font = None; + } + if let Some(c) = cp.fmt_change.as_deref_mut() + && c.old.font.as_deref() == Some(MONO_FONT) + { + c.old.font = None; + } + } + append(&mut out, &t, &cp, None); + } + Inline::FieldStart(instr) => { + let obj = InlineObject::FieldStart { instr: clean_text(instr), locked: false, dirty: false }; + append(&mut out, "", &CharProps::default(), Some(obj)); + } + Inline::FieldEnd => append(&mut out, "", &CharProps::default(), Some(InlineObject::FieldEnd)), + Inline::Image(_) | Inline::Chart(_) | Inline::Shape { .. } | Inline::Group { .. } => { + if let Some(mut obj) = self.drawing(i, 0) { + // In line, raised or lowered: its run's position (as Word keeps it). In + // a paragraph of its own, its run has the paragraph's formatting. + let mut cp = p + .mark + .as_deref() + .filter(|_| p.textless()) + .map(|m| self.budget(CharProps { ins: None, del: None, fmt_change: None, ..m.clone() })) + .unwrap_or_default(); + if let Some(f) = obj.float_mut() + && f.wrap == craft_wordmodel::para::Wrap::Inline + && f.y != 0.0 + { + cp.position = Some(f.y.clamp(-1584.0, 1584.0)); + f.y = 0.0; + } + append(&mut out, "", &cp, Some(obj)); + } + } + // A bookmark once (the first of a name): a point, or the start of the text it covers. + Inline::Anchor(name) if self.started.insert(name.clone()) => { + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkStart { name: name.clone() })); + if !self.ranged.contains(name) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); + } + } + Inline::Anchor(_) => {} + Inline::AnchorEnd(name) => { + if self.ranged.contains(name) && self.started.contains(name) && self.ended.insert(name.clone()) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); + } + } + Inline::Figure(_) => {} + Inline::Comment { id, comment } => { + // A reply is the comment's, not the text's: it has no range. + if let Some(doc_id) = self.comment(*id, comment) + && comment.parent.is_none() + { + append(&mut out, "", &CharProps::default(), Some(InlineObject::CommentStart { id: doc_id })); + } + } + Inline::CommentEnd(id) => { + if let Some(doc_id) = self.comments.get(id) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::CommentEnd { id: *doc_id })); + } + } + Inline::Note { endnote, custom, blocks, fmt } => { + if let Some(obj) = self.note(*endnote, custom, blocks) { + let style = if *endnote { "EndnoteReference" } else { "FootnoteReference" }; + let cp = CharProps { style: Some(style.into()), ..self.budget(char_props(fmt)) }; + append(&mut out, "", &cp, Some(obj)); + } + } + Inline::Field { instr, result, fmt, locked } => { + let cp = self.budget(char_props(fmt)); + let (instr, result) = (clean_text(instr), clean_text(result)); + if instr.trim().is_empty() { + append(&mut out, &result, &cp, None); + } else { + let obj = InlineObject::Field { instr, result, locked: *locked, dirty: false, code: false, form: String::new() }; + append(&mut out, "", &cp, Some(obj)); + } + } + // A content control around its text. + Inline::Control { control, text, fmt } => { + let cp = self.budget(char_props(fmt)); + let mut c = control.clone(); + c.block = false; + append(&mut out, "", &cp, Some(InlineObject::ControlStart { control: c })); + append(&mut out, &clean_text(text), &cp, None); + append(&mut out, "", &cp, Some(InlineObject::ControlEnd)); + } + Inline::Equation { linear, display } => { + let linear = clean_text(linear); + if !linear.is_empty() { + let obj = InlineObject::Equation { linear, display: *display, math: Default::default() }; + append(&mut out, "", &CharProps::default(), Some(obj)); + } + } + Inline::Ruby { base, ruby, fmt } => { + let (base, ruby) = (clean_text(base), clean_text(ruby)); + let cp = self.budget(char_props(fmt)); + // A source without a base or without a reading (malformed markup) is just its + // text: a guide that annotates nothing is no guide. + if base.is_empty() || ruby.is_empty() { + append(&mut out, if base.is_empty() { &ruby } else { &base }, &cp, None); + } else { + // Ruby attributes a format without them doesn't state (HTML): the + // Phonetic Guide's defaults for a new guide over this run's text. + let obj = ruby_object(base, ruby, cp.size.unwrap_or(RUBY_BASE_SIZE)); + append(&mut out, "", &cp, Some(obj)); + } + } + } + } + if let Some(m) = p.mark.as_deref().filter(|_| p.textless()) { + out.mark = self.budget(CharProps { ins: None, del: None, fmt_change: None, ..m.clone() }); + } + out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m)); + out.mark.del = p.mark_del.as_deref().map(|m| self.revision(RevisionKind::Delete, m)); + out.normalize(); + out.touch(); + // Its markup kept for the writer, as long as nothing changes the paragraph from here on + // (see `craft_wordmodel::kept`: the reader seals it once the document is done). + self.keep_pending(p, out.rev); + out + } + + /// A new list instance whose levels follow the kinds used in `group` (first use per level). + fn new_list(&mut self, group: &[ListInfo]) -> u32 { + let num_levels = levels_for(ListKind::Numbered); + let bul_levels = levels_for(ListKind::Bullet); + let mut kinds: [Option; 9] = [None; 9]; + for li in group { + if let Some(k) = kinds.get_mut(li.level.min(8) as usize) + && k.is_none() + { + *k = Some(li.ordered); + } + } + let mut starts: [Option; 9] = [None; 9]; + let mut labels: [Option; 9] = [None; 9]; + for li in group { + if let Some(s) = starts.get_mut(li.level.min(8) as usize) + && s.is_none() + { + *s = Some(li.start); + } + if let Some(l) = labels.get_mut(li.level.min(8) as usize) + && l.is_none() + { + *l = li.label; + } + } + // Levels nobody used follow the first level's kind. + let first = group.first().map(|l| l.ordered).unwrap_or(false); + let levels: Vec = (0..9) + .map(|i| { + let ordered = kinds.get(i).copied().flatten().unwrap_or(first); + let src = if ordered { &num_levels } else { &bul_levels }; + let mut level = src.get(i).cloned().unwrap_or_default(); + if ordered && let Some(s) = starts.get(i).copied().flatten() { + level.start = s; + } + // The source's own label: its format and text, or its bullet. + match labels.get(i).copied().flatten() { + Some(l) if ordered && l.format != craft_wordmodel::section::NumFormat::Bullet => { + level.format = l.format; + level.text = l.text(i as u8); + } + Some(l) if !ordered => { + if let Some(c) = l.bullet().filter(|c| !c.is_control() && !level.text.starts_with(*c)) { + let custom = levels_for(ListKind::BulletChar(c)); + level.text = c.to_string(); + level.chr.font = custom.first().and_then(|l| l.chr.font.clone()); + } + } + _ => {} + } + if let Some(l) = labels.get(i).copied().flatten() { + if let Some((indent, hanging)) = l.position() { + (level.indent, level.hanging) = (indent, hanging); + } + if let Some(a) = l.align { + level.align = a; + } + } + level + }) + .collect(); + let numbering = Arc::make_mut(&mut self.doc.numbering); + let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0); + numbering.abstracts.push(AbstractNum { id: aid, name: None, levels, ..Default::default() }); + let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); + numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() }); + nid + } + + fn blocks(&mut self, blocks: &[FBlock], depth: usize) -> Blocks { + let mut out = Blocks::new(); + let mut i = 0; + while let Some(b) = blocks.get(i) { + match b { + FBlock::Para(p) if p.list.is_some() => { + // A run of consecutive list paragraphs is one list, until an item of another + // list or (when the source doesn't tell lists apart) a top-level item of the + // other kind (bulleted or numbered) starts a new one. A list the source + // numbers goes on where it shows up again. + let mut j = i; + let mut group: Vec = Vec::new(); + let mut top: Option = None; + let id = p.list.map_or(0, |l| l.list); + while let Some(FBlock::Para(q)) = blocks.get(j) { + let Some(li) = q.list else { break }; + if li.list != id { + break; + } + if li.level == 0 && id == 0 { + if top.is_some_and(|o| o != li.ordered) { + break; + } + top = Some(li.ordered); + } + group.push(li); + j += 1; + } + let num = match self.lists.get(&id) { + Some(num) if id != 0 => *num, + _ => { + let num = self.new_list(&group); + if id != 0 { + self.lists.insert(id, num); + } + num + } + }; + for q in blocks.get(i..j).unwrap_or(&[]) { + if let FBlock::Para(q) = q { + let para = self.para(q, Some(num)); + out.push(para_block(para)); + } + } + i = j.max(i + 1); + continue; + } + FBlock::Para(p) => out.push(para_block(self.para(p, None))), + FBlock::Table(t) => { + if depth >= MAX_DEPTH { + // Too deep: flatten into paragraphs. + for row in &t.rows { + for c in row { + out.extend(self.blocks(&c.blocks, depth)); + } + } + } else if let Some(tb) = self.table(t, depth) { + out.push(Arc::new(Block::Table(tb))); + } + } + } + i += 1; + } + out + } + + fn table(&mut self, t: &FTable, depth: usize) -> Option { + let rows: Vec<(usize, &Vec)> = + t.rows.iter().enumerate().filter(|(_, r)| !r.is_empty()).take(craft_wordmodel::table::MAX_ROWS).collect(); + if rows.is_empty() { + return None; + } + let cols = t.cols().clamp(1, craft_wordmodel::table::MAX_COLS); + let width = self.doc.last_section.text_width().max(72.0); + // One template row for the grid widths and props; the real rows are built below. Sizing it + // to `rows.len()` would allocate a full rows × cols cell grid only to clear it, which a + // tall table (many rows, wide from merged cells) turns into a large transient spike. + let mut tb = Table::new(1, cols, width); + // A table from a format that keeps cell formatting (ODT) has its cells' own borders and no + // table style of Word's kind: Table Grid's single spacing would change its text. + if t.borderless || t.extra.is_some() { + tb.props.style = None; + } + if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 1.0) { + tb.grid = t.widths.iter().map(|w| w.clamp(6.0, 1584.0)).collect(); + } + tb.rows.clear(); + 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)); + tb.props.fixed |= x.fixed; + let space = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1584.0)); + (tb.props.space_before, tb.props.space_after) = (space(x.space[0]), space(x.space[1])); + } + for (ri, r) in rows { + let mut row = craft_wordmodel::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 { craft_wordmodel::props::HeightRule::Exact } else { craft_wordmodel::props::HeightRule::AtLeast }; + } + row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)); + let mut g = 0usize; + for c in r.iter().take(craft_wordmodel::table::MAX_COLS) { + let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1)); + let w: f32 = tb.grid.get(g..(g + span).min(tb.grid.len())).map(|s| s.iter().sum()).unwrap_or(72.0); + g += span; + let mut blocks = self.blocks(&c.blocks, depth + 1); + if blocks.is_empty() || matches!(blocks.last().map(|b| &**b), Some(Block::Table(_))) { + blocks.push(para_block(Paragraph::new())); + } + let vmerge = if c.covered { + VMerge::Continue + } else if c.rowspan > 1 { + VMerge::Restart + } else { + VMerge::None + }; + // A background picture goes into the document's media. + let picture = c.picture.as_deref().map(|p| { + let media = self.doc.add_media(p.img.data.as_ref().clone(), &p.img.ext); + Box::new(craft_wordmodel::props::CellPicture { media, size: [p.img.w, p.img.h], repeat: p.repeat, align: p.align }) + }); + row.cells.push(craft_wordmodel::Cell { + props: CellProps { + width: Some(w), + span: span as u32, + vmerge, + shading: c.shading, + borders: c.borders, + margins: c.margins.filter(|m| m.iter().all(|v| v.is_finite())).map(|m| m.map(|v| v.clamp(0.0, 1584.0))), + valign: c.valign, + text_direction: c.direction, + picture, + ..Default::default() + }, + blocks, + controls: Default::default(), + }); + } + if row.cells.is_empty() { + row.cells.push(craft_wordmodel::Cell::empty()); + } + tb.rows.push(row); + } + // Its markup kept for the writer, by its place among the top's tables. + if depth == 0 { + if t.kept.is_some() || t.wrap.is_some() { + if self.doc.kept.get().is_none() { + self.doc.kept = craft_wordmodel::kept::Kept::new(Default::default()); + } + if let Some(k) = self.doc.kept.get_mut() { + if let Some(xml) = &t.kept { + k.keep_pending_table(self.top_tables, xml.clone()); + } + if let Some(w) = &t.wrap { + k.keep_pending_table_wrap(self.top_tables, (**w).clone()); + } + } + } + self.top_tables += 1; + } + Some(tb) + } +} + +fn finite_or(v: f32, d: f32) -> f32 { + if v.is_finite() { v } else { d } +} + +/// Build a document from a flow. +pub fn to_doc(flow: &Flow) -> Document { + to_doc_in(flow, craft_wordmodel::SectionProps::default()) +} + +/// [`to_doc`] on a page of the source's own size and margins (`section`), which tables size to. +/// Most named text boxes linked. +const MAX_NAMED_BOXES: usize = 10_000; + +impl Builder<'_> { + /// Each text box whose text continues in another (by name) linked to it, a chain at a time + /// (a box is in at most one chain, and a chain doesn't loop back on itself). + fn link_boxes(&mut self) { + let mut linked_to = std::collections::HashSet::new(); + for (from, name) in std::mem::take(&mut self.chains) { + let Some(&to) = self.boxes.get(&name) else { continue }; + if to == from || !linked_to.insert(to) { + continue; + } + if let Some(p) = self.doc.parts.get_mut(&from) { + p.body.next = Some(to); + } + } + // A link that closes a loop is undone. + for id in self.boxes.values().copied().collect::>() { + let mut seen = std::collections::HashSet::new(); + let mut at = id; + while let Some(next) = self.doc.parts.get(&at).and_then(|p| p.body.next) { + if !seen.insert(at) || seen.len() > MAX_NAMED_BOXES { + if let Some(p) = self.doc.parts.get_mut(&at) { + p.body.next = None; + } + break; + } + at = next; + } + } + } +} + +pub fn to_doc_in(flow: &Flow, section: craft_wordmodel::SectionProps) -> Document { + let mut doc = Document::new(); + doc.last_section = section; + doc.numbering = Default::default(); + let uses_code = uses_kind(&flow.blocks, Kind::Code, 0); + if uses_code { + Arc::make_mut(&mut doc.styles).upsert(code_style()); + } + let mut b = Builder::new(&mut doc, &flow.blocks); + let body = b.blocks(&flow.blocks, 0); + b.link_boxes(); + doc.body = body; + let m = &flow.meta; + doc.core.title = m.title.clone(); + doc.core.creator = m.author.clone(); + doc.core.subject = m.subject.clone(); + doc.core.keywords = m.keywords.clone(); + doc.core.description = m.description.clone(); + doc.core.created = m.created.clone(); + doc.ensure_nonempty(); + doc +} + +/// `blocks` as document blocks of `doc` (their pictures go into its media), for a header, +/// footer or other part an importer adds itself. +pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks { + let mut out = Builder::new(doc, blocks).blocks(blocks, 0); + if out.is_empty() { + out.push(para_block(Paragraph::new())); + } + out +} diff --git a/crates/flow/src/types.rs b/crates/flow/src/types.rs new file mode 100644 index 000000000..e897778f9 --- /dev/null +++ b/crates/flow/src/types.rs @@ -0,0 +1,1094 @@ +use super::{Align, Arc, InlineObject, OBJ, REPORTED, Rgb, RubyAlign, VAlign, ruby_raise}; + +/// Font used for code spans and code blocks. +pub const MONO_FONT: &str = "Courier New"; + +/// Style id of code-block paragraphs. +pub const CODE_STYLE: &str = "Code"; + +/// Nesting limit for tables in tables (hostile input). +pub const MAX_DEPTH: usize = 8; + +/// Table cells a Markdown document makes in all, header rows included (hostile input). A short +/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column +/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out. +pub const MAX_TABLE_CELLS: usize = 1 << 18; + +/// Deepest groups in groups kept (hostile files). +pub const MAX_GROUP_DEPTH: usize = 8; + +/// Longest font name the importers keep, in bytes (as the .docx reader); a longer one (no real +/// font has one) is dropped (#3713). +pub const MAX_FONT_NAME: usize = 256; + +/// Longest hyperlink target the importers keep, in bytes; a longer link is dropped and its text +/// kept (#3713). +pub const MAX_LINK: usize = 8 << 10; + +/// Link and font bytes the document's runs may carry between them. A flow's spans share one +/// copy of a name ([`Fmt`]), but every run of a document owns its own: past this budget, runs +/// lose their link and font rather than multiply a small file into gigabytes (#3713). +pub(super) const MAX_RUN_STRINGS: usize = 64 << 20; + +/// `name` as a span's font, unless it is longer than [`MAX_FONT_NAME`] (then reported, see +/// [`dropped`]). +pub fn kept_font(name: &str) -> Option> { + let kept = (name.len() <= MAX_FONT_NAME).then(|| name.into()); + if kept.is_none() { + dropped(Dropped::Font); + } + kept +} + +/// `target` as a span's link, unless it is longer than [`MAX_LINK`] (then reported, see +/// [`dropped`]). +pub fn kept_link(target: &str) -> Option> { + let kept = (target.len() <= MAX_LINK).then(|| target.into()); + if kept.is_none() { + dropped(Dropped::Link); + } + kept +} + +/// What an importer leaves out to stay within a limit (review of #3873: no limit drops data +/// without saying so). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dropped { + /// A font name over [`MAX_FONT_NAME`]. + Font, + /// A link target over [`MAX_LINK`]. + Link, + /// Runs' links and fonts past [`MAX_RUN_STRINGS`]. + RunStrings, + /// A style name cut to [`MAX_FONT_NAME`]. + StyleName, + /// ODT `text:s` spaces past the document's budget. + Spaces, + /// Markdown table rows and columns past the table limits or the document's cell budget. + TableCells, +} + +impl Dropped { + fn message(self) -> String { + match self { + Dropped::Font => format!("import: font names longer than {MAX_FONT_NAME} bytes are dropped; that text keeps the surrounding font"), + Dropped::Link => format!("import: links longer than {MAX_LINK} bytes are dropped; their text is kept"), + Dropped::RunStrings => { + format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them") + } + Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"), + Dropped::Spaces => { + "import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string() + } + Dropped::TableCells => format!( + "import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out", + craft_wordmodel::table::MAX_COLS, + craft_wordmodel::table::MAX_ROWS, + MAX_TABLE_CELLS + ), + } + } + + /// Whether this thread's import has logged this kind ([`dropped`]). + pub fn reported(self) -> bool { + REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0) + } +} + +/// Log that the importer left something of `kind` out: once per kind per imported document +/// ([`begin_import`]), not once per item. +pub fn dropped(kind: Dropped) { + let bit = 1u8 << (kind as u8); + if REPORTED.with(|r| r.replace(r.get() | bit)) & bit == 0 { + log::warn!("{}", kind.message()); + } +} + +/// Start a new document's reports: each importer's entry point calls this first. +pub fn begin_import() { + REPORTED.with(|r| r.set(0)); +} + +/// Character formatting the text formats can express. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Fmt { + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub strike: bool, + pub code: bool, + pub sup: bool, + pub sub: bool, + /// Shared: every span of a link holds the same target. + pub link: Option>, + pub color: Option, + /// Points. + pub size: Option, + /// Shared, as `link`. + pub font: Option>, + /// Background (highlight / shading). + pub background: Option, + /// Tracked insertion of this text (formats that keep revisions: RTF). + pub ins: Option, + /// Tracked deletion of this text. Only [`from_doc_tracked`] keeps deleted text in a flow. + pub del: Option, + /// Tracked formatting change of this text: who made it and the formatting before it (only + /// [`from_doc_tracked`] and formats that keep revisions set it). + pub fmt_change: Option>>, + /// Formatting beyond the fields above, for formats that keep it (ODT): underline style and + /// colour, double strike, caps, small caps, hidden, spacing, scale, position, outline, + /// shadow, emboss, engrave and language, as character properties. Only flows asked for it + /// ([`FlowOpts::chars`]) hold it. + pub extra: Option>, +} + +/// A tracked formatting change in a flow: who made it, and the formatting before it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FmtChange { + pub mark: RevMark, + pub old: T, +} + +/// Who made a tracked change and when (ISO 8601; empty = unknown). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct RevMark { + pub author: String, + pub date: String, + /// The source's identity of the change, when it has one (an ODT changed region; the + /// revision index in [`from_doc_tracked`]): marks with different ids stay separate + /// revisions. `None` (RTF) = one revision per kind, author and date. + pub id: Option, +} + +/// An embedded picture. +#[derive(Clone, Debug, PartialEq)] +pub struct Img { + pub data: Arc>, + /// File extension (`png`, `jpeg`…). + pub ext: String, + /// Display size, points. + pub w: f32, + pub h: f32, + pub alt: String, + /// How a floating picture is placed and wrapped, for formats that keep it (ODT); `None`: in + /// line with the text. + pub float: Option>, + /// The embedded object this picture shows, kept whole (ODT only; see [`OdfObject`]). + pub object: Option>, + /// How much is cut off its left, top, right and bottom, fractions of its size (0..1), for + /// formats that keep it (ODT). + pub crop: [f32; 4], +} + +/// An embedded object WordCraft shows but doesn't edit, kept whole for saving back (ODF 1.3 +/// §10.4.6 `draw:object`, a sub-document such as a spreadsheet, or §10.4.7 `draw:object-ole`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OdfObject { + /// Its media type (`application/vnd.oasis.opendocument.spreadsheet`…). + pub mime: String, + /// An OLE object: one binary file (`draw:object-ole`) rather than a sub-document folder. + pub ole: bool, + /// Its files, by their path in its folder (an OLE object's one file: ""). + pub files: Vec<(String, Arc>)>, + /// The replacement picture as read (LibreOffice's own metafile format, say), written back + /// in place of the picture shown. + pub replacement: Option>>, +} + +/// An editable chart in a flow ([`Inline::Chart`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Chart { + pub spec: Arc, + /// Display size, points. + pub w: f32, + pub h: f32, + pub alt: String, + /// How a floating chart is placed and wrapped; `None`: in line with the text. + pub float: Option>, + /// The chart object as read (ODT), written back whole while the chart is unchanged, so it + /// keeps the look WordCraft doesn't model (its fonts, lines and colours). + pub source: Option>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Inline { + Text(String, Fmt), + Image(Img), + /// A bookmark (link target). + Anchor(String), + /// Where bookmark `name`, started by an [`Inline::Anchor`] before it, ends: the bookmark + /// covers the text between. Only flows asked for fields ([`FlowOpts::fields`]) hold them; + /// an anchor without one is a point. + AnchorEnd(String), + /// A chart or diagram known here only by its alt text: written where alt text has a place + /// (an HTML or Markdown image), left out of editable formats so it never turns into body text. + Figure(String), + /// A chart WordCraft can edit, `w` × `h` points: only in flows asked for boxes + /// ([`FlowOpts::boxes`]); others get its alt text as an [`Inline::Figure`]. + Chart(Chart), + /// A content control holding plain `text` (a form control in ODT): only in flows asked for + /// boxes ([`FlowOpts::boxes`]); others get its text. + Control { + control: Box, + text: String, + fmt: Fmt, + }, + /// An equation in the linear format WordCraft keeps them in (`x=(-b±√(b^2-4ac))/2a`). Formats + /// without equations write `linear` as text. + Equation { + linear: String, + display: bool, + }, + /// Ruby (phonetic guide): `ruby` text (furigana, pinyin…) over `base` text, in the formatting + /// of the run it sits in (#1681). Formats without ruby write the base text + /// ([`ruby_as_text`]). + Ruby { + base: String, + ruby: String, + fmt: Fmt, + }, + /// A field (page number, date, document property, cross-reference): its Word field code + /// (`PAGE`, `REF name \h`…) and the result it last showed, in its run's formatting. Only + /// flows asked for fields ([`FlowOpts::fields`]) hold them; formats without fields write the + /// result as text. + Field { + instr: String, + result: String, + fmt: Fmt, + /// Its result is kept, not worked out again (Word's locked field, ODF's `text:fixed`). + locked: bool, + }, + /// A footnote or endnote: its custom mark (empty: numbered) and its content, without the + /// note's own mark. Only flows asked for notes ([`FlowOpts::notes`]) hold them; formats + /// without notes leave them out. + Note { + endnote: bool, + custom: String, + blocks: Vec, + /// The reference's own formatting (beyond the note reference style). + fmt: Fmt, + }, + /// Where comment `id` (the flow's own number for it) starts, and the comment. A reply + /// ([`FComment::parent`]) has no range: it only follows its comment. Only flows asked for + /// comments ([`FlowOpts::comments`]) hold them. + Comment { + id: u32, + comment: Box, + }, + /// Where comment `id`'s range ends. + CommentEnd(u32), + /// A drawing shape or text box: its kind, its size in points, its placement when it floats, + /// its text (none for a shape without), and its look. Only flows asked for them + /// ([`FlowOpts::boxes`]) hold them. + Shape { + kind: craft_wordmodel::para::ShapeKind, + w: f32, + h: f32, + float: Option>, + blocks: Vec, + look: BoxLook, + /// A freeform's paths ([`craft_wordmodel::para::ShapeKind::Freeform`]). + path: Option>, + }, + /// The start of a field whose result is the content after it, up to the matching + /// [`Inline::FieldEnd`] (a table of contents around its entries); its field code. Only flows + /// asked for fields ([`FlowOpts::fields`]) hold them. + FieldStart(String), + /// The end of the innermost [`Inline::FieldStart`]. + FieldEnd, + /// Drawing shapes, text boxes and pictures grouped into one object: its size in points, its + /// placement when it floats, and its members, each at its offset in the group. Only flows + /// asked for shapes ([`FlowOpts::boxes`]) hold them. + Group { + w: f32, + h: f32, + float: Option>, + members: Vec, + }, +} + +/// A member of an [`Inline::Group`]: a shape, text box or picture (its placement in the group is +/// `x`, `y`, points from the group's top left corner; its own float holds only its turn and +/// mirroring). +#[derive(Clone, Debug, PartialEq)] +pub struct Member { + pub x: f32, + pub y: f32, + pub inline: Inline, +} + +/// How a text box or shape looks: its fill, its outline (colour and width, dash and +/// arrowheads), its inner margins, and whether it grows to fit its text. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxLook { + /// Fill: a colour or a gradient; `None` is no fill. + pub fill: Option, + /// Outline colour and width in points; `None` is no line. + pub line: Option<(Rgb, f32)>, + /// The outline's dash pattern. + pub dash: craft_wordmodel::line_dash::LineDash, + /// Arrowheads at a line's start and end. + pub arrows: (bool, bool), + /// Inner margins (left, top, right, bottom) in points; `None` is the default. + pub inset: Option<[f32; 4]>, + /// The box grows to its text; its height is the least it is. + pub auto_fit: bool, + /// The drawing id connectors glue to, and a connector's glued ends (see + /// [`craft_wordmodel::connector`]). + pub conn: Option>, + /// A text box's name and the box its text continues in when it doesn't fit (linked text + /// boxes), by name. + pub chain: Option>, + /// Which way a text box's text runs (turned down or up). + pub vert: craft_wordmodel::props::TextVert, + /// A custom shape's geometry as read (ODT; see `ShapeExtra::odf_geometry`). + pub geometry: Option>, +} + +/// A text box's name, and the name of the box its text continues in ([`BoxLook::chain`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Chain { + pub name: String, + pub next: Option, +} + +/// A shape's drawing id (0: none) and, for a connector, what its ends are glued to. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Conn { + pub id: u32, + pub start: Option, + pub end: Option, +} + +impl Default for BoxLook { + /// A new text box's: white, with a thin black line. + fn default() -> BoxLook { + BoxLook { + fill: Some(craft_wordmodel::fill::Fill::solid(Rgb::WHITE)), + line: Some((Rgb::BLACK, 0.75)), + dash: Default::default(), + arrows: (false, false), + inset: None, + auto_fit: false, + conn: None, + chain: None, + vert: Default::default(), + geometry: None, + } + } +} + +/// The bookmarks in `blocks` whose end ([`Inline::AnchorEnd`]) follows their start +/// ([`Inline::Anchor`]): ranges, not points. Tables' cells, notes, comments and shapes' text +/// are looked through, in document order. +pub fn ranged_anchors(blocks: &[FBlock]) -> std::collections::HashSet { + fn walk(blocks: &[FBlock], depth: usize, started: &mut std::collections::HashSet, ranged: &mut std::collections::HashSet) { + if depth > MAX_DEPTH { + return; + } + for b in blocks { + match b { + FBlock::Para(p) => { + for i in &p.inlines { + match i { + Inline::Anchor(n) => { + started.insert(n.clone()); + } + Inline::AnchorEnd(n) if started.contains(n) => { + ranged.insert(n.clone()); + } + Inline::Note { blocks, .. } | Inline::Shape { blocks, .. } => walk(blocks, depth + 1, started, ranged), + Inline::Comment { comment, .. } => walk(&comment.blocks, depth + 1, started, ranged), + _ => {} + } + } + } + FBlock::Table(t) => { + for c in t.rows.iter().flatten() { + walk(&c.blocks, depth + 1, started, ranged); + } + } + } + } + } + let (mut started, mut ranged) = Default::default(); + walk(blocks, 0, &mut started, &mut ranged); + ranged +} + +/// `blocks` with each table replaced by the blocks of its cells, row by row (nested tables too), +/// for places that hold paragraphs only. +pub fn flatten_tables(blocks: &[FBlock]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec> = vec![blocks.iter()]; + while let Some(top) = stack.last_mut() { + match top.next() { + Some(FBlock::Para(p)) => out.push(FBlock::Para(p.clone())), + Some(FBlock::Table(t)) => { + // Cells in reverse so they come off the stack in order. + for c in t.rows.iter().rev().flat_map(|r| r.iter().rev()) { + stack.push(c.blocks.iter()); + } + } + None => { + stack.pop(); + } + } + } + out +} + +/// A comment in a flow ([`Inline::Comment`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FComment { + pub author: String, + pub initials: String, + /// ISO 8601; empty = unknown. + pub date: String, + pub resolved: bool, + /// The comment this one answers (its flow id). + pub parent: Option, + pub blocks: Vec, +} + +/// A field that never shows its result: `ASK` and `SET` only assign text to a bookmark +/// (ECMA-376 §17.16.5.4, §17.16.5.60), as layout shows them. Its stored result stays in the +/// flow (ODT writes it back); text exports write nothing for it. +pub fn shows_nothing(instr: &str) -> bool { + let name = instr.split_whitespace().next().unwrap_or(""); + name.eq_ignore_ascii_case("ASK") || name.eq_ignore_ascii_case("SET") +} + +/// What [`super::from_doc_with`] keeps besides text and formatting. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FlowOpts { + /// Tracked changes: deleted text stays, and text carries its insertion and deletion marks. + pub tracked: bool, + /// Fields as [`Inline::Field`] (otherwise their result is text). + pub fields: bool, + /// Footnotes and endnotes as [`Inline::Note`] (otherwise left out). + pub notes: bool, + /// Comments as [`Inline::Comment`] and [`Inline::CommentEnd`] (otherwise left out). + pub comments: bool, + /// Text boxes and drawing shapes as [`Inline::Shape`] (otherwise left out). + pub boxes: bool, + /// Character formatting beyond the [`Fmt`] fields ([`Fmt::extra`]), hidden text kept (as + /// hidden), and a monospaced font by its name rather than as code. + pub chars: bool, + /// Paragraph formatting beyond the [`Para`] fields ([`Para::extra`]). + pub paras: bool, + /// Lists with their identity and labels ([`ListInfo::list`], [`ListInfo::label`]; otherwise + /// consecutive items form one list in the default look of its kind). + pub lists: bool, +} + +/// Base text size (points) a phonetic guide falls back to when the source states none: the +/// Phonetic Guide command's own fallback for an unresolved size +/// (`crates/engine/src/cmd/phonetic.rs`). +pub const RUBY_BASE_SIZE: f32 = 10.5; + +/// Ruby text size for a base of `base_size` points: half the base size, rounded down to a half +/// point, as the Phonetic Guide command sets it. +fn ruby_default_size(base_size: f32) -> f32 { + (base_size.floor() / 2.0).max(1.0) +} + +/// The ruby a format without its own ruby markup gets: a [`InlineObject::Ruby`] over `base` with +/// the Phonetic Guide's defaults (centred, half the base size, raised clear of the base text). +pub fn ruby_object(base: String, ruby: String, base_size: f32) -> InlineObject { + let base_size = if base_size.is_finite() { base_size.clamp(1.0, 1584.0) } else { RUBY_BASE_SIZE }; + let size = ruby_default_size(base_size); + let raise = ruby_raise(0.0, base_size, size).clamp(0.0, 1584.0); + InlineObject::Ruby { base, ruby, align: RubyAlign::default(), size, raise, base_size, lang: String::new(), props: Default::default() } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + #[default] + Normal, + /// 1..=6. + Heading(u8), + Title, + Quote, + /// One line of a code block. + Code, + /// A horizontal rule (empty paragraph with a bottom border). + Rule, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ListInfo { + pub ordered: bool, + /// 0-based nesting level (0..=8). + pub level: u8, + /// Number of the first item of an ordered list (1 unless the source says otherwise). + pub start: u32, + /// The list the paragraph belongs to, when the source tells its lists apart (ODT): items + /// of one list share its number, wherever they are, and a different number starts another + /// list. 0: not stated (consecutive items form one list). + pub list: u32, + /// How the paragraph's level shows its number or bullet, when the source says. + pub label: Option