JSON open repairs hostile run lengths (#1337) (#1412)

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:
authored and GitHub committed 2026-10-14 19:31:14 -04:00
1 parent 4d2649738e
commit 3370fd1ba2
4 files changed
+56 -19

No files matched your search

+12
View File
@@ -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
View File
@@ -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.
+5 -1
View File
@@ -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),
+21
View File
@@ -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();