mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
refactor(doc): split lib.rs into modules
This commit is contained in:
@@ -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<Arc<Block>>;
|
||||
|
||||
/// 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<u32>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::Part;
|
||||
use super::*;
|
||||
|
||||
pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
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<u32, Part>, b: &BTreeMap<u32, Part>) -> 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<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
|
||||
}
|
||||
@@ -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<Arc<Vec<u8>>, 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<String, Arc<Vec<u8>>>) {
|
||||
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<String, Arc<Vec<u8>>>, bytes: &Vec<u8>) -> Option<String> {
|
||||
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<u32> = 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<Path> {
|
||||
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<Pos> {
|
||||
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<Path> {
|
||||
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<Path> {
|
||||
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<usize> = 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<u32> {
|
||||
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<StoryRef> {
|
||||
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<StoryRef> {
|
||||
let followers: std::collections::BTreeSet<u32> = 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<StoryRef> = 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<u32> = 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<Vec<u32>> {
|
||||
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<u32, u32> = 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<Vec<u32>> {
|
||||
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<u32>) -> BTreeMap<u32, (f32, f32)> {
|
||||
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<u32> {
|
||||
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<Block>], 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<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
let mut out = roots;
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = 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<u8>, 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<PermRange> {
|
||||
let mut out: Vec<PermRange> = 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<u32, Vec<usize>> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, DocError>;
|
||||
+20
-1696
File diff suppressed because it is too large
Load Diff
@@ -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<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
|
||||
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<u32> = 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<Blocks>,
|
||||
/// Above the rest of a note continued from the page before.
|
||||
pub continuation_separator: Option<Blocks>,
|
||||
/// Below the part of a note that continues on the next page.
|
||||
pub continuation_notice: Option<Blocks>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
/// 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<props::Rgb>,
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
}
|
||||
@@ -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<u32>);
|
||||
|
||||
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<String> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Rgb>,
|
||||
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
|
||||
pub page_picture: Option<PagePicture>,
|
||||
pub watermark: Option<Watermark>,
|
||||
/// 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<Rgb>,
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
/// `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<math::MathProps>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<Zoom>,
|
||||
/// 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<ZoomFit>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u32>, out: &mut Vec<Path>, 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<u32>, out: &mut Vec<Path>, 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
|
||||
}
|
||||
Reference in New Issue
Block a user