mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:31:36 +00:00
Paragraph::normalize now uses saturating sums, moves a run end that splits a character to the next boundary, and makes the runs cover the text exactly. Opening a .json document normalizes every paragraph in every story. Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
4 files changed
+56
-19
No files matched your search
@@ -1207,6 +1207,18 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
+18
-18
@@ -1033,32 +1033,32 @@ impl Paragraph {
|
||||
|
||||
/// Merge adjacent equal runs and drop empty ones.
|
||||
pub fn normalize(&mut self) {
|
||||
// Runs must end on character boundaries and cover the text exactly (never expected, but
|
||||
// files lie): clamp each end to the text, push it past a split character, pad the tail.
|
||||
let n = self.text.len();
|
||||
let mut end = 0usize;
|
||||
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
|
||||
for r in self.runs.drain(..) {
|
||||
if r.len == 0 {
|
||||
let mut e = end.saturating_add(r.len).min(n);
|
||||
while !self.text.is_char_boundary(e) {
|
||||
e += 1;
|
||||
}
|
||||
if e == end {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.props == r.props => l.len += r.len,
|
||||
_ => out.push(r),
|
||||
Some(l) if l.props == r.props => l.len += e - end,
|
||||
_ => out.push(Run { len: e - end, props: r.props }),
|
||||
}
|
||||
end = e;
|
||||
}
|
||||
if end < n {
|
||||
match out.last_mut() {
|
||||
Some(l) => l.len += n - end,
|
||||
None => out.push(Run { len: n, props: self.mark.clone() }),
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
|
||||
let total: usize = self.runs.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
if total < self.text.len() {
|
||||
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
|
||||
self.runs.push(Run { len: self.text.len() - total, props: p });
|
||||
} else {
|
||||
let mut keep = self.text.len();
|
||||
for r in &mut self.runs {
|
||||
r.len = r.len.min(keep);
|
||||
keep -= r.len;
|
||||
}
|
||||
self.runs.retain(|r| r.len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete `a..b` (bytes). Objects inside are removed.
|
||||
|
||||
@@ -188,7 +188,11 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
}
|
||||
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
|
||||
"txt" | "text" => wordcraft_formats::txt::import_on(bytes, page),
|
||||
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
|
||||
"json" => {
|
||||
let mut d = serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?;
|
||||
d.normalize_runs();
|
||||
d
|
||||
}
|
||||
other => match crate::io_ext::open(other, bytes, page) {
|
||||
Some(r) => r?,
|
||||
None => return open_by_content(name, other, bytes, password, page),
|
||||
|
||||
@@ -5807,6 +5807,27 @@ fn insert_picture_takes_a_windows_metafile() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
// "héllo": é is two bytes (1..3); a run ending at 2 splits it, usize::MAX overflows a sum.
|
||||
for lens in [vec![2, 4], vec![usize::MAX, usize::MAX], vec![1], vec![], vec![0, 99]] {
|
||||
let mut d = Document::from_text("héllo");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) {
|
||||
p.runs = lens.iter().map(|&len| wordcraft_doc::para::Run { len, props: Default::default() }).collect();
|
||||
}
|
||||
let bytes = serde_json::to_vec(&d).unwrap();
|
||||
let (doc, _) = crate::io::open_bytes_with("x.json", &bytes, None).unwrap();
|
||||
let Some(Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") };
|
||||
let mut end = 0;
|
||||
for (r, _) in p.run_ranges() {
|
||||
assert!(p.text.is_char_boundary(r.end) && r.end > r.start, "{lens:?}");
|
||||
end = r.end;
|
||||
}
|
||||
assert_eq!(end, p.text.len(), "{lens:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_aliases_add_one_undo_step() {
|
||||
let mut s = s();
|
||||
|
||||
Reference in new issue
Block a user