//! WordCraft document model. //! //! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers, //! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables; //! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is //! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they //! touch (`Arc::make_mut`). //! //! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths //! compare in document order. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod bidi; pub mod chart; mod cjk_numbers; pub mod cluster; pub mod collate; pub mod connector; pub mod control; pub mod edit; pub mod effects; pub mod encoding; pub mod eq; pub mod fields; pub mod freeform; pub mod graphic; pub mod link; pub mod locale_number; pub mod math; pub mod math_edit; pub mod math_latex; pub mod math_linear; pub mod math_symbols; pub mod numbering; pub mod para; pub mod props; pub mod resolve; pub mod section; pub mod smart_art; pub mod styles; pub mod symbol_fonts; pub mod table; pub mod wordart; pub mod wrap; use std::collections::BTreeMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; pub use control::{ContentControl, ControlKind, ControlLock, ControlRange, ControlWrap}; pub use fields::FieldRange; pub use numbering::{ListKind, Numbering}; pub use para::{InlineObject, Paragraph, Run}; pub use props::{Align, CharProps, ParaProps, Rgb, TextColor}; pub use section::SectionProps; pub use styles::{Style, StyleKind, StyleSheet}; pub use table::{Cell, Row, Table}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum DocError { #[error("offset {0} is not a character boundary in the paragraph")] BadOffset(usize), #[error("no paragraph at {0}")] BadPath(String), #[error("no story {0:?}")] BadStory(StoryRef), #[error("{0}")] Invalid(String), } pub type Result = std::result::Result; /// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the /// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id), /// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken. pub fn free_id(largest: Option, used: impl Iterator, min: u32) -> Option { let Some(largest) = largest else { return Some(min) }; if let Some(next) = largest.checked_add(1) { return Some(next.max(min)); } let mut ids: Vec = used.filter(|&i| i >= min).collect(); ids.sort_unstable(); ids.dedup(); let mut want = min; for i in ids { if i > want { break; } want = want.checked_add(1)?; } Some(want) } /// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.) #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Block { Para(Paragraph), Table(Table), } impl Block { pub fn as_para(&self) -> Option<&Paragraph> { match self { Block::Para(p) => Some(p), Block::Table(_) => None, } } pub fn as_table(&self) -> Option<&Table> { match self { Block::Table(t) => Some(t), Block::Para(_) => None, } } } pub type Blocks = Vec>; /// Text boxes inside text boxes count (and show) this many levels deep. pub const MAX_TEXT_BOX_DEPTH: usize = 4; /// Bounds the work of expanding text boxes inside text boxes (layout, walks, saving), whatever a /// document says: a box isn't expanded inside itself, nesting stops at [`MAX_TEXT_BOX_DEPTH`], and /// an outermost box expands at most [`BoxBudget::MAX_NESTED`] boxes inside it. (A box whose /// shapes all show that same box, or chains of boxes each showing the next many times, would /// otherwise grow exponentially.) Outermost boxes aren't capped: their number is the document's /// (or its pages', for headers), so the work stays linear and no box is left blank for budget. #[derive(Debug, Default)] pub struct BoxBudget { open: Vec, nested: usize, total: usize, } impl BoxBudget { /// Boxes expanded inside one outermost box. pub const MAX_NESTED: usize = 64; /// Start expanding text box story `id`; false if it mustn't be (pair a true with `leave`). pub fn enter(&mut self, id: u32) -> bool { if self.open.contains(&id) || self.open.len() >= MAX_TEXT_BOX_DEPTH { return false; } if self.open.is_empty() { self.nested = 0; } else if self.nested >= Self::MAX_NESTED { return false; } else { self.nested += 1; } self.total += 1; self.open.push(id); true } /// Done expanding the box last entered. pub fn leave(&mut self) { self.open.pop(); } /// Whether a text box is being expanded now. pub fn inside(&self) -> bool { !self.open.is_empty() } /// Boxes expanded so far. pub fn total(&self) -> usize { self.total } } pub fn para_block(p: Paragraph) -> Arc { Arc::new(Block::Para(p)) } /// Which story a position is in. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum StoryRef { #[default] Body, Part(u32), } /// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell, /// three more indexes per nesting level. Lexicographic order = document order. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] pub struct Path(pub Vec); impl Path { pub fn top(i: usize) -> Path { Path(vec![i as u32]) } /// The block index within its container. pub fn last(&self) -> usize { self.0.last().copied().unwrap_or(0) as usize } pub fn with_last(&self, i: usize) -> Path { let mut p = self.clone(); if let Some(l) = p.0.last_mut() { *l = i as u32; } p } /// The container prefix (everything but the last index). pub fn parent(&self) -> &[u32] { self.0.split_last().map(|(_, r)| r).unwrap_or(&[]) } /// Nesting depth (0 = top-level story block). pub fn depth(&self) -> usize { self.0.len().saturating_sub(1) / 3 } /// The enclosing table cell (table block path, row, cell) if any. pub fn cell(&self) -> Option<(Path, usize, usize)> { if self.0.len() < 4 { return None; } let n = self.0.len(); let t = Path(self.0.get(..n - 3)?.to_vec()); Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize)) } } impl std::fmt::Display for Path { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s: Vec = self.0.iter().map(|i| i.to_string()).collect(); write!(f, "{}", s.join("/")) } } /// A position in a document. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] pub struct Pos { pub story: StoryRef, pub path: Path, pub off: usize, } impl Pos { pub fn new(story: StoryRef, path: Path, off: usize) -> Pos { Pos { story, path, off } } pub fn body(block: usize, off: usize) -> Pos { Pos { story: StoryRef::Body, path: Path::top(block), off } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum PartKind { #[default] Header, Footer, Footnote, Endnote, Comment, TextBox, } /// A document's own separators and continuation notice for its footnotes or its endnotes /// (ECMA-376 §17.11: the `w:footnote`/`w:endnote` entries of type `separator`, /// `continuationSeparator` and `continuationNotice`). `None` = the default: a short line above /// the notes, a line across the column above notes continued from the page before, no notice. /// The lines themselves are [`InlineObject::note_separator`] objects in the paragraphs. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct NoteSeparators { /// Above the notes on a page. pub separator: Option, /// Above the rest of a note continued from the page before. pub continuation_separator: Option, /// Below the part of a note that continues on the next page. pub continuation_notice: Option, } impl NoteSeparators { /// No separator of the document's own: the defaults apply. pub fn is_empty(&self) -> bool { self.separator.is_none() && self.continuation_separator.is_none() && self.continuation_notice.is_none() } } /// A secondary story. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] pub struct Part { pub kind: PartKind, pub blocks: Blocks, /// A text box's text direction, alignment and link (unused by other parts). #[serde(default, skip_serializing_if = "TextBody::is_default")] pub body: TextBody, } /// How a text box shows its text (Shape Format › Text): which way it runs, where it sits between /// the box's top and bottom, and the box its overflow continues in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct TextBody { pub vert: props::TextVert, pub anchor: props::VAlign, /// Create Link: the text box (another `TextBox` part, shown by its own shape) the text /// continues in when it doesn't fit this one. The linked box shows the rest of this box's /// story; its own blocks are unused. See [`Document::text_box_chains`]. pub next: Option, /// Internal margins (left, top, right, bottom), EMU, when the file gives its own /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`); /// `None` is Word's defaults. pub inset: Option<[u32; 4]>, /// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the /// box to its text, so none of the text is cut. pub auto_fit: bool, } /// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05". pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720]; /// Widest text box margin kept, EMU (22", the largest page side). pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800; impl TextBody { pub fn is_default(&self) -> bool { *self == TextBody::default() } /// The internal margins (left, top, right, bottom) in points. pub fn insets_pt(&self) -> [f32; 4] { self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0) } } /// Most boxes one chain of linked text boxes runs through (hostile files). pub const MAX_LINKED_BOXES: usize = 256; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct Comment { pub author: String, pub initials: String, /// ISO 8601. pub date: String, /// Reply to this comment id. pub parent: Option, pub resolved: bool, /// `Document::parts` id of the comment text. pub part: u32, /// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word /// keeps tracking the same comment. `None` for comments made here: the writer assigns one. #[serde(skip_serializing_if = "Option::is_none")] pub durable_id: Option, /// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there. #[serde(skip_serializing_if = "Option::is_none")] pub date_utc: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum RevisionKind { #[default] Insert, Delete, Format, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Revision { pub kind: RevisionKind, pub author: String, pub date: String, /// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision, /// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain /// insertion or deletion. #[serde(skip_serializing_if = "Option::is_none")] pub move_name: Option, } impl Revision { pub fn new(kind: RevisionKind, author: impl Into, date: impl Into) -> Revision { Revision { kind, author: author.into(), date: date.into(), move_name: None } } } impl Document { /// The move name of revision `idx`, when it is one end of a move. pub fn move_name(&self, idx: Option) -> Option<&str> { idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref()) } } /// A bibliography source. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct Source { /// Unique tag cited by CITATION fields. pub tag: String, /// book, bookSection, article (journal), periodical, conference, report, website, film, other pub kind: String, /// "Last, First; Last, First" pub author: String, pub title: String, pub year: String, pub publisher: String, pub city: String, pub journal: String, pub volume: String, pub pages: String, pub url: String, /// More bibliography fields (Create Source › Show All Bibliography Fields). pub editor: String, pub edition: String, pub issue: String, pub month: String, pub day: String, /// Date accessed (websites). pub accessed: String, pub doi: String, } /// A custom document property (File › Info › Properties › Custom). Citation managers keep /// their per-document preferences here (Zotero: `ZOTERO_PREF_1`, `ZOTERO_PREF_2`…). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct CustomProp { pub name: String, /// OOXML variant type: `lpwstr`, `i4`, `r8`, `bool`, `filetime`…, or `raw` when `value` is /// the property's XML content kept verbatim (vectors, blobs). pub kind: String, pub value: String, } /// Document properties (File › Info). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct CoreProps { pub title: String, pub subject: String, pub creator: String, pub keywords: String, pub description: String, pub category: String, pub last_modified_by: String, pub created: String, pub modified: String, pub revision: u32, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Settings { pub track_changes: bool, /// Default tab stop interval, points. pub default_tab: f32, pub even_odd_headers: bool, pub mirror_margins: bool, pub auto_hyphenation: bool, /// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking /// it at a word boundary would leave more than this empty at its end. pub hyphenation_zone: f32, /// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off). pub hyphenate_caps: bool, /// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit. pub consecutive_hyphen_limit: u32, pub page_color: Option, /// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour. pub page_picture: Option, pub watermark: Option, /// Theme fonts (major = headings, minor = body). pub major_font: String, pub minor_font: String, /// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink. pub theme_colors: Vec, pub theme_name: String, pub footnote_format: section::NumFormat, pub endnote_format: section::NumFormat, /// The document's footnote and endnote placement, start and restart (`w:footnotePr` / /// `w:endnotePr` in the settings); sections may override them. #[serde(skip_serializing_if = "section::NoteProps::is_empty")] pub footnote_pr: section::NoteProps, #[serde(skip_serializing_if = "section::NoteProps::is_empty")] pub endnote_pr: section::NoteProps, /// Restrict Editing, when enforced: `readOnly`, `comments`, `trackedChanges`, `forms`, or /// `none` when only formatting is restricted (`w:documentProtection w:edit`). pub protection: Option, /// Restrict Editing limits formatting to the styles that aren't locked, and forbids direct /// formatting (`w:formatting="1"`). #[serde(skip_serializing_if = "std::ops::Not::not")] pub protect_formatting: bool, /// The protection password's hash, as the file's `w:documentProtection` attributes /// (`w:algorithmName`, `w:hashValue`, `w:saltValue`, `w:spinCount`, or the older /// `w:cryptAlgorithmSid`, `w:hash`, `w:salt`…). Empty: no password. #[serde(skip_serializing_if = "Vec::is_empty")] pub protect_hash: Vec<(String, String)>, /// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word /// 2013 and later, which places a table's border at the margin rather than its text and lets /// justified lines shrink their spaces to fit more text. pub compat_mode: u32, /// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word /// draws one that isn't installed in a serif face. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roman_fonts: Vec, /// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space /// after plus the second's space before, not the larger of the two. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub sum_paragraph_spacing: bool, /// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`). /// Word ignores the layout override in compatibility mode 15, but the flag still round-trips. pub allow_space_of_same_style_in_table: bool, /// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch. pub grid_h: f32, pub grid_v: f32, /// Equation options (`m:mathPr`), when the document has them. #[serde(skip_serializing_if = "Option::is_none")] pub math: Option, /// Citation and bibliography style (References › Style) by name, when the document /// records one (OOXML's `b:Sources/@StyleName`). #[serde(skip_serializing_if = "Option::is_none")] pub bib_style: Option, /// The template the document is attached to (Tools › Templates and Add-ins): the target of /// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it. #[serde(skip_serializing_if = "Option::is_none")] pub attached_template: Option, /// Update the document's styles from the attached template when it is opened (`w:linkStyles`). pub link_styles: bool, /// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100% /// without a fit mode. #[serde(skip_serializing_if = "Option::is_none")] pub zoom: Option, /// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376 /// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out /// the document's creator and last-modified-by. The open document keeps its real values. #[serde(skip_serializing_if = "std::ops::Not::not")] pub remove_personal_info: bool, /// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69): /// comments and tracked changes are saved without `w:date`. #[serde(skip_serializing_if = "std::ops::Not::not")] pub remove_date_time: bool, } /// A saved zoom: a percentage, or a fit mode (the percentage it last came to). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Zoom { /// 10–500. pub percent: u32, pub fit: Option, } /// View › Zoom's fit modes a document can be saved in. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ZoomFit { OnePage, PageWidth, TextWidth, } /// Word's compatibility mode for documents that don't state one. pub const LEGACY_COMPAT_MODE: u32 = 12; /// The compatibility mode of documents created by Word 2013 and later, and by WordCraft. pub const COMPAT_MODE_CURRENT: u32 = 15; /// Word's hyphenation zone when a document doesn't set one (0.25"), points. pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0; /// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say. pub const DEFAULT_GRID: f32 = 9.0; /// A picture fill for the page background (`v:background/v:fill` with an image). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PagePicture { /// Key into [`Document::media`]. pub media: String, /// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise. pub fill_type: String, /// `o:title` of the fill (texture name), kept for round trip. pub title: String, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Watermark { pub text: String, pub font: String, pub color: Rgb, pub diagonal: bool, pub semitransparent: bool, } impl Default for Watermark { fn default() -> Self { Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true } } } /// Our default theme colours. pub const THEME_COLORS: [Rgb; 12] = [ Rgb(0x00, 0x00, 0x00), Rgb(0xFF, 0xFF, 0xFF), Rgb(0x0E, 0x28, 0x41), Rgb(0xE8, 0xE8, 0xE8), Rgb(0x15, 0x60, 0x82), Rgb(0xE9, 0x71, 0x32), Rgb(0x19, 0x6B, 0x24), Rgb(0x0F, 0x9E, 0xD5), Rgb(0xA0, 0x2B, 0x93), Rgb(0x4E, 0xA7, 0x2E), Rgb(0x46, 0x78, 0x86), Rgb(0x96, 0x60, 0x7D), ]; impl Default for Settings { fn default() -> Self { Settings { track_changes: false, default_tab: 36.0, even_odd_headers: false, mirror_margins: false, auto_hyphenation: false, hyphenation_zone: DEFAULT_HYPHENATION_ZONE, hyphenate_caps: true, consecutive_hyphen_limit: 0, page_color: None, page_picture: None, watermark: None, major_font: styles::HEADING_FONT.into(), minor_font: styles::BODY_FONT.into(), theme_colors: THEME_COLORS.to_vec(), theme_name: "Craft".into(), footnote_format: section::NumFormat::Decimal, endnote_format: section::NumFormat::LowerRoman, footnote_pr: section::NoteProps::default(), endnote_pr: section::NoteProps::default(), protection: None, protect_formatting: false, protect_hash: Vec::new(), compat_mode: COMPAT_MODE_CURRENT, roman_fonts: Vec::new(), sum_paragraph_spacing: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, math: None, bib_style: None, attached_template: None, link_styles: false, zoom: None, remove_personal_info: false, remove_date_time: false, } } } /// A document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Document { pub body: Blocks, /// Properties of the last section (OOXML's body-level `w:sectPr`). pub last_section: SectionProps, pub parts: BTreeMap, pub styles: StyleSheet, pub numbering: Numbering, pub comments: BTreeMap, pub revisions: Vec, pub settings: Settings, pub core: CoreProps, /// Bibliography sources (References › Manage Sources). pub sources: Vec, /// Custom document properties, in file order. pub custom_props: Vec, /// The document's own footnote separators and continuation notice. #[serde(skip_serializing_if = "NoteSeparators::is_empty")] pub footnote_separators: NoteSeparators, /// The document's own endnote separators and continuation notice. #[serde(skip_serializing_if = "NoteSeparators::is_empty")] pub endnote_separators: NoteSeparators, /// Embedded media (images) by key. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub media: BTreeMap>>, /// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub passthrough: BTreeMap>>, } impl Default for Document { fn default() -> Self { Document::new() } } impl Document { /// A blank document: one empty Normal paragraph, Letter, built-in styles. pub fn new() -> Self { Document { body: vec![para_block(Paragraph::new())], last_section: SectionProps::default(), parts: BTreeMap::new(), styles: StyleSheet::builtin(), numbering: Numbering::default(), comments: BTreeMap::new(), revisions: Vec::new(), settings: Settings::default(), core: CoreProps::default(), sources: Vec::new(), custom_props: Vec::new(), footnote_separators: NoteSeparators::default(), endnote_separators: NoteSeparators::default(), media: BTreeMap::new(), passthrough: BTreeMap::new(), } } /// A document from plain text, one paragraph per line. pub fn from_text(text: &str) -> Self { let mut d = Document::new(); d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect(); d.ensure_nonempty(); d } /// Every story must hold at least one paragraph, and every cell too. pub fn ensure_nonempty(&mut self) { fn fix(bl: &mut Blocks) { if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { bl.push(para_block(Paragraph::new())); } for b in bl.iter_mut() { if let Block::Table(t) = &**b { let needs = t .rows .iter() .any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_))))); if needs && let Block::Table(t) = Arc::make_mut(b) { for r in &mut t.rows { for c in &mut r.cells { fix_cell(&mut c.blocks); } } } } } } fn fix_cell(bl: &mut Blocks) { if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { bl.push(para_block(Paragraph::new())); } } fix(&mut self.body); for p in self.parts.values_mut() { if p.blocks.is_empty() { p.blocks.push(para_block(Paragraph::new())); } } } pub fn story(&self, s: StoryRef) -> Option<&Blocks> { match s { StoryRef::Body => Some(&self.body), StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks), } } pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> { match s { StoryRef::Body => Ok(&mut self.body), StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)), } } /// The block list that contains the block at `path`. pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> { let mut bl = self.story(s)?; let parent = path.parent(); if !parent.len().is_multiple_of(3) { return None; } for ch in parent.chunks(3) { let [b, r, c] = ch else { return None }; let Block::Table(t) = &**bl.get(*b as usize)? else { return None }; bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks; } Some(bl) } pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> { let bad = || DocError::BadPath(path.to_string()); let parent: Vec = path.parent().to_vec(); if !parent.len().is_multiple_of(3) { return Err(bad()); } let mut bl = self.story_mut(s)?; for ch in parent.chunks(3) { let [b, r, c] = ch else { return Err(bad()) }; let blk = bl.get_mut(*b as usize).ok_or_else(bad)?; let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) }; bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks; } Ok(bl) } pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> { self.container(s, path)?.get(path.last()).map(|b| &**b) } pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> { self.block(s, path)?.as_para() } pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> { self.para(p.story, &p.path) } pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> { let i = path.last(); let bl = self.container_mut(s, path)?; match bl.get_mut(i).map(Arc::make_mut) { Some(Block::Para(p)) => Ok(p), _ => Err(DocError::BadPath(path.to_string())), } } pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> { self.block(s, path)?.as_table() } pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> { let i = path.last(); let bl = self.container_mut(s, path)?; match bl.get_mut(i).map(Arc::make_mut) { Some(Block::Table(t)) => Ok(t), _ => Err(DocError::BadPath(path.to_string())), } } /// Paths of every paragraph in a story, in document order (tables descended, depth-limited). pub fn para_paths(&self, s: StoryRef) -> Vec { let mut out = Vec::new(); if let Some(bl) = self.story(s) { walk(bl, &mut Vec::new(), &mut out, 0); } out } /// First and last positions of a story. pub fn start_of(&self, s: StoryRef) -> Pos { let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0)); Pos { story: s, path, off: 0 } } pub fn end_of(&self, s: StoryRef) -> Pos { let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0)); let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0); Pos { story: s, path, off } } /// The position just after the shape that owns text box story `part` (body first, then the /// other parts), for leaving the text box. pub fn text_box_anchor(&self, part: u32) -> Option { let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().filter(|k| **k != part).map(|k| StoryRef::Part(*k))); for story in stories { for path in self.para_paths(story) { let Some(p) = self.para(story, &path) else { continue }; for off in p.object_offsets() { if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) { return Some(Pos { story, path, off: off + para::OBJ.len_utf8() }); } } } } None } /// The next / previous paragraph path in document order. pub fn next_para(&self, s: StoryRef, path: &Path) -> Option { let all = self.para_paths(s); let i = all.binary_search(path).ok()?; all.get(i + 1).cloned() } pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option { let all = self.para_paths(s); let i = all.binary_search(path).ok()?; i.checked_sub(1).and_then(|j| all.get(j).cloned()) } /// Clamp a position into the document (valid path, char boundary). pub fn clamp(&self, p: &Pos) -> Pos { if let Some(para) = self.para_at(p) { return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) }; } let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body }; let all = self.para_paths(story); let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0)); let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0); Pos { story, path, off } } /// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`). pub fn plain_text(&self, s: StoryRef) -> String { self.story_text(s, false) } /// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their /// Unicode equivalents ([`Paragraph::shown_text`]). pub fn shown_text(&self, s: StoryRef) -> String { self.story_text(s, true) } fn story_text(&self, s: StoryRef, shown: bool) -> String { let mut out = String::new(); if let Some(bl) = self.story(s) { text_of(bl, &mut out, 0, shown); } if out.ends_with('\n') { out.pop(); } out } /// Section properties in effect for each top-level body block (index → section number), and /// the list of sections. pub fn sections(&self) -> Vec<(usize, &SectionProps)> { let mut v = Vec::new(); for (i, b) in self.body.iter().enumerate() { if let Block::Para(p) = &**b && let Some(s) = &p.section { v.push((i, &**s)); } } v.push((self.body.len().saturating_sub(1), &self.last_section)); v } /// The footnote (`endnote` false) or endnote options in effect in section `sect`: its own, /// else the document's, else Word's defaults (footnotes at the page bottom in 1, 2, 3; /// endnotes at the end of the document in i, ii, iii; numbered on through the document). /// Placements that don't apply to the kind fall back to its default. pub fn note_options(&self, sect: &SectionProps, endnote: bool) -> section::NoteOptions { use section::NotePos; let (own, doc, fmt) = if endnote { (§.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format) } else { (§.footnote_pr, &self.settings.footnote_pr, self.settings.footnote_format) }; let pos = own.pos.or(doc.pos); let pos = if endnote { pos.filter(|p| matches!(p, NotePos::SectEnd | NotePos::DocEnd)).unwrap_or(NotePos::DocEnd) } else { pos.filter(|p| matches!(p, NotePos::PageBottom | NotePos::BeneathText)).unwrap_or(NotePos::PageBottom) }; section::NoteOptions { pos, num_fmt: own.num_fmt.unwrap_or(fmt), num_start: own.num_start.or(doc.num_start).unwrap_or(1).clamp(1, section::MAX_NOTE_START), num_restart: own.num_restart.or(doc.num_restart).unwrap_or_default(), } } /// The section that contains top-level body block `block` (index into `sections()`). pub fn section_index_of(&self, block: usize) -> usize { self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0) } /// Mutable section properties for the section containing body block `block`. pub fn section_mut(&mut self, block: usize) -> &mut SectionProps { let ends: Vec = self.sections().iter().map(|(e, _)| *e).collect(); let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1)); if idx + 1 < ends.len() && let Some(e) = ends.get(idx) && let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut) && let Some(s) = p.section.as_mut() { return s; } &mut self.last_section } /// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more /// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part. pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 { self.try_add_part(kind, blocks).unwrap_or(0) } /// [`Document::add_part`], or an error, adding nothing, when every part id is taken. pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result { let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1) .ok_or_else(|| DocError::Invalid("no free part id".into()))?; let mut part = Part { kind, blocks, body: TextBody::default() }; if part.blocks.is_empty() { part.blocks.push(para_block(Paragraph::new())); } self.parts.insert(id, part); Ok(id) } /// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit, /// including generated numbered-list labels). pub fn word_count(&self) -> usize { self.story_word_count(StoryRef::Body) } /// Word's default count: the body plus its text boxes, footnotes and endnotes. pub fn word_count_including_notes(&self) -> usize { self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum() } /// Word count of one story, including generated numbered-list labels. pub fn story_word_count(&self, s: StoryRef) -> usize { let Some(bl) = self.story(s) else { return 0 }; let mut counters = numbering::Counters::default(); story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0) } /// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in /// document order. Headers, footers, comments and stories nothing points at aren't included. pub fn counted_stories(&self) -> Vec { self.reachable(vec![StoryRef::Body], &|o| match o { InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])], o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(), }) } /// Drop the text box stories no box shows any more (a deleted or cut box's text), nested /// ones too. Undo snapshots and the clipboard keep their own copies. Free for a document /// without text boxes; otherwise one pass over its paragraphs. Returns how many went. pub fn prune_text_boxes(&mut self) -> usize { if !self.parts.values().any(|p| p.kind == PartKind::TextBox) { return 0; } let roots = std::iter::once(StoryRef::Body) .chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id))) .collect(); let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect()); let before = self.parts.len(); self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id))); let gone = before - self.parts.len(); if gone > 0 { // A link to a box that went ends the chain there (its id may be reused). let ids: std::collections::BTreeSet = self.parts.keys().copied().collect(); for p in self.parts.values_mut() { if p.body.next.is_some_and(|n| !ids.contains(&n)) { p.body.next = None; } } } gone } /// The chains of linked text boxes (Create Link), each in order from the box holding the /// text: every chain has two or more boxes. A link to a part that isn't a text box, a box /// already linked from another, or links running in a circle are ignored (those boxes show /// their own text); a chain stops after [`MAX_LINKED_BOXES`]. pub fn text_box_chains(&self) -> Vec> { let is_box = |id: u32| self.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox); // Who links to whom: the first box (by id) to link to a box wins. let mut prev: BTreeMap = BTreeMap::new(); for (id, p) in &self.parts { if p.kind != PartKind::TextBox { continue; } if let Some(n) = p.body.next.filter(|n| *n != *id && is_box(*n)) { prev.entry(n).or_insert(*id); } } let next = |id: u32| self.parts.get(&id).and_then(|p| p.body.next).filter(|n| prev.get(n) == Some(&id)); let mut out = Vec::new(); for (id, p) in &self.parts { if p.kind != PartKind::TextBox || prev.contains_key(id) || next(*id).is_none() { continue; } let mut chain = vec![*id]; let mut at = *id; while let Some(n) = next(at) { if chain.len() >= MAX_LINKED_BOXES || chain.contains(&n) { break; } chain.push(n); at = n; } out.push(chain); } out } /// The chain (see [`Document::text_box_chains`]) text box `part` belongs to, if linked. pub fn text_box_chain(&self, part: u32) -> Option> { self.text_box_chains().into_iter().find(|c| c.contains(&part)) } /// The size (width, height) of the shapes showing text boxes `ids`, in any story (a group /// member's as the group stretches it). pub fn text_box_sizes(&self, ids: &std::collections::BTreeSet) -> BTreeMap { let mut out = BTreeMap::new(); let stories = std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)); for blocks in stories { for b in blocks { edit::each_para(b, 0, &mut |p| { for o in &p.objects { match o { InlineObject::Shape { story: Some(id), w, h, .. } if ids.contains(id) => { out.entry(*id).or_insert((*w, *h)); } InlineObject::Group { w, h, .. } => { for (r, c) in o.group_rects(0.0, 0.0, *w, *h) { if let Some(id) = c.text_box().filter(|id| ids.contains(id)) { out.entry(id).or_insert((r[2], r[3])); } } } _ => {} } } }); } } out } /// Every inline object in reading order: the body's, with each text box's objects where the /// box is (nested boxes too, within a [`BoxBudget`]). pub fn objects_in_reading_order(&self, f: &mut dyn FnMut(&InlineObject)) { self.walk_objects(&self.body, &mut BoxBudget::default(), &mut |p, k| { if let Some(o) = p.objects.get(k) { f(o); } true }); } /// Like [`Document::objects_in_reading_order`], with each object's paragraph and index; /// `f` returns whether to go into the object's text box (false: the box is left out). pub fn objects_in_reading_order_at(&self, f: &mut dyn FnMut(&Paragraph, usize) -> bool) { self.walk_objects(&self.body, &mut BoxBudget::default(), f); } /// Like [`Document::objects_in_reading_order_at`], also giving the top-level body block each /// object is in (for objects in text boxes, the block of the box's anchor). pub fn objects_in_reading_order_by_block(&self, f: &mut dyn FnMut(usize, &Paragraph, usize) -> bool) { let mut budget = BoxBudget::default(); for (i, b) in self.body.iter().enumerate() { self.walk_objects(std::slice::from_ref(b), &mut budget, &mut |p, k| f(i, p, k)); } } /// The footnotes and endnotes referenced inside text box story `part` (nested boxes too), in /// reading order. pub fn notes_in_text_box(&self, part: u32) -> Vec { let mut out = Vec::new(); let mut budget = BoxBudget::default(); if let Some(p) = self.parts.get(&part).filter(|p| p.kind == PartKind::TextBox) && budget.enter(part) { self.walk_objects(&p.blocks, &mut budget, &mut |p, k| { if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(k) { out.push(*id); } true }); budget.leave(); } out } fn walk_objects(&self, blocks: &[Arc], budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) { for b in blocks { edit::each_para(b, 0, &mut |p| { for (k, o) in p.objects.iter().enumerate() { if !f(p, k) { continue; } for id in o.text_boxes() { if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox) && budget.enter(id) { self.walk_objects(&part.blocks, budget, f); budget.leave(); } } } }); } } /// `roots`, then every story their objects lead to (`follow`: an object's story id and the /// part kinds that count), transitively, in the order found. fn reachable(&self, roots: Vec, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec { let mut out = roots; let mut seen: std::collections::BTreeSet = out.iter().copied().collect(); let mut i = 0; while let Some(s) = out.get(i).copied() { i += 1; let mut found = Vec::new(); for b in self.story(s).into_iter().flatten() { edit::each_para(b, 0, &mut |p| { found.extend(p.objects.iter().flat_map(follow)); }); } for (id, kinds) in found { if self.parts.get(&id).is_some_and(|p| kinds.contains(&p.kind)) && seen.insert(StoryRef::Part(id)) { out.push(StoryRef::Part(id)); } } } out } /// A drawing id no shape uses yet (for gluing a connector to a shape that has none). pub fn next_shape_id(&self) -> u32 { let mut max = 0u32; let mut see = |o: &InlineObject| match o { InlineObject::Shape { extra, .. } => max = max.max(extra.id), InlineObject::Group { children, .. } => { for c in children { if let InlineObject::Shape { extra, .. } = &c.obj { max = max.max(extra.id); } } } _ => {} }; for blocks in std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)) { for b in blocks { edit::each_para(b, 0, &mut |p| p.objects.iter().for_each(&mut see)); } } max.saturating_add(1).max(1) } /// Change every inline object in every story (paragraphs inside tables too), copying only /// the blocks whose paragraphs `wants` says have objects to change. pub fn objects_mut(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) { let mut stories: Vec<&mut Blocks> = vec![&mut self.body]; stories.extend(self.parts.values_mut().map(|p| &mut p.blocks)); for blocks in stories { for b in blocks.iter_mut() { let mut any = false; edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants)); if any { edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| { if p.objects.iter().any(wants) { p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f); p.touch(); } }); } } } } /// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted /// JSON, whose run lengths may overflow, fall short or split a character. pub fn normalize_runs(&mut self) { let mut stories: Vec<&mut Blocks> = vec![&mut self.body]; stories.extend(self.parts.values_mut().map(|p| &mut p.blocks)); for blocks in stories { for b in blocks.iter_mut() { edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize); } } } /// Number of paragraphs in the body (all depths). pub fn paragraph_count(&self) -> usize { self.para_paths(StoryRef::Body).len() } /// Add a media item; returns its key (deduplicated by content). pub fn add_media(&mut self, bytes: Vec, ext: &str) -> String { if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) { return k.clone(); } let n = self.media.len() + 1; let mut key = format!("image{n}.{ext}"); let mut i = n; while self.media.contains_key(&key) { i += 1; key = format!("image{i}.{ext}"); } self.media.insert(key.clone(), Arc::new(bytes)); key } /// The value of a custom property (names compare case-insensitively, as in Word). pub fn custom_prop(&self, name: &str) -> Option<&str> { self.custom_props.iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.value.as_str()) } /// Set a custom text property, replacing one of the same name in place. pub fn set_custom_prop(&mut self, name: &str, value: &str) { match self.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name)) { Some(p) => { p.kind = "lpwstr".into(); p.value = value.to_string(); } None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }), } } /// Remove a custom property. Returns whether it existed. pub fn remove_custom_prop(&mut self, name: &str) -> bool { let n = self.custom_props.len(); self.custom_props.retain(|p| !p.name.eq_ignore_ascii_case(name)); self.custom_props.len() != n } /// Ranges that stay editable while the document is protected (Restrict Editing exceptions), /// in document order across every story: the [`InlineObject::PermStart`]'s id, group and /// editor, its start (just after the marker) and its end (at the matching /// [`InlineObject::PermEnd`], or the end of the story when it has none). pub fn perm_ranges(&self) -> Vec { let mut out: Vec = Vec::new(); let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k))); for story in stories { let mut open: Vec = Vec::new(); for path in self.para_paths(story) { let Some(p) = self.para(story, &path) else { continue }; for off in p.object_offsets() { match p.object_at(off) { Some(InlineObject::PermStart { id, group, editor }) => { let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() }; open.push(out.len()); out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start }); } Some(InlineObject::PermEnd { id }) => { if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) { let i = open.remove(k); if let Some(r) = out.get_mut(i) { r.end = Pos { story, path: path.clone(), off }; } } } _ => {} } } } // A range left open runs to the end of its story. let end = self.end_of(story); for i in open { if let Some(r) = out.get_mut(i) { r.end = end.clone(); } } } out } /// Bookmark names in the body, in order. pub fn bookmarks(&self) -> Vec<(String, Pos)> { let mut out = Vec::new(); for path in self.para_paths(StoryRef::Body) { if let Some(p) = self.para(StoryRef::Body, &path) { for off in p.object_offsets() { if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) { out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off })); } } } } out } } /// A range that stays editable while the document is protected; see [`Document::perm_ranges`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PermRange { pub id: u32, /// Editing group (`everyone`, `editors`…); empty when the range names an editor. pub group: String, /// The one editor the range is for; empty for a group. pub editor: String, pub start: Pos, pub end: Pos, } impl PermRange { /// Can everyone edit it (rather than one person or group)? pub fn everyone(&self) -> bool { self.group.eq_ignore_ascii_case("everyone") } } /// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char). pub fn count_words(s: &str) -> usize { s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count() } fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { if depth > 16 { return; } for (i, b) in bl.iter().enumerate() { prefix.push(i as u32); match &**b { Block::Para(_) => out.push(Path(prefix.clone())), Block::Table(t) => { for (r, row) in t.rows.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() { prefix.push(r as u32); prefix.push(c as u32); walk(&cell.blocks, prefix, out, depth + 1); prefix.pop(); prefix.pop(); } } } } prefix.pop(); } } fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) { if depth > 16 { return; } for b in bl { match &**b { Block::Para(p) => { out.push_str(&if shown { p.shown_text() } else { p.plain_text() }); out.push('\n'); } Block::Table(t) => { for row in &t.rows { let mut cells = Vec::new(); for c in &row.cells { let mut s = String::new(); text_of(&c.blocks, &mut s, depth + 1, shown); cells.push(s.trim_end_matches('\n').replace('\n', " ")); } out.push_str(&cells.join("\t")); out.push('\n'); } } } } } fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize { if depth > 16 { return 0; } let mut total = 0usize; for b in bl { match &**b { Block::Para(p) => { let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0); if let Some(n) = n && let Some((label, def)) = counters.next_label(numbering, n.num, n.level) && !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None) { total = total.saturating_add(count_words(&label)); } total = total.saturating_add(count_words(&p.plain_text())); } Block::Table(t) => { for row in &t.rows { for c in &row.cells { total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1)); } } } } } total } #[cfg(test)] mod tests;