diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f7415b5aae..c8d88f369c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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() diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index a6b08857b6..f1379bd354 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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 = 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. diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7bd35f19c6..d96b4ebe42 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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::(bytes).map_err(|e| format!("{name}: {e}"))?, + "json" => { + let mut d = serde_json::from_slice::(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), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b69d4ff1ea..34d7bda848 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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();