designcraft/crates/doc/src/story.rs

521 lines
18 KiB
Rust

//! Stories: the text that flows through a chain of threaded text frames.
//!
//! A story is one `String` plus formatting:
//! - `paras`: one [`ParaFormat`] per paragraph (paragraphs are separated by `\n`; the last
//! paragraph has no separator), so `paras.len() == text.matches('\n').count() + 1`;
//! - `chars`: character-format runs `(byte_len, CharFormat)` covering exactly `text.len()` bytes.
//! Runs are never empty unless the text is empty (then there is exactly one empty run that holds
//! the "typing" format).
//!
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]).
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::attrs::{CharAttrs, ParaAttrs};
use crate::ids::{ItemId, StoryId};
use crate::table::Table;
pub const PAGE_NUMBER: char = '\u{E000}';
pub const SECTION_MARKER: char = '\u{E001}';
pub const COLUMN_BREAK: char = '\u{E002}';
pub const FRAME_BREAK: char = '\u{E003}';
pub const PAGE_BREAK: char = '\u{E004}';
pub const INDENT_HERE: char = '\u{E005}';
pub const RIGHT_INDENT_TAB: char = '\u{E006}';
pub const NEXT_PAGE_NUMBER: char = '\u{E007}';
pub const PREV_PAGE_NUMBER: char = '\u{E008}';
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
pub const TABLE_ANCHOR: char = '\u{E009}';
pub const FORCED_LINE_BREAK: char = '\u{2028}';
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
pub const NO_CHAR_STYLE: &str = "[None]";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParaFormat {
pub style: String,
#[serde(default, skip_serializing_if = "ParaAttrs::is_empty")]
pub para: ParaAttrs,
/// Paragraph-level character overrides are stored on the char runs; this holds nothing.
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
pub chars: CharAttrs,
/// The table anchored in this paragraph (key into [`Story::tables`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<u64>,
}
impl Default for ParaFormat {
fn default() -> Self {
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default(), table: None }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CharFormat {
pub style: String,
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
pub over: CharAttrs,
}
impl Default for CharFormat {
fn default() -> Self {
CharFormat { style: NO_CHAR_STYLE.into(), over: CharAttrs::default() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CharRun {
pub len: usize,
#[serde(flatten)]
pub format: CharFormat,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Story {
pub id: StoryId,
pub text: String,
pub paras: Vec<ParaFormat>,
pub chars: Vec<CharRun>,
/// Text frames in thread order.
pub frames: Vec<ItemId>,
/// Bumped on every edit (composition cache key).
#[serde(default)]
pub rev: u64,
/// Tables anchored in the story, by id (see [`crate::table`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tables: BTreeMap<u64, Arc<Table>>,
}
impl Story {
pub fn new(id: StoryId) -> Self {
Story {
id,
text: String::new(),
paras: vec![ParaFormat::default()],
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
frames: vec![],
rev: 0,
tables: BTreeMap::new(),
}
}
pub fn with_text(id: StoryId, text: &str, para: ParaFormat) -> Self {
let mut s = Story::new(id);
s.paras[0] = para;
s.insert(0, text);
s
}
pub fn len(&self) -> usize {
self.text.len()
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
/// Byte ranges of paragraphs (without the trailing `\n`).
pub fn para_ranges(&self) -> Vec<std::ops::Range<usize>> {
let mut v = Vec::with_capacity(self.paras.len());
let mut start = 0;
for (i, c) in self.text.char_indices() {
if c == '\n' {
v.push(start..i);
start = i + 1;
}
}
v.push(start..self.text.len());
v
}
/// Index of the paragraph containing byte `pos`.
pub fn para_at(&self, pos: usize) -> usize {
self.text[..pos.min(self.text.len())].bytes().filter(|b| *b == b'\n').count()
}
/// Character format at byte `pos` (the run containing it; at a boundary, the preceding run —
/// the format new typing takes).
pub fn char_format_at(&self, pos: usize) -> &CharFormat {
let mut start = 0;
for r in &self.chars {
let end = start + r.len;
if pos < end || (pos == end && pos > start) {
return &r.format;
}
start = end;
}
&self.chars.last().expect("at least one run").format
}
/// Iterate `(range, format)` of character runs.
pub fn runs(&self) -> impl Iterator<Item = (std::ops::Range<usize>, &CharFormat)> {
let mut start = 0;
self.chars.iter().map(move |r| {
let s = start;
start += r.len;
(s..start, &r.format)
})
}
/// Insert `text` at byte `pos` using the format at that position.
pub fn insert(&mut self, pos: usize, text: &str) {
let fmt = self.char_format_at(pos).clone();
self.insert_with(pos, text, fmt);
}
/// Insert with an explicit character format.
pub fn insert_with(&mut self, pos: usize, text: &str, fmt: CharFormat) {
self.insert_with_raw(pos, text, fmt);
self.fix_tables();
}
/// Insert without the table fix-up (callers fix up themselves).
pub(crate) fn insert_raw(&mut self, pos: usize, text: &str) {
let fmt = self.char_format_at(pos).clone();
self.insert_with_raw(pos, text, fmt);
}
fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
if text.is_empty() {
return;
}
let pos = floor_char_boundary(&self.text, pos);
// Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it.
let pi = self.para_at(pos);
let newlines = text.bytes().filter(|b| *b == b'\n').count();
if newlines > 0 {
let f = self.paras[pi].clone();
for _ in 0..newlines {
self.paras.insert(pi + 1, f.clone());
}
}
self.text.insert_str(pos, text);
// Character runs.
self.splice_runs(pos, 0, text.len(), fmt);
self.normalize();
self.rev += 1;
}
/// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format.
pub fn delete(&mut self, range: std::ops::Range<usize>) {
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
if a == b {
return;
}
let pi = self.para_at(a);
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
// Merged paragraphs keep a table whose anchor survives the deletion.
if removed > 0 && !self.tables.is_empty() {
let ranges = self.para_ranges();
let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR));
let last = self.paras[pi + removed]
.table
.filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR));
self.paras[pi].table = first.or(last);
}
self.paras.drain(pi + 1..pi + 1 + removed);
self.text.replace_range(a..b, "");
self.remove_run_bytes(a, b - a);
self.normalize();
self.fix_tables();
self.rev += 1;
}
/// Replace a range with text (delete + insert using the format at the start of the range).
pub fn replace(&mut self, range: std::ops::Range<usize>, text: &str) {
let fmt = if range.start < range.end { self.format_after(range.start).clone() } else { self.char_format_at(range.start).clone() };
self.delete(range.clone());
self.insert_with(range.start, text, fmt);
}
/// Format of the character *starting* at `pos`.
pub fn format_after(&self, pos: usize) -> &CharFormat {
let mut start = 0;
for r in &self.chars {
if pos < start + r.len {
return &r.format;
}
start += r.len;
}
&self.chars.last().expect("run").format
}
/// Apply `f` to the character format of every run intersecting `range` (splitting runs at the
/// range boundaries). With an empty range nothing changes (callers set typing formats instead).
pub fn format_chars(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut CharFormat)) {
let a = floor_char_boundary(&self.text, range.start);
let b = floor_char_boundary(&self.text, range.end);
if a >= b {
return;
}
self.split_at(a);
self.split_at(b);
let mut start = 0;
for r in &mut self.chars {
let end = start + r.len;
if start >= a && end <= b && r.len > 0 {
f(&mut r.format);
}
start = end;
}
self.normalize();
self.rev += 1;
}
/// Apply `f` to every paragraph intersecting `range` (an empty range = the paragraph at it).
pub fn format_paras(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut ParaFormat)) {
let a = self.para_at(range.start);
let b = self.para_at(range.end.max(range.start));
for p in &mut self.paras[a..=b] {
let table = p.table;
f(p);
p.table = table;
}
self.rev += 1;
}
/// Plain text of a range.
pub fn slice(&self, range: std::ops::Range<usize>) -> &str {
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
&self.text[a..b]
}
/// Check invariants (tests and debug assertions).
pub fn check(&self) -> Result<(), String> {
let n = self.text.matches('\n').count() + 1;
if self.paras.len() != n {
return Err(format!("story {}: {} paragraph formats for {} paragraphs", self.id.0, self.paras.len(), n));
}
let total: usize = self.chars.iter().map(|r| r.len).sum();
if total != self.text.len() {
return Err(format!("story {}: runs cover {total} bytes, text has {}", self.id.0, self.text.len()));
}
if self.chars.is_empty() {
return Err("no char runs".into());
}
if !self.text.is_empty() && self.chars.iter().any(|r| r.len == 0) {
return Err("empty run in non-empty story".into());
}
let mut pos = 0;
for r in &self.chars {
pos += r.len;
if !self.text.is_char_boundary(pos) {
return Err(format!("run boundary {pos} splits a character"));
}
}
self.check_tables()
}
// ---------- run helpers ----------
fn split_at(&mut self, pos: usize) {
let mut start = 0;
for i in 0..self.chars.len() {
let end = start + self.chars[i].len;
if pos > start && pos < end {
let tail = CharRun { len: end - pos, format: self.chars[i].format.clone() };
self.chars[i].len = pos - start;
self.chars.insert(i + 1, tail);
return;
}
start = end;
}
}
fn splice_runs(&mut self, pos: usize, _del: usize, ins: usize, fmt: CharFormat) {
if self.chars.len() == 1 && self.chars[0].len == 0 {
self.chars[0] = CharRun { len: ins, format: fmt };
return;
}
self.split_at(pos);
// Find insertion index: after the run that ends at pos.
let mut start = 0;
let mut idx = self.chars.len();
for (i, r) in self.chars.iter().enumerate() {
if start == pos {
idx = i;
break;
}
start += r.len;
}
// Extend the neighbouring run if it has the same format.
if idx > 0 && self.chars[idx - 1].format == fmt {
self.chars[idx - 1].len += ins;
} else if idx < self.chars.len() && self.chars[idx].format == fmt {
self.chars[idx].len += ins;
} else {
self.chars.insert(idx, CharRun { len: ins, format: fmt });
}
}
fn remove_run_bytes(&mut self, pos: usize, len: usize) {
let typing = self.char_format_at(pos).clone();
let end = pos + len;
let mut start = 0;
for r in &mut self.chars {
let (rs, re) = (start, start + r.len);
start = re;
let ov = re.min(end).saturating_sub(rs.max(pos));
r.len -= ov;
}
self.chars.retain(|r| r.len > 0);
if self.chars.is_empty() {
self.chars.push(CharRun { len: 0, format: typing });
}
}
fn normalize(&mut self) {
let mut out: Vec<CharRun> = Vec::with_capacity(self.chars.len());
for r in self.chars.drain(..) {
if r.len == 0 && !out.is_empty() {
continue;
}
match out.last_mut() {
Some(last) if last.format == r.format => last.len += r.len,
Some(last) if last.len == 0 => *last = r,
_ => out.push(r),
}
}
if out.is_empty() {
out.push(CharRun { len: 0, format: CharFormat::default() });
}
self.chars = out;
}
}
pub fn floor_char_boundary(s: &str, mut i: usize) -> usize {
i = i.min(s.len());
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
fn st(t: &str) -> Story {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn paragraphs_track_newlines() {
let mut s = st("one\ntwo");
assert_eq!(s.paras.len(), 2);
s.insert(3, "\nmid");
assert_eq!(s.text, "one\nmid\ntwo");
assert_eq!(s.paras.len(), 3);
s.delete(3..8);
assert_eq!(s.text, "onetwo");
assert_eq!(s.paras.len(), 1);
s.check().unwrap();
assert_eq!(s.para_ranges(), vec![0..6]);
}
#[test]
fn char_formats_split_and_merge() {
let mut s = st("hello world");
s.format_chars(0..5, |f| f.over.size = Some(20.0));
assert_eq!(s.chars.len(), 2);
s.check().unwrap();
// Typing at the end of the bold run continues it.
s.insert(5, "!");
assert_eq!(s.chars[0].len, 6);
assert_eq!(s.text, "hello! world");
s.format_chars(0..12, |f| f.over.size = Some(20.0));
assert_eq!(s.chars.len(), 1);
s.check().unwrap();
}
#[test]
fn delete_everything_keeps_typing_format() {
let mut s = st("abc");
s.format_chars(0..3, |f| f.over.tracking = Some(50.0));
s.delete(0..3);
assert!(s.is_empty());
assert_eq!(s.chars.len(), 1);
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
s.check().unwrap();
s.insert(0, "x");
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
}
#[test]
fn unicode_boundaries_are_respected() {
let mut s = st("héllo");
s.delete(1..2); // inside 'é' (2 bytes): clamps
s.check().unwrap();
s.format_chars(0..3, |f| f.over.size = Some(9.0));
s.check().unwrap();
}
#[test]
fn replace_and_format_paras() {
let mut s = st("a\nb\nc");
s.format_paras(2..2, |p| p.style = "Head".into());
assert_eq!(s.paras[1].style, "Head");
s.replace(0..1, "AAA");
assert_eq!(s.text, "AAA\nb\nc");
s.check().unwrap();
assert_eq!(s.para_at(4), 1);
}
mod prop {
use super::*;
use proptest::prelude::*;
#[derive(Debug, Clone)]
enum Op {
Ins(usize, String),
Del(usize, usize),
Fmt(usize, usize, u8),
}
fn op() -> impl Strategy<Value = Op> {
prop_oneof![
(0usize..64, "[a-zé\n ]{0,6}").prop_map(|(p, t)| Op::Ins(p, t)),
(0usize..64, 0usize..10).prop_map(|(p, n)| Op::Del(p, n)),
(0usize..64, 0usize..10, 0u8..3).prop_map(|(p, n, k)| Op::Fmt(p, n, k)),
]
}
proptest! {
#[test]
fn invariants_hold(ops in proptest::collection::vec(op(), 0..40)) {
let mut s = st("seed text\nline two");
let mut model = s.text.clone();
for o in ops {
match o {
Op::Ins(p, t) => {
let p = floor_char_boundary(&s.text, p);
s.insert(p, &t);
model.insert_str(p, &t);
}
Op::Del(p, n) => {
let a = floor_char_boundary(&s.text, p);
let b = floor_char_boundary(&s.text, a + n).max(a);
s.delete(a..b);
model.replace_range(a..b, "");
}
Op::Fmt(p, n, k) => s.format_chars(p..p + n, |f| f.over.size = Some(8.0 + k as f64)),
}
prop_assert_eq!(&s.text, &model);
prop_assert!(s.check().is_ok(), "{:?}", s.check());
}
}
}
}
}