From 440d314f9c3431d155c407c0c45a4e5bc104f0e2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:21:38 +0000 Subject: [PATCH] refactor(doc): split lib.rs into modules --- crates/doc/src/block.rs | 80 ++ crates/doc/src/compare.rs | 96 ++ crates/doc/src/document.rs | 862 ++++++++++++++++++ crates/doc/src/error.rs | 15 + crates/doc/src/lib.rs | 1716 +---------------------------------- crates/doc/src/parts.rs | 133 +++ crates/doc/src/position.rs | 73 ++ crates/doc/src/settings.rs | 305 +++++++ crates/doc/src/text_walk.rs | 163 ++++ 9 files changed, 1747 insertions(+), 1696 deletions(-) create mode 100644 crates/doc/src/block.rs create mode 100644 crates/doc/src/compare.rs create mode 100644 crates/doc/src/document.rs create mode 100644 crates/doc/src/error.rs create mode 100644 crates/doc/src/parts.rs create mode 100644 crates/doc/src/position.rs create mode 100644 crates/doc/src/settings.rs create mode 100644 crates/doc/src/text_walk.rs diff --git a/crates/doc/src/block.rs b/crates/doc/src/block.rs new file mode 100644 index 000000000..251aa6e84 --- /dev/null +++ b/crates/doc/src/block.rs @@ -0,0 +1,80 @@ +use super::*; + +/// 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 + } +} diff --git a/crates/doc/src/compare.rs b/crates/doc/src/compare.rs new file mode 100644 index 000000000..41ca3ca3d --- /dev/null +++ b/crates/doc/src/compare.rs @@ -0,0 +1,96 @@ +use super::block::{Block, Blocks}; +use super::parts::Part; +use super::*; + +pub fn para_block(p: Paragraph) -> Arc { + Arc::new(Block::Para(p)) +} + +/// The same blocks: the same content. The blocks are compared by address first — the +/// document is copy-on-write, so a block that changed was copied and has another address — +/// and a block that was copied but holds the same content is compared by value. +pub(crate) fn same_blocks(a: &Blocks, b: &Blocks) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y)) +} + +/// The same block by value. A table's cells compare their blocks by address first too: an edit in +/// one cell copies the whole table, which mustn't mean comparing every cell by value. +fn same_block(a: &Block, b: &Block) -> bool { + match (a, b) { + (Block::Table(x), Block::Table(y)) => same_table(x, y), + _ => a == b, + } +} + +/// The same table. Every field is listed, so a new one does not build until someone decides +/// whether a change to it counts. +fn same_table(a: &Table, b: &Table) -> bool { + let Table { props, grid, rows, controls } = a; + let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b; + props == other_props + && grid == other_grid + && controls == other_controls + && rows.len() == other_rows.len() + && rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y)) +} + +fn same_row(a: &Row, b: &Row) -> bool { + let Row { props, cells, controls } = a; + let Row { props: other_props, cells: other_cells, controls: other_controls } = b; + props == other_props + && controls == other_controls + && cells.len() == other_cells.len() + && cells.iter().zip(other_cells).all(|(x, y)| { + let Cell { props, blocks, controls } = x; + let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y; + props == other_props && controls == other_controls && same_blocks(blocks, other_blocks) + }) +} + +/// The same building blocks, their content compared by address first. +pub(crate) fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| { + let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x; + let BuildingBlock { + name: other_name, + gallery: other_gallery, + category: other_category, + description: other_description, + guid: other_guid, + style: other_style, + types: other_types, + behaviors: other_behaviors, + blocks: other_blocks, + } = y; + name == other_name + && gallery == other_gallery + && category == other_category + && description == other_description + && guid == other_guid + && style == other_style + && types == other_types + && behaviors == other_behaviors + && same_blocks(blocks, other_blocks) + }) +} + +/// The same parts: the same keys, and each part the same. +pub(crate) fn same_parts(a: &BTreeMap, b: &BTreeMap) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2)) +} + +/// The same part: the same kind, text body and blocks. Every field is listed, so a new one +/// does not build until someone decides whether a change to it counts. +fn same_part(a: &Part, b: &Part) -> bool { + let Part { kind, blocks, body } = a; + let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b; + kind == other_kind && body == other_body && same_blocks(blocks, other_blocks) +} + +/// The same bytes: the same content. The bytes are compared by address first — a changed +/// picture was copied, so it has another address — and a picture that was copied but holds +/// the same bytes is compared by value. +pub(crate) fn same_bytes(a: &BTreeMap>>, b: &BTreeMap>>) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2)) +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs new file mode 100644 index 000000000..318bfcc33 --- /dev/null +++ b/crates/doc/src/document.rs @@ -0,0 +1,862 @@ +use super::block::{Block, Blocks, BoxBudget}; +use super::compare::{para_block, same_blocks, same_bytes, same_glossary, same_parts}; +use super::error::{DocError, Result}; +use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, TextBody, free_id}; +use super::position::{Path, Pos, StoryRef}; +use super::settings::{CoreProps, CustomProp, Settings}; +use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk}; +use super::*; + +/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a +/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; +/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate. +#[derive(Clone, Default)] +pub(crate) struct MediaIndex { + by_bytes: std::collections::HashMap>, String>, + /// `media.len()` when the index last matched it. + len: usize, + /// The number after the last generated `image{n}` key, so taken names are probed once. + next: usize, +} + +impl MediaIndex { + fn rebuild(&mut self, media: &BTreeMap>>) { + self.by_bytes.clear(); + // The first key (in key order) of equal bytes wins, as a scan would find it. + for (k, v) in media.iter().rev() { + self.by_bytes.insert(v.clone(), k.clone()); + } + self.len = media.len(); + self.next = 0; + } + + fn find(&self, media: &BTreeMap>>, bytes: &Vec) -> Option { + self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned() + } +} + +impl PartialEq for MediaIndex { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for MediaIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MediaIndex") + } +} + +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(), + glossary: Vec::new(), + media_index: MediaIndex::default(), + } + } + + /// 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 + } + + fn fix_cell_blocks(bl: &mut Blocks) { + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + } + + fn fix_story_blocks(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 { + Self::fix_cell_blocks(&mut c.blocks); + } + } + } + } + } + } + + /// Every story must hold at least one paragraph, and every cell too. + pub fn ensure_nonempty(&mut self) { + Self::fix_story_blocks(&mut self.body); + for p in self.parts.values_mut() { + if p.blocks.is_empty() { + p.blocks.push(para_block(Paragraph::new())); + } + } + } + + /// Whether the document holds the same content as `other`: the same blocks, the same + /// styles, lists, settings, comments and the rest. + /// + /// The blocks are compared by address first: the document is copy-on-write, so a block + /// that changed was copied and has another address. Comparing them by value would walk + /// the whole document, and this is asked after every command. + /// + /// Every field is listed, so a new one does not build until someone decides whether a + /// change to it counts (the same rule the session's edit snapshot follows). + pub fn same_content(&self, other: &Document) -> bool { + let Document { + body, + last_section, + parts, + styles, + numbering, + comments, + revisions, + settings, + core, + sources, + custom_props, + footnote_separators, + endnote_separators, + media, + passthrough, + glossary, + media_index: _, + } = self; + let Document { + body: other_body, + last_section: other_section, + parts: other_parts, + styles: other_styles, + numbering: other_numbering, + comments: other_comments, + revisions: other_revisions, + settings: other_settings, + core: other_core, + sources: other_sources, + custom_props: other_props, + footnote_separators: other_footnotes, + endnote_separators: other_endnotes, + media: other_media, + passthrough: other_passthrough, + glossary: other_glossary, + media_index: _, + } = other; + same_blocks(body, other_body) + && same_parts(parts, other_parts) + && last_section == other_section + && styles == other_styles + && numbering == other_numbering + && comments == other_comments + && revisions == other_revisions + && settings == other_settings + && core == other_core + && sources == other_sources + && custom_props == other_props + && footnote_separators == other_footnotes + && endnote_separators == other_endnotes + && same_bytes(media, other_media) + && same_bytes(passthrough, other_passthrough) + && same_glossary(glossary, other_glossary) + } + + 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, o) in p.objects_with_offsets() { + if 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, TextMode::Plain) + } + + /// [`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, TextMode::Shown) + } + + /// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`. + pub fn final_text(&self, s: StoryRef) -> String { + self.story_text(s, TextMode::Final) + } + + fn story_text(&self, s: StoryRef, mode: TextMode) -> String { + let mut out = String::new(); + if let Some(bl) = self.story(s) { + text_of(bl, &mut out, 0, mode); + } + 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 + } + + /// Remove the section break on the mark of top-level body paragraph `block`: its section + /// joins the following one ([`SectionProps::absorb`]). False when there is no break there. + pub fn remove_section_break(&mut self, block: usize) -> bool { + if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) { + return false; + } + let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false }; + let Some(before) = p.section.take() else { return false }; + self.section_mut(block).absorb(&before); + true + } + + /// 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], ¬es_and_boxes) + } + + /// The stories a reader sees, in reading order: the body, then the headers and footers in + /// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header, + /// a first-page or even-page header whose option is off, a linked text box's own text) and + /// comment text aren't included. + pub fn shown_stories(&self) -> Vec { + let followers: std::collections::BTreeSet = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect(); + let follow = |o: &InlineObject| { + let mut v = notes_and_boxes(o); + v.retain(|(id, _)| !followers.contains(id)); + v + }; + let mut out = self.reachable(vec![StoryRef::Body], &follow); + let mut seen: std::collections::BTreeSet = out.iter().copied().collect(); + let mut roots = Vec::new(); + let mut root_seen = std::collections::BTreeSet::new(); + let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default()); + for (_, sec) in self.sections() { + // Unset versions are inherited from the previous section. + for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] { + set.default = own.default.or(set.default); + set.first = own.first.or(set.first); + set.even = own.even.or(set.even); + let first = set.first.filter(|_| sec.title_page); + let even = set.even.filter(|_| self.settings.even_odd_headers); + // By the first page each one shows on: page 1, then page 2 (even), then page 3. + let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] }; + for id in order.into_iter().flatten() { + if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) { + roots.push(StoryRef::Part(id)); + } + } + } + } + for st in self.reachable(roots, &follow) { + if seen.insert(st) { + out.push(st); + } + } + out + } + + /// 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 { + let ix = &mut self.media_index; + if ix.len != self.media.len() { + ix.rebuild(&self.media); + } + if let Some(k) = ix.find(&self.media, &bytes) { + return k; + } + if ix.by_bytes.contains_key(&bytes) { + // Stale (`media` was edited directly without changing its size): look again. + ix.rebuild(&self.media); + if let Some(k) = ix.find(&self.media, &bytes) { + return k; + } + } + let n = self.media.len().saturating_add(1); + let mut i = n.max(ix.next); + let mut key = format!("image{i}.{ext}"); + while self.media.contains_key(&key) { + i = i.saturating_add(1); + key = format!("image{i}.{ext}"); + } + let bytes = Arc::new(bytes); + self.media.insert(key.clone(), bytes.clone()); + ix.by_bytes.entry(bytes).or_insert_with(|| key.clone()); + ix.len = self.media.len(); + ix.next = i.saturating_add(1); + 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 { + // Open ranges by id, latest last (an end closes the latest open range of its id). + let mut open: BTreeMap> = BTreeMap::new(); + for path in self.para_paths(story) { + let Some(p) = self.para(story, &path) else { continue }; + for (off, o) in p.objects_with_offsets() { + match o { + InlineObject::PermStart { id, group, editor } => { + let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() }; + open.entry(*id).or_default().push(out.len()); + out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start }); + } + InlineObject::PermEnd { id } => { + if let Some(i) = open.get_mut(id).and_then(Vec::pop) + && 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.into_values().flatten() { + 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, o) in p.objects_with_offsets() { + if let InlineObject::BookmarkStart { name } = o { + out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off })); + } + } + } + } + out + } +} diff --git a/crates/doc/src/error.rs b/crates/doc/src/error.rs new file mode 100644 index 000000000..3d8f1f4bc --- /dev/null +++ b/crates/doc/src/error.rs @@ -0,0 +1,15 @@ +use super::position::StoryRef; + +#[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; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7f8a12e17..9d0100eb0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -67,677 +67,27 @@ mod review; pub use review::{AUTO_TEXT_GALLERY, BuildingBlock, Comment, QUICK_PARTS_GALLERY, Revision, RevisionKind}; -#[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), -} +mod block; +mod compare; +mod document; +mod error; +mod parts; +mod position; +mod settings; +mod text_walk; -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)) -} - -/// The same blocks: the same content. The blocks are compared by address first — the -/// document is copy-on-write, so a block that changed was copied and has another address — -/// and a block that was copied but holds the same content is compared by value. -fn same_blocks(a: &Blocks, b: &Blocks) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y)) -} - -/// The same block by value. A table's cells compare their blocks by address first too: an edit in -/// one cell copies the whole table, which mustn't mean comparing every cell by value. -fn same_block(a: &Block, b: &Block) -> bool { - match (a, b) { - (Block::Table(x), Block::Table(y)) => same_table(x, y), - _ => a == b, - } -} - -/// The same table. Every field is listed, so a new one does not build until someone decides -/// whether a change to it counts. -fn same_table(a: &Table, b: &Table) -> bool { - let Table { props, grid, rows, controls } = a; - let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b; - props == other_props - && grid == other_grid - && controls == other_controls - && rows.len() == other_rows.len() - && rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y)) -} - -fn same_row(a: &Row, b: &Row) -> bool { - let Row { props, cells, controls } = a; - let Row { props: other_props, cells: other_cells, controls: other_controls } = b; - props == other_props - && controls == other_controls - && cells.len() == other_cells.len() - && cells.iter().zip(other_cells).all(|(x, y)| { - let Cell { props, blocks, controls } = x; - let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y; - props == other_props && controls == other_controls && same_blocks(blocks, other_blocks) - }) -} - -/// The same building blocks, their content compared by address first. -fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| { - let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x; - let BuildingBlock { - name: other_name, - gallery: other_gallery, - category: other_category, - description: other_description, - guid: other_guid, - style: other_style, - types: other_types, - behaviors: other_behaviors, - blocks: other_blocks, - } = y; - name == other_name - && gallery == other_gallery - && category == other_category - && description == other_description - && guid == other_guid - && style == other_style - && types == other_types - && behaviors == other_behaviors - && same_blocks(blocks, other_blocks) - }) -} - -/// The same parts: the same keys, and each part the same. -fn same_parts(a: &BTreeMap, b: &BTreeMap) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2)) -} - -/// The same part: the same kind, text body and blocks. Every field is listed, so a new one -/// does not build until someone decides whether a change to it counts. -fn same_part(a: &Part, b: &Part) -> bool { - let Part { kind, blocks, body } = a; - let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b; - kind == other_kind && body == other_body && same_blocks(blocks, other_blocks) -} - -/// The same bytes: the same content. The bytes are compared by address first — a changed -/// picture was copied, so it has another address — and a picture that was copied but holds -/// the same bytes is compared by value. -fn same_bytes(a: &BTreeMap>>, b: &BTreeMap>>) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2)) -} - -/// 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(ToString::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, - /// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text - /// colour means inside this box, instead of black. Text whose colour comes from its runs, - /// styles or the document defaults keeps it (Word, observed with synthetic documents). - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_color: Option, -} - -/// 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; - -/// 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, - /// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off - /// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an - /// ordinary deletion and insertion. - pub track_moves: 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, - /// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its - /// natural spacing; by default Word stretches it to the margin like a wrapped line. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub do_not_expand_shift_return: 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, - /// The shape's box (its `style` width and height, points), which the text is stretched to - /// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives - /// ([`Watermark::frame`]). - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option<[f32; 2]>, -} - -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, - size: None, - } - } -} - -impl Watermark { - /// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the - /// page diagonal across (half the page width when not diagonal), as high as the text's length - /// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's - /// `rotation:315`). - pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] { - if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) { - return [w.min(4000.0), h.min(4000.0)]; - } - let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0)); - let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw }; - let width = (across * 0.5).clamp(36.0, 2000.0); - let chars = self.text.chars().take(200).count().max(1) as f32; - [width, (width / (0.55 * chars)).clamp(12.0, 400.0)] - } -} - -/// 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, - track_moves: true, - 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, - do_not_expand_shift_return: 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, - } - } -} +pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH}; +pub use compare::para_block; +use document::MediaIndex; +pub use error::{DocError, Result}; +pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id}; +pub use position::{Path, Pos, StoryRef}; +pub use settings::{ + COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source, + THEME_COLORS, Watermark, Zoom, ZoomFit, +}; +pub use text_walk::count_words; +pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between}; /// A document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -778,1032 +128,6 @@ pub struct Document { media_index: MediaIndex, } -/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a -/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; -/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate. -#[derive(Clone, Default)] -struct MediaIndex { - by_bytes: std::collections::HashMap>, String>, - /// `media.len()` when the index last matched it. - len: usize, - /// The number after the last generated `image{n}` key, so taken names are probed once. - next: usize, -} - -impl MediaIndex { - fn rebuild(&mut self, media: &BTreeMap>>) { - self.by_bytes.clear(); - // The first key (in key order) of equal bytes wins, as a scan would find it. - for (k, v) in media.iter().rev() { - self.by_bytes.insert(v.clone(), k.clone()); - } - self.len = media.len(); - self.next = 0; - } - - fn find(&self, media: &BTreeMap>>, bytes: &Vec) -> Option { - self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned() - } -} - -impl PartialEq for MediaIndex { - fn eq(&self, _: &Self) -> bool { - true - } -} - -impl std::fmt::Debug for MediaIndex { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("MediaIndex") - } -} - -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(), - glossary: Vec::new(), - media_index: MediaIndex::default(), - } - } - - /// 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 - } - - fn fix_cell_blocks(bl: &mut Blocks) { - if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { - bl.push(para_block(Paragraph::new())); - } - } - - fn fix_story_blocks(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 { - Self::fix_cell_blocks(&mut c.blocks); - } - } - } - } - } - } - - /// Every story must hold at least one paragraph, and every cell too. - pub fn ensure_nonempty(&mut self) { - Self::fix_story_blocks(&mut self.body); - for p in self.parts.values_mut() { - if p.blocks.is_empty() { - p.blocks.push(para_block(Paragraph::new())); - } - } - } - - /// Whether the document holds the same content as `other`: the same blocks, the same - /// styles, lists, settings, comments and the rest. - /// - /// The blocks are compared by address first: the document is copy-on-write, so a block - /// that changed was copied and has another address. Comparing them by value would walk - /// the whole document, and this is asked after every command. - /// - /// Every field is listed, so a new one does not build until someone decides whether a - /// change to it counts (the same rule the session's edit snapshot follows). - pub fn same_content(&self, other: &Document) -> bool { - let Document { - body, - last_section, - parts, - styles, - numbering, - comments, - revisions, - settings, - core, - sources, - custom_props, - footnote_separators, - endnote_separators, - media, - passthrough, - glossary, - media_index: _, - } = self; - let Document { - body: other_body, - last_section: other_section, - parts: other_parts, - styles: other_styles, - numbering: other_numbering, - comments: other_comments, - revisions: other_revisions, - settings: other_settings, - core: other_core, - sources: other_sources, - custom_props: other_props, - footnote_separators: other_footnotes, - endnote_separators: other_endnotes, - media: other_media, - passthrough: other_passthrough, - glossary: other_glossary, - media_index: _, - } = other; - same_blocks(body, other_body) - && same_parts(parts, other_parts) - && last_section == other_section - && styles == other_styles - && numbering == other_numbering - && comments == other_comments - && revisions == other_revisions - && settings == other_settings - && core == other_core - && sources == other_sources - && custom_props == other_props - && footnote_separators == other_footnotes - && endnote_separators == other_endnotes - && same_bytes(media, other_media) - && same_bytes(passthrough, other_passthrough) - && same_glossary(glossary, other_glossary) - } - - 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, o) in p.objects_with_offsets() { - if 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, TextMode::Plain) - } - - /// [`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, TextMode::Shown) - } - - /// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`. - pub fn final_text(&self, s: StoryRef) -> String { - self.story_text(s, TextMode::Final) - } - - fn story_text(&self, s: StoryRef, mode: TextMode) -> String { - let mut out = String::new(); - if let Some(bl) = self.story(s) { - text_of(bl, &mut out, 0, mode); - } - 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 - } - - /// Remove the section break on the mark of top-level body paragraph `block`: its section - /// joins the following one ([`SectionProps::absorb`]). False when there is no break there. - pub fn remove_section_break(&mut self, block: usize) -> bool { - if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) { - return false; - } - let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false }; - let Some(before) = p.section.take() else { return false }; - self.section_mut(block).absorb(&before); - true - } - - /// 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], ¬es_and_boxes) - } - - /// The stories a reader sees, in reading order: the body, then the headers and footers in - /// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header, - /// a first-page or even-page header whose option is off, a linked text box's own text) and - /// comment text aren't included. - pub fn shown_stories(&self) -> Vec { - let followers: std::collections::BTreeSet = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect(); - let follow = |o: &InlineObject| { - let mut v = notes_and_boxes(o); - v.retain(|(id, _)| !followers.contains(id)); - v - }; - let mut out = self.reachable(vec![StoryRef::Body], &follow); - let mut seen: std::collections::BTreeSet = out.iter().copied().collect(); - let mut roots = Vec::new(); - let mut root_seen = std::collections::BTreeSet::new(); - let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default()); - for (_, sec) in self.sections() { - // Unset versions are inherited from the previous section. - for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] { - set.default = own.default.or(set.default); - set.first = own.first.or(set.first); - set.even = own.even.or(set.even); - let first = set.first.filter(|_| sec.title_page); - let even = set.even.filter(|_| self.settings.even_odd_headers); - // By the first page each one shows on: page 1, then page 2 (even), then page 3. - let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] }; - for id in order.into_iter().flatten() { - if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) { - roots.push(StoryRef::Part(id)); - } - } - } - } - for st in self.reachable(roots, &follow) { - if seen.insert(st) { - out.push(st); - } - } - out - } - - /// 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 { - let ix = &mut self.media_index; - if ix.len != self.media.len() { - ix.rebuild(&self.media); - } - if let Some(k) = ix.find(&self.media, &bytes) { - return k; - } - if ix.by_bytes.contains_key(&bytes) { - // Stale (`media` was edited directly without changing its size): look again. - ix.rebuild(&self.media); - if let Some(k) = ix.find(&self.media, &bytes) { - return k; - } - } - let n = self.media.len().saturating_add(1); - let mut i = n.max(ix.next); - let mut key = format!("image{i}.{ext}"); - while self.media.contains_key(&key) { - i = i.saturating_add(1); - key = format!("image{i}.{ext}"); - } - let bytes = Arc::new(bytes); - self.media.insert(key.clone(), bytes.clone()); - ix.by_bytes.entry(bytes).or_insert_with(|| key.clone()); - ix.len = self.media.len(); - ix.next = i.saturating_add(1); - 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 { - // Open ranges by id, latest last (an end closes the latest open range of its id). - let mut open: BTreeMap> = BTreeMap::new(); - for path in self.para_paths(story) { - let Some(p) = self.para(story, &path) else { continue }; - for (off, o) in p.objects_with_offsets() { - match o { - InlineObject::PermStart { id, group, editor } => { - let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() }; - open.entry(*id).or_default().push(out.len()); - out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start }); - } - InlineObject::PermEnd { id } => { - if let Some(i) = open.get_mut(id).and_then(Vec::pop) - && 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.into_values().flatten() { - 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, o) in p.objects_with_offsets() { - if let InlineObject::BookmarkStart { name } = o { - 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() -} - -/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped. -pub(crate) const PARA_WALK_DEPTH: usize = 16; - -/// The note and text box stories an object shows, for `Document::reachable`. -fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> { - match o { - InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])], - o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(), - } -} - -fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { - if depth > PARA_WALK_DEPTH { - 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(); - } -} - -/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside. -pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize, (a, b): (&[u32], &[u32])) { - use std::cmp::Ordering::{Greater, Less}; - if depth > PARA_WALK_DEPTH { - return; - } - // Where the paths starting with `q` lie against `x`: all before, all after, or either. - let side = |q: &[u32], x: &[u32]| { - let m = q.len().min(x.len()); - q[..m].cmp(&x[..m]) - }; - for (i, blk) in bl.iter().enumerate() { - prefix.push(i as u32); - if side(prefix, b) == Greater { - prefix.pop(); - break; - } - if side(prefix, a) != Less { - match &**blk { - Block::Para(_) => { - if prefix.as_slice() >= a && prefix.as_slice() <= b { - 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); - if side(prefix, a) != Less && side(prefix, b) != Greater { - walk_between(&cell.blocks, prefix, out, depth + 1, (a, b)); - } - prefix.pop(); - prefix.pop(); - } - } - } - } - } - prefix.pop(); - } -} - -/// Which text of a paragraph [`text_of`] collects. -#[derive(Clone, Copy)] -enum TextMode { - /// Every run, tracked deletions included ([`Paragraph::plain_text`]). - Plain, - /// As shown on screen ([`Paragraph::shown_text`]). - Shown, - /// Without tracked deletions ([`Paragraph::final_text`]). - Final, -} - -fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) { - if depth > 16 { - return; - } - for b in bl { - match &**b { - Block::Para(p) => { - out.push_str(&match mode { - TextMode::Plain => p.plain_text(), - TextMode::Shown => p.shown_text(), - TextMode::Final => p.final_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, mode); - 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 test_log; #[cfg(test)] diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs new file mode 100644 index 000000000..535073bd9 --- /dev/null +++ b/crates/doc/src/parts.rs @@ -0,0 +1,133 @@ +use super::block::Blocks; +use super::position::Pos; +use super::*; + +/// 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) +} + +#[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, + /// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text + /// colour means inside this box, instead of black. Text whose colour comes from its runs, + /// styles or the document defaults keeps it (Word, observed with synthetic documents). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_color: Option, +} + +/// 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; + +/// 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") + } +} diff --git a/crates/doc/src/position.rs b/crates/doc/src/position.rs new file mode 100644 index 000000000..479aab4ae --- /dev/null +++ b/crates/doc/src/position.rs @@ -0,0 +1,73 @@ +use super::*; + +/// 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(ToString::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 } + } +} diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs new file mode 100644 index 000000000..d5a739038 --- /dev/null +++ b/crates/doc/src/settings.rs @@ -0,0 +1,305 @@ +use super::*; + +/// 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, + /// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off + /// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an + /// ordinary deletion and insertion. + pub track_moves: 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, + /// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its + /// natural spacing; by default Word stretches it to the margin like a wrapped line. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub do_not_expand_shift_return: 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, + /// The shape's box (its `style` width and height, points), which the text is stretched to + /// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives + /// ([`Watermark::frame`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option<[f32; 2]>, +} + +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, + size: None, + } + } +} + +impl Watermark { + /// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the + /// page diagonal across (half the page width when not diagonal), as high as the text's length + /// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's + /// `rotation:315`). + pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] { + if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) { + return [w.min(4000.0), h.min(4000.0)]; + } + let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0)); + let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw }; + let width = (across * 0.5).clamp(36.0, 2000.0); + let chars = self.text.chars().take(200).count().max(1) as f32; + [width, (width / (0.55 * chars)).clamp(12.0, 400.0)] + } +} + +/// 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, + track_moves: true, + 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, + do_not_expand_shift_return: 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, + } + } +} diff --git a/crates/doc/src/text_walk.rs b/crates/doc/src/text_walk.rs new file mode 100644 index 000000000..799f79893 --- /dev/null +++ b/crates/doc/src/text_walk.rs @@ -0,0 +1,163 @@ +use super::block::{Block, Blocks}; +use super::parts::PartKind; +use super::position::Path; +use super::*; + +/// 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() +} + +/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped. +pub(crate) const PARA_WALK_DEPTH: usize = 16; + +/// The note and text box stories an object shows, for `Document::reachable`. +pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> { + match o { + InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])], + o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(), + } +} + +pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { + if depth > PARA_WALK_DEPTH { + 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(); + } +} + +/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside. +pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize, (a, b): (&[u32], &[u32])) { + use std::cmp::Ordering::{Greater, Less}; + if depth > PARA_WALK_DEPTH { + return; + } + // Where the paths starting with `q` lie against `x`: all before, all after, or either. + let side = |q: &[u32], x: &[u32]| { + let m = q.len().min(x.len()); + q[..m].cmp(&x[..m]) + }; + for (i, blk) in bl.iter().enumerate() { + prefix.push(i as u32); + if side(prefix, b) == Greater { + prefix.pop(); + break; + } + if side(prefix, a) != Less { + match &**blk { + Block::Para(_) => { + if prefix.as_slice() >= a && prefix.as_slice() <= b { + 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); + if side(prefix, a) != Less && side(prefix, b) != Greater { + walk_between(&cell.blocks, prefix, out, depth + 1, (a, b)); + } + prefix.pop(); + prefix.pop(); + } + } + } + } + } + prefix.pop(); + } +} + +/// Which text of a paragraph [`text_of`] collects. +#[derive(Clone, Copy)] +pub(crate) enum TextMode { + /// Every run, tracked deletions included ([`Paragraph::plain_text`]). + Plain, + /// As shown on screen ([`Paragraph::shown_text`]). + Shown, + /// Without tracked deletions ([`Paragraph::final_text`]). + Final, +} + +pub(crate) fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) { + if depth > 16 { + return; + } + for b in bl { + match &**b { + Block::Para(p) => { + out.push_str(&match mode { + TextMode::Plain => p.plain_text(), + TextMode::Shown => p.shown_text(), + TextMode::Final => p.final_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, mode); + cells.push(s.trim_end_matches('\n').replace('\n', " ")); + } + out.push_str(&cells.join("\t")); + out.push('\n'); + } + } + } + } +} + +pub(crate) 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 +}