Files
Dylan M. Taylor 08882273d1 Add craft-wordmodel: WordCraft's document model without its editing code (#963)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.
2026-11-11 19:43:08 -05:00

135 lines
5.4 KiB
Rust

//! Range fields: a [`InlineObject::FieldStart`] … [`InlineObject::FieldEnd`] pair whose result is
//! the ordinary content between the two markers, possibly spanning paragraphs (a Zotero
//! bibliography). Pairs nest like brackets and never cross a block list: a pair opened in a
//! table cell closes in that cell.
//!
//! Editing can leave a marker without its partner (deleting a selection that holds only one
//! end). Such orphans are harmless in the model and in layout; writers call
//! [`Document::balance_field_ranges`] so a saved file always has whole fields.
use crate::para::OBJ;
use crate::{Block, Blocks, Document, InlineObject, Path, Pos, StoryRef};
/// Deepest table nesting scanned (matches `walk` in the crate root).
const MAX_DEPTH: usize = 16;
/// A whole range field.
#[derive(Clone, Debug, PartialEq)]
pub struct FieldRange {
pub instr: String,
pub locked: bool,
/// Where the `FieldStart` marker is.
pub start: Pos,
/// Where the `FieldEnd` marker is (the result is everything between the two).
pub end: Pos,
/// Number of enclosing range fields.
pub depth: usize,
}
#[derive(Default)]
struct Scan {
ranges: Vec<FieldRange>,
orphans: Vec<(Path, usize)>,
}
fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Scan) {
if depth > MAX_DEPTH {
return;
}
// Open starts: (index into out.ranges once closed is unknown, so keep the data here).
let mut open: Vec<(String, bool, Pos)> = Vec::new();
for (i, b) in bl.iter().enumerate() {
prefix.push(u32::try_from(i).unwrap_or(u32::MAX));
match &**b {
Block::Para(p) => {
let path = Path(prefix.clone());
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::FieldStart { instr, locked, .. } => {
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
}
InlineObject::FieldEnd => match open.pop() {
Some((instr, locked, start)) => {
let depth = open.len();
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
}
None => out.orphans.push((path.clone(), off)),
},
_ => {}
}
}
}
Block::Table(t) => {
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(u32::try_from(r).unwrap_or(u32::MAX));
prefix.push(u32::try_from(c).unwrap_or(u32::MAX));
scan(story, &cell.blocks, prefix, depth + 1, out);
prefix.pop();
prefix.pop();
}
}
}
}
prefix.pop();
}
out.orphans.extend(open.into_iter().map(|(_, _, p)| (p.path, p.off)));
}
fn scan_story(story: StoryRef, bl: &Blocks) -> Scan {
let mut out = Scan::default();
scan(story, bl, &mut Vec::new(), 0, &mut out);
out.ranges.sort_by(|a, b| a.start.path.cmp(&b.start.path).then(a.start.off.cmp(&b.start.off)));
out
}
impl Document {
fn story_refs(&self) -> Vec<StoryRef> {
std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k))).collect()
}
/// The whole range fields of a story, in document order of their starts.
pub fn field_ranges(&self, story: StoryRef) -> Vec<FieldRange> {
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
}
/// `p` moved past the range-field ends right after it. A click just after a field's result
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
/// after the field, as in Word, not in its result.
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
if at != off || !matches!(o, InlineObject::FieldEnd) {
break;
}
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
}
/// Whether any story holds a range-field marker without its partner.
pub fn has_unbalanced_field_ranges(&self) -> bool {
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
}
/// Remove range-field markers that have no partner. Returns how many were removed.
pub fn balance_field_ranges(&mut self) -> usize {
let mut removed = 0;
for s in self.story_refs() {
let Some(bl) = self.story(s) else { continue };
let mut orphans = scan_story(s, bl).orphans;
// Last first, so earlier offsets in the same paragraph stay valid.
orphans.sort();
for (path, off) in orphans.into_iter().rev() {
if let Ok(p) = self.para_mut(s, &path)
&& p.delete(off, off + OBJ.len_utf8()).is_ok()
{
removed += 1;
}
}
}
removed
}
}