diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index d4a80e53..85f48f48 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -1,8 +1,10 @@ //! Formatted text slices: copy a range of a story (with paragraph and character formats, -//! footnotes, cross-references, index markers and tables) and insert it into another story. +//! footnotes, endnotes, editorial notes, cross-references, index markers and tables) and insert +//! it into another story. use std::sync::Arc; +use crate::endnotes::{ENDNOTE_REF, NOTE_MARK}; use crate::ids::StoryId; use crate::index::INDEX_MARK; use crate::notes::FOOTNOTE_REF; @@ -33,6 +35,12 @@ impl Story { let count = |m: char, upto: usize| self.text[..upto].matches(m).count(); let span = |m: char| count(m, a)..count(m, b); out.notes = self.notes[span(FOOTNOTE_REF)].to_vec(); + // Malformed input can have fewer payloads than markers. Keep the available entries; + // insertion creates empty entries for any missing payloads. + let endnotes = span(ENDNOTE_REF); + out.endnotes = self.endnotes.iter().skip(endnotes.start).take(endnotes.len()).cloned().collect(); + let editorial = span(NOTE_MARK); + out.editorial = self.editorial.iter().skip(editorial.start).take(editorial.len()).cloned().collect(); out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec(); out.xrefs = self.xrefs[span(XREF_MARK)].to_vec(); out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec(); @@ -81,7 +89,7 @@ impl Story { *p = crate::story::ParaFormat { table: None, ..pf.clone() }; } } - // Payloads of footnotes, cross-references and index markers, in order. + // Marker payloads in text order, keeping newly allocated destination note ids. let first = |m: char| self.text[..pos].matches(m).count(); let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK)); let no = first(crate::anchored::OBJECT_MARK); @@ -96,6 +104,22 @@ impl Story { *slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() }); } } + // Limit payload copies to inserted markers so excess source entries cannot overwrite + // notes already following the insertion point. + let ne = first(ENDNOTE_REF); + for (k, n) in src.endnotes.iter().take(src.text.matches(ENDNOTE_REF).count()).enumerate() { + if let Some(slot) = self.endnotes.get_mut(ne + k) { + let id = slot.id; + *slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() }); + } + } + let nn = first(NOTE_MARK); + for (k, n) in src.editorial.iter().take(src.text.matches(NOTE_MARK).count()).enumerate() { + if let Some(slot) = self.editorial.get_mut(nn + k) { + let id = slot.id; + *slot = Arc::new(crate::endnotes::EditorialNote { id, ..(**n).clone() }); + } + } for (k, x) in src.xrefs.iter().enumerate() { if let Some(slot) = self.xrefs.get_mut(nx + k) { *slot = x.clone(); @@ -253,6 +277,93 @@ mod tests { assert!(a.extract(2..8).notes.is_empty()); } + fn append_note_pair(st: &mut Story, text: &str) { + st.insert_endnote(st.len(), text, ParaFormat::default()); + st.insert(st.len(), &NOTE_MARK.to_string()); + let note = Arc::make_mut(st.editorial.last_mut().unwrap()); + note.text = text.into(); + note.author = format!("{text} author"); + } + + #[test] + fn clipped_endnotes_and_editorial_keep_payloads_with_fresh_destination_ids() { + let mut source = Story::new(StoryId(1)); + append_note_pair(&mut source, "outside before"); + let start = source.len(); + append_note_pair(&mut source, "Citation é\nsecond"); + let end = source.len(); + let citation = &mut Arc::make_mut(&mut source.endnotes[1]).text; + citation.format_chars(0..8, |f| f.over.size = Some(19.0)); + citation.paras[1].style = "Citation".into(); + append_note_pair(&mut source, "outside after"); + source.check().unwrap(); + let original = source.clone(); + let slice = source.extract(start..end); + slice.check().unwrap(); + assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}")); + assert_eq!(slice.endnotes, vec![source.endnotes[1].clone()]); + assert_eq!(slice.editorial, vec![source.editorial[1].clone()]); + + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "destination before"); + let at = dest.len(); + append_note_pair(&mut dest, "destination after"); + let before = dest.clone(); + assert_eq!(dest.insert_story(at, &slice), at + slice.len()); + dest.check().unwrap(); + assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3)); + assert_eq!(dest.endnotes[1].text, slice.endnotes[0].text); + assert_eq!(dest.editorial[1].text, "Citation é\nsecond"); + assert_eq!(dest.editorial[1].author, "Citation é\nsecond author"); + assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3)); + assert_eq!(dest.endnotes[0], before.endnotes[0]); + assert_eq!(dest.endnotes[2], before.endnotes[1]); + assert_eq!(dest.editorial[0], before.editorial[0]); + assert_eq!(dest.editorial[2], before.editorial[1]); + Arc::make_mut(&mut dest.endnotes[1]).text.insert(0, "changed"); + Arc::make_mut(&mut dest.editorial[1]).author = "changed".into(); + assert_eq!(source, original); + assert_eq!(slice.endnotes[0], original.endnotes[1]); + assert_eq!(slice.editorial[0], original.editorial[1]); + } + + #[test] + fn extract_tolerates_missing_endnote_and_editorial_payloads() { + let mut source = Story::new(StoryId(1)); + for text in ["before", "selected", "missing"] { + append_note_pair(&mut source, text); + } + let pair_len = ENDNOTE_REF.len_utf8() + NOTE_MARK.len_utf8(); + for available in [0, 1, 2] { + let mut damaged = source.clone(); + damaged.endnotes.truncate(available); + damaged.editorial.truncate(available); + let original = damaged.clone(); + let slice = damaged.extract(pair_len..damaged.len()); + assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(2)); + let expected = if available == 2 { 1 } else { 0 }; + assert_eq!((slice.endnotes.len(), slice.editorial.len()), (expected, expected)); + if expected == 1 { + assert_eq!(slice.endnotes[0], source.endnotes[1]); + assert_eq!(slice.editorial[0], source.editorial[1]); + } + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "untouched"); + let tail = dest.clone(); + dest.insert_story(0, &slice); + dest.check().unwrap(); + assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3)); + assert_eq!((dest.endnotes.len(), dest.editorial.len()), (3, 3)); + assert_eq!(dest.endnotes[2], tail.endnotes[0]); + assert_eq!(dest.editorial[2], tail.editorial[0]); + assert_eq!(dest.endnotes[0].text.text, if expected == 1 { "selected" } else { "" }); + assert_eq!(dest.editorial[0].text, if expected == 1 { "selected" } else { "" }); + assert!(dest.endnotes[1].text.is_empty()); + assert!(dest.editorial[1].text.is_empty()); + assert_eq!(damaged, original); + } + } + #[test] fn append_tables_preserves_sparse_and_wrapping_ids() { for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] { @@ -283,6 +394,39 @@ mod tests { } } + #[test] + fn excess_note_payloads_do_not_overwrite_destination_neighbors() { + let mut payload = Story::new(StoryId(1)); + append_note_pair(&mut payload, "copied"); + for has_markers in [false, true] { + let mut src = if has_markers { payload.clone() } else { Story::with_text(StoryId(1), "plain", ParaFormat::default()) }; + src.endnotes.push(payload.endnotes[0].clone()); + src.editorial.push(payload.editorial[0].clone()); + let original = src.clone(); + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "before"); + let at = dest.len(); + append_note_pair(&mut dest, "after"); + let before = dest.clone(); + assert_eq!(dest.insert_story(at, &src), at + src.len()); + dest.check().unwrap(); + let pair = format!("{ENDNOTE_REF}{NOTE_MARK}"); + assert_eq!(dest.text, format!("{pair}{}{pair}", src.text)); + let tail = if has_markers { 2 } else { 1 }; + assert_eq!((dest.endnotes.len(), dest.editorial.len()), (tail + 1, tail + 1)); + assert_eq!(dest.endnotes[0], before.endnotes[0]); + assert_eq!(dest.endnotes[tail], before.endnotes[1]); + assert_eq!(dest.editorial[0], before.editorial[0]); + assert_eq!(dest.editorial[tail], before.editorial[1]); + if has_markers { + assert_eq!(dest.endnotes[1].text, payload.endnotes[0].text); + assert_eq!((dest.editorial[1].text.as_str(), dest.editorial[1].author.as_str()), ("copied", "copied author")); + assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3)); + } + assert_eq!(src, original); + } + } + #[test] fn append_story_with_maximal_ids_does_not_overflow() { let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());