Skip to main content

common/
format_runs.rs

1//! Per-block character formatting as sorted, non-overlapping byte spans.
2//!
3//! Each block carries a `Vec<FormatRun>` (formatting) and a
4//! `Vec<ImageAnchor>` (image positions). The block's `plain_text` is
5//! the authoritative character source for byte offsets used by both.
6//! Replaces the pre-Phase-1 model where every formatted run and every
7//! inline image was a row in the now-deleted `inline_elements` entity
8//! table; the [`InlineSegment`] type in this module is a transient
9//! view synthesized from `(plain_text, format_runs, block_images)`
10//! for readers (export, fragments, cursor) that still consume a
11//! per-segment shape.
12//!
13//! Invariants are documented on [`FormatRun`] and enforced by
14//! [`debug_assert_well_formed`] and by [`splice_range`] / [`shift_after`]
15//! which rebuild the run list while preserving them.
16//!
17//! **In a release build a `debug_assert!` is not enforcement.** It is compiled out, so
18//! a contract violation there produced a malformed run list *silently* — and autosave
19//! wrote that corruption to the writer's file seconds later. The bulk-edit path
20//! therefore uses the checked siblings, which report instead of assert:
21//! [`check_well_formed`], [`try_splice_range`] and [`shift_runs_for_replace`], all
22//! returning [`FormatRunError`].
23
24use crate::entities::{CharVerticalAlignment, UnderlineStyle};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28/// A violation of the format-run invariants, reported instead of asserted.
29///
30/// The invariants used to be guarded only by `debug_assert!`, which is **compiled out
31/// of release builds** — so a contract violation silently produced a malformed run list
32/// in a shipped binary, and (with autosave) that corruption became the file's new truth
33/// within seconds. Anything on the *replace* path returns this instead, so the use case
34/// can refuse the edit and say why rather than quietly mangle a writer's formatting.
35#[derive(Debug, Clone, PartialEq, Eq, Error)]
36pub enum FormatRunError {
37    #[error("byte range {start}..{end} is reversed")]
38    ReversedRange { start: u32, end: u32 },
39
40    #[error(
41        "replacement run {run_start}..{run_end} falls outside the spliced range \
42         {range_start}..{range_end}"
43    )]
44    ReplacementOutsideRange {
45        run_start: u32,
46        run_end: u32,
47        range_start: u32,
48        range_end: u32,
49    },
50
51    #[error("run {start}..{end} is empty or reversed")]
52    EmptyRun { start: u32, end: u32 },
53
54    #[error("runs overlap or are out of order at index {index}: {left:?} then {right:?}")]
55    RunsOverlap {
56        index: usize,
57        left: Box<FormatRun>,
58        right: Box<FormatRun>,
59    },
60
61    #[error("adjacent runs with identical formatting were left uncoalesced at index {index}")]
62    RunsNotCoalesced { index: usize },
63
64    #[error("run {start}..{end} runs past the end of the block's {text_len} bytes")]
65    RunPastEndOfBlock {
66        start: u32,
67        end: u32,
68        text_len: usize,
69    },
70}
71
72/// What the replacement text wears when it overwrites formatted text.
73///
74/// Before this existed the behaviour was **emergent, not chosen**: a replace was a
75/// delete followed by an insert, and `shift_runs_for_insert` hardcodes "the inserted
76/// text inherits whatever run ends at the insertion point". That silently destroys
77/// formatting — renaming a character whose name reads `Auré**lien**` dropped the bold
78/// entirely, and not one test in the repo covered it.
79///
80/// The old behaviour is still the default (and is byte-for-byte identical); it is now
81/// simply one option among four, and the caller has to look at it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83pub enum ReplaceFormatPolicy {
84    /// The replacement inherits the format of the run that straddles the start of the
85    /// replaced range or ends exactly on it (precisely: `byte_start < start &&
86    /// byte_end >= start`); otherwise it is unformatted. A run beginning *exactly* at
87    /// the start never contributes.
88    ///
89    /// This is the Qt / ProseMirror insertion convention, and it is what
90    /// delete-then-insert has always produced — byte for byte, which
91    /// `inherit_preceding_matches_the_historical_delete_then_insert` pins *differentially*
92    /// (it runs the historical primitives and compares, rather than trusting a
93    /// transcription of them).
94    #[default]
95    InheritPreceding,
96
97    /// If a **single run covers the whole replaced range**, the replacement keeps that
98    /// run's format; otherwise fall back to [`Self::InheritPreceding`]. "Rename a name
99    /// that was entirely bold and it stays bold" — without guessing when the range is
100    /// formatted unevenly.
101    PreserveIfFullyCovered,
102
103    /// The replacement takes the format that covered the **most bytes** of the replaced
104    /// range. Unformatted gaps count as a candidate, so a mostly-plain range stays
105    /// plain; ties go to the *formatted* run, because a tie means the writer's
106    /// formatting covered at least as much as its absence and dropping it is the
107    /// destructive answer.
108    KeepDominantRun,
109
110    /// The replacement carries no formatting at all.
111    PreserveNothing,
112}
113
114/// Content type for an inline segment: text, image, footnote reference, or empty.
115#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
116pub enum InlineContent {
117    #[default]
118    Empty,
119    Text(String),
120    /// A footnote reference — the marker in the prose that points at a note.
121    ///
122    /// Carries only the **label**, never the number a reader sees. The number is
123    /// a fact about document order and about which notes an export happens to
124    /// include, so storing it would mean rewriting the author's prose every time
125    /// a note was inserted above it. Renderers derive it; the model never knows
126    /// it.
127    ///
128    /// The label need not resolve. A reference whose definition lives outside
129    /// this document — which is the normal state for a host that owns note
130    /// bodies itself — must survive a round trip unchanged, so nothing here
131    /// requires a matching definition to exist.
132    FootnoteRef {
133        label: String,
134    },
135    Image {
136        name: String,
137        /// Alternative text describing the image.
138        ///
139        /// Carried on the model rather than derived from the resource name
140        /// because it is the image's accessible description and its export
141        /// representation (HTML/EPUB `alt`, DOCX drawing description, Djot's
142        /// `![…]` label) — a filename is none of those things.
143        ///
144        /// `#[serde(default)]`: fragments are serialized onto the OS clipboard,
145        /// so a payload written by a build without this field must still
146        /// deserialize.
147        #[serde(default)]
148        alt: String,
149        width: i64,
150        height: i64,
151        quality: i64,
152    },
153}
154
155/// A lean view type representing one inline segment (text or image) with its
156/// associated formatting. Used by readers (export, fragments, cursor) to
157/// consume per-segment data synthesized from `(plain_text, format_runs,
158/// block_images)` via [`crate::format_runs_query::inline_segments_for_block`].
159/// Never stored — synthesized on demand.
160///
161/// The `fmt_*` field names match those on `Block` and on `FragmentElement`
162/// so readers can copy fields verbatim across the three types.
163#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
164pub struct InlineSegment {
165    pub content: InlineContent,
166    pub fmt_font_family: Option<String>,
167    pub fmt_font_point_size: Option<i64>,
168    pub fmt_font_weight: Option<i64>,
169    pub fmt_font_bold: Option<bool>,
170    pub fmt_font_italic: Option<bool>,
171    pub fmt_font_underline: Option<bool>,
172    pub fmt_font_overline: Option<bool>,
173    pub fmt_font_strikeout: Option<bool>,
174    pub fmt_letter_spacing: Option<i64>,
175    pub fmt_word_spacing: Option<i64>,
176    pub fmt_anchor_href: Option<String>,
177    pub fmt_anchor_names: Vec<String>,
178    pub fmt_is_anchor: Option<bool>,
179    pub fmt_tooltip: Option<String>,
180    pub fmt_underline_style: Option<UnderlineStyle>,
181    pub fmt_vertical_alignment: Option<CharVerticalAlignment>,
182}
183
184/// Character-level formatting for a contiguous byte span. One per
185/// [`FormatRun`]; one per [`ImageAnchor`]. Fields mirror the `fmt_*`
186/// set on [`InlineSegment`] and on `FragmentElement` so values copy
187/// across types verbatim.
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub struct CharacterFormat {
190    pub font_family: Option<String>,
191    pub font_point_size: Option<i64>,
192    pub font_weight: Option<i64>,
193    pub font_bold: Option<bool>,
194    pub font_italic: Option<bool>,
195    pub font_underline: Option<bool>,
196    pub font_overline: Option<bool>,
197    pub font_strikeout: Option<bool>,
198    pub letter_spacing: Option<i64>,
199    pub word_spacing: Option<i64>,
200    pub anchor_href: Option<String>,
201    pub anchor_names: Vec<String>,
202    pub is_anchor: Option<bool>,
203    pub tooltip: Option<String>,
204    pub underline_style: Option<UnderlineStyle>,
205    pub vertical_alignment: Option<CharVerticalAlignment>,
206}
207
208/// One run of identical character formatting inside a block. Byte offsets
209/// are relative to the block's `plain_text` (Phase 1) or to the block's
210/// rope range (Phase 2).
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct FormatRun {
213    pub byte_start: u32,
214    pub byte_end: u32,
215    pub format: CharacterFormat,
216}
217
218/// An image embedded at a specific byte position inside a block. In
219/// Phase 1 the byte position is an index into the block's `plain_text`;
220/// in Phase 2 it points at the U+FFFC sentinel character in the rope.
221///
222/// Images carry their own [`CharacterFormat`] because vertical alignment
223/// and anchor metadata apply per inline run.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct ImageAnchor {
226    pub byte_offset: u32,
227    pub name: String,
228    /// Alternative text. See [`InlineContent::Image::alt`] for why it lives on
229    /// the model and why it defaults.
230    #[serde(default)]
231    pub alt: String,
232    pub width: i64,
233    pub height: i64,
234    pub quality: i64,
235    pub format: CharacterFormat,
236}
237
238/// A footnote reference embedded at a specific byte position inside a block.
239///
240/// The exact shape of [`ImageAnchor`], and for the same reason: a reference is
241/// an inline object occupying one `U+FFFC` sentinel in the block's text, so the
242/// rope moves it with every edit and deleting the sentence deletes the
243/// reference — no re-anchoring, no quote matching, no orphan state to keep.
244///
245/// It carries its own [`CharacterFormat`] because a reference is normally
246/// superscript, and because whatever formatting surrounds it should not bleed
247/// onto the marker.
248///
249/// What it deliberately does **not** carry is the number. See
250/// [`InlineContent::FootnoteRef`].
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct FootnoteRefAnchor {
253    pub byte_offset: u32,
254    /// Matches a footnote definition's `Frame::footnote_label`, when one exists
255    /// in this document at all. Djot's `[^label]` syntax on the wire.
256    pub label: String,
257    pub format: CharacterFormat,
258}
259
260/// One inline object anchored in a block: an image, or a footnote reference.
261///
262/// Both occupy a single `U+FFFC` and are woven into the text by the same rules,
263/// so the weave takes them as one byte-ordered sequence rather than walking two
264/// lists and hoping they interleave. They are stored apart because they are
265/// edited apart, and are brought together only here.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum BlockAnchor<'a> {
268    Image(&'a ImageAnchor),
269    FootnoteRef(&'a FootnoteRefAnchor),
270}
271
272impl BlockAnchor<'_> {
273    /// Byte position of this anchor's sentinel within the block's text.
274    pub fn byte_offset(&self) -> u32 {
275        match self {
276            BlockAnchor::Image(i) => i.byte_offset,
277            BlockAnchor::FootnoteRef(f) => f.byte_offset,
278        }
279    }
280}
281
282/// Every anchor in a block, in byte order.
283///
284/// A stable sort, so an image and a reference mirrored at the same offset keep
285/// a deterministic order rather than swapping between reads — which would show
286/// up as a diff in exported Djot for a document nobody edited.
287pub fn block_anchors<'a>(
288    images: &'a [ImageAnchor],
289    footnote_refs: &'a [FootnoteRefAnchor],
290) -> Vec<BlockAnchor<'a>> {
291    let mut anchors: Vec<BlockAnchor<'a>> = Vec::with_capacity(images.len() + footnote_refs.len());
292    anchors.extend(images.iter().map(BlockAnchor::Image));
293    anchors.extend(footnote_refs.iter().map(BlockAnchor::FootnoteRef));
294    anchors.sort_by_key(|a| a.byte_offset());
295    anchors
296}
297
298/// Debug-only invariant check. Run from `debug_assert!` callsites in
299/// the use cases that mutate format runs. Cheap: O(n) where n is the
300/// run count (typically < 100 per block in real prose).
301///
302/// # Invariants
303/// 1. Runs are sorted by `byte_start` ascending.
304/// 2. Each run has `byte_start < byte_end`.
305/// 3. Runs are non-overlapping: `runs[i].byte_end <= runs[i+1].byte_start`.
306/// 4. The last run's `byte_end` does not exceed `block_text_len`.
307/// 5. Adjacent runs with identical format are coalesced (no two
308///    consecutive runs satisfy `byte_end == next.byte_start &&
309///    format == next.format`).
310pub fn debug_assert_well_formed(runs: &[FormatRun], block_text_len: usize) {
311    // Only pay the O(n) walk where the assertion would actually fire.
312    if cfg!(debug_assertions)
313        && let Err(e) = check_well_formed(runs, block_text_len)
314    {
315        debug_assert!(false, "format runs are malformed: {e}");
316    }
317}
318
319/// The same invariants as [`debug_assert_well_formed`], but **reported rather than
320/// asserted** — so a caller that must not corrupt a block can refuse the edit.
321///
322/// This exists because `debug_assert!` is compiled out of release builds: a malformed
323/// run list produced in a shipped binary went completely undetected, and autosave wrote
324/// it to disk seconds later. The replace path calls this and propagates the error.
325pub fn check_well_formed(runs: &[FormatRun], block_text_len: usize) -> Result<(), FormatRunError> {
326    if runs.is_empty() {
327        return Ok(());
328    }
329    for run in runs {
330        if run.byte_start >= run.byte_end {
331            return Err(FormatRunError::EmptyRun {
332                start: run.byte_start,
333                end: run.byte_end,
334            });
335        }
336    }
337    for i in 0..runs.len() - 1 {
338        if runs[i].byte_end > runs[i + 1].byte_start {
339            return Err(FormatRunError::RunsOverlap {
340                index: i,
341                left: Box::new(runs[i].clone()),
342                right: Box::new(runs[i + 1].clone()),
343            });
344        }
345        if runs[i].byte_end == runs[i + 1].byte_start && runs[i].format == runs[i + 1].format {
346            return Err(FormatRunError::RunsNotCoalesced { index: i });
347        }
348    }
349    let last = runs.last().expect("non-empty");
350    if last.byte_end as usize > block_text_len {
351        return Err(FormatRunError::RunPastEndOfBlock {
352            start: last.byte_start,
353            end: last.byte_end,
354            text_len: block_text_len,
355        });
356    }
357    Ok(())
358}
359
360/// Merge adjacent runs that have identical formatting. O(n).
361pub fn coalesce_in_place(runs: &mut Vec<FormatRun>) {
362    if runs.len() < 2 {
363        return;
364    }
365    let mut write = 0usize;
366    for read in 1..runs.len() {
367        if runs[write].byte_end == runs[read].byte_start && runs[write].format == runs[read].format
368        {
369            runs[write].byte_end = runs[read].byte_end;
370        } else {
371            write += 1;
372            if write != read {
373                runs[write] = runs[read].clone();
374            }
375        }
376    }
377    runs.truncate(write + 1);
378}
379
380/// Replace the runs covering `range` with `replacement`, preserving the
381/// invariants. Runs that straddle the range boundary are clipped on
382/// either side; runs fully contained are removed.
383///
384/// The replacement byte ranges must lie within `range` and themselves
385/// be well-formed (sorted, non-overlapping). The function does NOT
386/// shift bytes after `range.end` — callers wanting to splice in a
387/// different-length text must call [`shift_after`] first or after,
388/// depending on whether the text length is changing.
389pub fn splice_range(
390    runs: &mut Vec<FormatRun>,
391    range: std::ops::Range<u32>,
392    replacement: Vec<FormatRun>,
393) {
394    if let Err(e) = try_splice_range(runs, range, replacement) {
395        // Previously this was a bare `debug_assert!` — compiled OUT of release, where a
396        // contract violation therefore went on to build a malformed run list and corrupt
397        // the block's formatting silently. Keep the loud failure in debug; in release,
398        // refuse the splice and leave `runs` untouched rather than mangle it.
399        //
400        // No existing caller can reach this: every one either builds a replacement that
401        // spans exactly the range, or goes through `shift_runs_for_delete`, which
402        // early-returns on an inverted range. It is a net, not a behaviour change.
403        debug_assert!(false, "splice_range contract violated: {e}");
404    }
405}
406
407/// [`splice_range`], but the contract is **checked and reported** instead of asserted.
408///
409/// `runs` is left completely untouched when this returns `Err` — validation happens
410/// before any mutation, so a rejected splice cannot half-apply.
411pub fn try_splice_range(
412    runs: &mut Vec<FormatRun>,
413    range: std::ops::Range<u32>,
414    replacement: Vec<FormatRun>,
415) -> Result<(), FormatRunError> {
416    if range.start > range.end {
417        return Err(FormatRunError::ReversedRange {
418            start: range.start,
419            end: range.end,
420        });
421    }
422    for r in &replacement {
423        if r.byte_start >= r.byte_end {
424            return Err(FormatRunError::EmptyRun {
425                start: r.byte_start,
426                end: r.byte_end,
427            });
428        }
429        if r.byte_start < range.start || r.byte_end > range.end {
430            return Err(FormatRunError::ReplacementOutsideRange {
431                run_start: r.byte_start,
432                run_end: r.byte_end,
433                range_start: range.start,
434                range_end: range.end,
435            });
436        }
437    }
438    for i in 1..replacement.len() {
439        if replacement[i - 1].byte_end > replacement[i].byte_start {
440            return Err(FormatRunError::RunsOverlap {
441                index: i - 1,
442                left: Box::new(replacement[i - 1].clone()),
443                right: Box::new(replacement[i].clone()),
444            });
445        }
446    }
447
448    let mut result: Vec<FormatRun> = Vec::with_capacity(runs.len() + replacement.len());
449
450    // Keep / clip everything strictly before range.start.
451    for run in runs.iter() {
452        if run.byte_end <= range.start {
453            result.push(run.clone());
454        } else if run.byte_start < range.start {
455            // Run straddles range.start: keep the left part.
456            result.push(FormatRun {
457                byte_start: run.byte_start,
458                byte_end: range.start,
459                format: run.format.clone(),
460            });
461        }
462    }
463
464    // Insert the replacement runs.
465    result.extend(replacement);
466
467    // Keep / clip everything starting at or after range.end.
468    for run in runs.iter() {
469        if run.byte_start >= range.end {
470            result.push(run.clone());
471        } else if run.byte_end > range.end {
472            // Run straddles range.end: keep the right part.
473            result.push(FormatRun {
474                byte_start: range.end,
475                byte_end: run.byte_end,
476                format: run.format.clone(),
477            });
478        }
479    }
480
481    coalesce_in_place(&mut result);
482    *runs = result;
483    Ok(())
484}
485
486/// Capture the slice of `runs` that intersects `[start..end)`, clipped
487/// to those bounds. Used by hand-rolled-inverse undo for format-only
488/// edits: callers capture this BEFORE calling [`splice_range`], and
489/// on undo splice the captured runs back into the same byte range to
490/// restore the prior state without paying the cost of a full
491/// `RopeStoreSnapshot`.
492///
493/// Gaps in the original runs (positions inside `[start..end)` with no
494/// formatting) become gaps in the captured output too — the undo
495/// splice preserves them faithfully.
496pub fn capture_runs_in_range(runs: &[FormatRun], start: u32, end: u32) -> Vec<FormatRun> {
497    let mut out = Vec::new();
498    for run in runs {
499        if run.byte_end <= start || run.byte_start >= end {
500            continue;
501        }
502        let clipped_start = std::cmp::max(run.byte_start, start);
503        let clipped_end = std::cmp::min(run.byte_end, end);
504        if clipped_start < clipped_end {
505            out.push(FormatRun {
506                byte_start: clipped_start,
507                byte_end: clipped_end,
508                format: run.format.clone(),
509            });
510        }
511    }
512    out
513}
514
515/// Capture the `(byte_offset, format)` pairs for every image anchor
516/// inside `[start..end)`. Used together with [`capture_runs_in_range`]
517/// by hand-rolled-inverse undo for format-only edits.
518pub fn capture_image_formats_in_range(
519    images: &[ImageAnchor],
520    start: u32,
521    end: u32,
522) -> Vec<(u32, CharacterFormat)> {
523    let mut out = Vec::new();
524    for img in images {
525        if img.byte_offset >= start && img.byte_offset < end {
526            out.push((img.byte_offset, img.format.clone()));
527        }
528    }
529    out
530}
531
532/// Shift the byte offsets of every run whose `byte_start >= threshold`
533/// by `delta`. Used after a text insert/delete to keep downstream runs
534/// in sync with the new block text. Runs strictly before the threshold
535/// are unaffected; runs that straddle the threshold are left alone
536/// (the caller should have spliced them first).
537///
538/// Panics in debug mode if `delta` would underflow a run's offset.
539pub fn shift_after(runs: &mut [FormatRun], threshold: u32, delta: i32) {
540    for run in runs.iter_mut() {
541        if run.byte_start >= threshold {
542            let new_start = (run.byte_start as i64) + (delta as i64);
543            let new_end = (run.byte_end as i64) + (delta as i64);
544            debug_assert!(new_start >= 0 && new_end >= new_start);
545            run.byte_start = new_start as u32;
546            run.byte_end = new_end as u32;
547        }
548    }
549}
550
551/// Synthesize a stable per-fragment id from a block id and byte offset
552/// within that block. Populates the `element_id` field in
553/// `FragmentContent::{Text, Image}` (the public layout-engine type),
554/// giving callers a stable handle across renders even though the
555/// underlying [`InlineSegment`]s are never stored. Two segments at the
556/// same `(block_id, byte_start)` always produce the same id; a segment
557/// that moves to a new byte_start (e.g. due to an insert upstream)
558/// gets a new id.
559///
560/// Bit layout (u64): bit 62 = synth tag (so synthesized ids never
561/// collide with real entity ids issued by the store's counter, which
562/// start at 1 and grow upward). Bits 32..62 = block id (1 billion
563/// blocks per document, 30 bits). Bottom 32 bits = byte offset (4 GB
564/// per block). The top bit stays zero so the value fits in positive
565/// i64 range — public DTOs expose element_id as i64.
566pub fn synth_element_id(block_id: u64, byte_start: u32) -> u64 {
567    const SYNTH_TAG: u64 = 0x4000_0000_0000_0000;
568    SYNTH_TAG | ((block_id & 0x3FFF_FFFF) << 32) | (byte_start as u64)
569}
570
571/// Same as `shift_after` for image anchors. Anchors AT the threshold are
572/// shifted (treated as part of the inserted region's right side).
573pub fn shift_images_after(images: &mut [ImageAnchor], threshold: u32, delta: i32) {
574    for img in images.iter_mut() {
575        if img.byte_offset >= threshold {
576            let new_off = (img.byte_offset as i64) + (delta as i64);
577            debug_assert!(new_off >= 0);
578            img.byte_offset = new_off as u32;
579        }
580    }
581}
582
583// ─────────────────────────────────────────────────────────────────────
584// Composite helpers used by writer use cases. These keep the per-block
585// run / image vectors well-formed under insert / delete / split.
586// ─────────────────────────────────────────────────────────────────────
587
588/// Apply an "insert `inserted_bytes` of text at `byte_offset`" mutation
589/// to a block's runs in place. Runs strictly before the offset are
590/// unchanged; runs strictly after are shifted by +inserted_bytes; runs
591/// that straddle the offset are extended (the inserted text inherits
592/// the surrounding run's format — Qt / ProseMirror convention).
593pub fn shift_runs_for_insert(runs: &mut [FormatRun], byte_offset: u32, inserted_bytes: u32) {
594    if inserted_bytes == 0 {
595        return;
596    }
597    for run in runs.iter_mut() {
598        if run.byte_start >= byte_offset {
599            run.byte_start += inserted_bytes;
600            run.byte_end += inserted_bytes;
601        } else if run.byte_end >= byte_offset {
602            // Run straddles the insertion point, or its right edge sits
603            // exactly on it. In both cases the inserted text inherits
604            // this run's format (Qt convention).
605            run.byte_end += inserted_bytes;
606        }
607    }
608}
609
610/// Apply a "delete byte range `[byte_start..byte_end)`" mutation to a
611/// block's runs. Splices the range with empty replacement (clipping
612/// straddling runs) and shifts everything past `byte_end` back by the
613/// deleted length. Adjacent runs that end up equal-format are coalesced.
614pub fn shift_runs_for_delete(runs: &mut Vec<FormatRun>, byte_start: u32, byte_end: u32) {
615    if byte_end <= byte_start {
616        return;
617    }
618    splice_range(runs, byte_start..byte_end, Vec::new());
619    let delta = (byte_end - byte_start) as i32;
620    shift_after(runs, byte_end, -delta);
621    // The shift can make a left-clipped run abut a shifted trailing run
622    // with identical format; coalesce once more to restore the invariant.
623    coalesce_in_place(runs);
624}
625
626/// Apply a "replace byte range `[byte_start..byte_end)` with `replacement_bytes` bytes"
627/// mutation to a block's runs, choosing explicitly what the replacement wears.
628///
629/// This is the one edit where the old delete-then-insert composition quietly lost a
630/// writer's formatting: replacing `Auré**lien**` deletes both runs under the range and
631/// then lets the replacement inherit whatever preceded it, so the bold is gone. That
632/// behaviour is still available — and is still the default — but it is now a *decision*
633/// ([`ReplaceFormatPolicy`]) rather than an accident of two functions being called in
634/// sequence.
635///
636/// Additive by design: `shift_runs_for_delete` / `shift_runs_for_insert` are untouched,
637/// because 13 other call sites across delete/insert/paste depend on their exact
638/// behaviour. [`ReplaceFormatPolicy::InheritPreceding`] is implemented *by calling that
639/// very composition*, so the default cannot drift away from what shipped.
640///
641/// Returns `Err` rather than corrupting the block if the range is inverted or the
642/// re-splice would violate the run invariants — see [`FormatRunError`].
643pub fn shift_runs_for_replace(
644    runs: &mut Vec<FormatRun>,
645    byte_start: u32,
646    byte_end: u32,
647    replacement_bytes: u32,
648    policy: ReplaceFormatPolicy,
649) -> Result<(), FormatRunError> {
650    if byte_end < byte_start {
651        return Err(FormatRunError::ReversedRange {
652            start: byte_start,
653            end: byte_end,
654        });
655    }
656
657    // Decide what the replacement wears from the runs as they stand BEFORE the edit —
658    // afterwards the runs under the range are gone and the evidence with them.
659    //
660    // `None` here means "do not override": let the composition below decide, which is
661    // exactly `InheritPreceding`.
662    //
663    // An empty range replaces nothing, so it is an *insertion*, and the two
664    // coverage-based policies have nothing to reason about — they must defer to the
665    // insert convention rather than strip the format the typed text would have
666    // inherited. Only `PreserveNothing`, which is an explicit request for unformatted
667    // text, still applies.
668    let destroys_formatting = byte_end > byte_start;
669    let override_format: Option<Option<CharacterFormat>> = match policy {
670        ReplaceFormatPolicy::InheritPreceding => None,
671        ReplaceFormatPolicy::PreserveNothing => Some(None),
672        ReplaceFormatPolicy::PreserveIfFullyCovered => {
673            covering_format(runs, byte_start, byte_end).map(Some)
674        }
675        ReplaceFormatPolicy::KeepDominantRun if destroys_formatting => {
676            Some(dominant_format(runs, byte_start, byte_end))
677        }
678        ReplaceFormatPolicy::KeepDominantRun => None,
679    };
680
681    // The historical composition. This IS `InheritPreceding`, byte for byte.
682    shift_runs_for_delete(runs, byte_start, byte_end);
683    shift_runs_for_insert(runs, byte_start, replacement_bytes);
684
685    // Every other policy is a targeted re-splice of exactly the replacement's span.
686    if let Some(format) = override_format
687        && replacement_bytes > 0
688    {
689        let span = byte_start..byte_start + replacement_bytes;
690        let replacement = match format {
691            Some(format) => vec![FormatRun {
692                byte_start: span.start,
693                byte_end: span.end,
694                format,
695            }],
696            None => Vec::new(),
697        };
698        try_splice_range(runs, span, replacement)?;
699    }
700    Ok(())
701}
702
703/// The format of the single run covering **all** of `[start..end)`, if one does.
704///
705/// An empty range is covered by nothing: a pure insertion has no formatting of its own
706/// to preserve, so every policy falls back to inheritance there rather than silently
707/// formatting typed text from a run it merely sits next to.
708fn covering_format(runs: &[FormatRun], start: u32, end: u32) -> Option<CharacterFormat> {
709    if end <= start {
710        return None;
711    }
712    runs.iter()
713        .find(|r| r.byte_start <= start && r.byte_end >= end)
714        .map(|r| r.format.clone())
715}
716
717/// The format covering the most bytes of `[start..end)`, or `None` if unformatted text
718/// covers more than any single run.
719///
720/// Gaps count as a candidate, so renaming inside a mostly-plain range stays plain. Ties
721/// go to the formatted run: a tie means the formatting covered at least as much of the
722/// range as its absence did, and dropping it is the destructive outcome.
723fn dominant_format(runs: &[FormatRun], start: u32, end: u32) -> Option<CharacterFormat> {
724    if end <= start {
725        return None;
726    }
727    let span = u64::from(end - start);
728    let mut covered = 0u64;
729    let mut best: Option<(u64, &FormatRun)> = None;
730
731    for r in runs {
732        let lo = r.byte_start.max(start);
733        let hi = r.byte_end.min(end);
734        if hi <= lo {
735            continue;
736        }
737        let overlap = u64::from(hi - lo);
738        covered += overlap;
739        // `>` keeps the EARLIEST run on a tie between two runs, so the result does not
740        // depend on iteration order beyond the list's own sortedness.
741        if best.is_none_or(|(best_overlap, _)| overlap > best_overlap) {
742            best = Some((overlap, r));
743        }
744    }
745
746    let plain = span - covered;
747    match best {
748        Some((overlap, run)) if overlap >= plain => Some(run.format.clone()),
749        _ => None,
750    }
751}
752
753/// Apply an "insert" shift to a block's image anchors. Anchors at or
754/// past the offset move forward by `inserted_bytes`.
755/// Apply an "insert" mutation to a block's footnote references — the rule
756/// [`shift_images_for_insert`] applies, for the same reason.
757pub fn shift_footnote_refs_for_insert(
758    notes: &mut [FootnoteRefAnchor],
759    byte_offset: u32,
760    inserted_bytes: u32,
761) {
762    if inserted_bytes == 0 {
763        return;
764    }
765    for note in notes.iter_mut() {
766        if note.byte_offset >= byte_offset {
767            note.byte_offset += inserted_bytes;
768        }
769    }
770}
771
772pub fn shift_images_for_insert(images: &mut [ImageAnchor], byte_offset: u32, inserted_bytes: u32) {
773    if inserted_bytes == 0 {
774        return;
775    }
776    for img in images.iter_mut() {
777        if img.byte_offset >= byte_offset {
778            img.byte_offset += inserted_bytes;
779        }
780    }
781}
782
783/// Apply a "delete" mutation to a block's image anchors. Anchors whose
784/// `byte_offset` falls inside `[byte_start..byte_end)` are removed;
785/// anchors at or past `byte_end` shift back by the deleted length.
786/// Returns the number of anchors removed.
787pub fn shift_images_for_delete(
788    images: &mut Vec<ImageAnchor>,
789    byte_start: u32,
790    byte_end: u32,
791) -> usize {
792    if byte_end <= byte_start {
793        return 0;
794    }
795    let before = images.len();
796    images.retain(|i| !(i.byte_offset >= byte_start && i.byte_offset < byte_end));
797    let removed = before - images.len();
798    let delta = (byte_end - byte_start) as i32;
799    shift_images_after(images, byte_end, -delta);
800    removed
801}
802
803/// Translate a logical character offset (counting text characters AND
804/// image positions interleaved by their `byte_offset`) into a UTF-8
805/// byte offset within `plain_text`. Used by writer use cases to map a
806/// document-space char position to the byte position where text edits
807/// should land in `block.plain_text`.
808///
809/// Each image already occupies exactly one character of `plain_text`: the
810/// `U+FFFC` OBJECT REPLACEMENT CHARACTER that `insert_image` mirrors into the
811/// rope, which [`ImageAnchor::byte_offset`] points at. So a logical offset *is*
812/// a character offset and this is a plain char→byte mapping.
813///
814/// `images` is retained in the signature — and deliberately unused — because
815/// getting this wrong is silent and expensive, and callers pass it naturally.
816/// The previous implementation walked the anchor list *in addition to*
817/// `char_indices()`, so every image advanced the logical counter twice. That
818/// dates from the pre-rope model, where an anchor genuinely contributed no
819/// bytes; once the sentinel went into the rope the two representations started
820/// double-counting. The visible effects: selecting across an image returned the
821/// wrong text (an extra sentinel, a missing character), and deleting a range
822/// containing one removed too little.
823pub fn logical_offset_to_byte(plain_text: &str, _images: &[ImageAnchor], char_offset: i64) -> u32 {
824    if char_offset <= 0 {
825        return 0;
826    }
827    plain_text
828        .char_indices()
829        .nth(char_offset as usize)
830        .map(|(b, _)| b as u32)
831        .unwrap_or(plain_text.len() as u32)
832}
833
834/// Split a block's format runs at `byte_offset`. The returned right-hand
835/// vector has its run offsets re-based so they start at byte 0 of the
836/// new (right) block. Straddling runs are split with their `format`
837/// cloned to both halves.
838pub fn split_runs_at(runs: &[FormatRun], byte_offset: u32) -> (Vec<FormatRun>, Vec<FormatRun>) {
839    let mut left = Vec::new();
840    let mut right = Vec::new();
841    for run in runs {
842        if run.byte_end <= byte_offset {
843            left.push(run.clone());
844        } else if run.byte_start >= byte_offset {
845            right.push(FormatRun {
846                byte_start: run.byte_start - byte_offset,
847                byte_end: run.byte_end - byte_offset,
848                format: run.format.clone(),
849            });
850        } else {
851            left.push(FormatRun {
852                byte_start: run.byte_start,
853                byte_end: byte_offset,
854                format: run.format.clone(),
855            });
856            right.push(FormatRun {
857                byte_start: 0,
858                byte_end: run.byte_end - byte_offset,
859                format: run.format.clone(),
860            });
861        }
862    }
863    (left, right)
864}
865
866/// Split block image anchors at `byte_offset`. Anchors at exactly
867/// `byte_offset` go to the right half (rebased to offset 0).
868/// Split footnote references at `byte_offset`, rebasing the right-hand side to
869/// zero — the exact rule [`split_images_at`] applies, because a reference
870/// occupies a block the same way an image does.
871pub fn split_footnote_refs_at(
872    notes: &[FootnoteRefAnchor],
873    byte_offset: u32,
874) -> (Vec<FootnoteRefAnchor>, Vec<FootnoteRefAnchor>) {
875    let mut left = Vec::new();
876    let mut right = Vec::new();
877    for note in notes {
878        if note.byte_offset < byte_offset {
879            left.push(note.clone());
880        } else {
881            let mut new = note.clone();
882            new.byte_offset -= byte_offset;
883            right.push(new);
884        }
885    }
886    (left, right)
887}
888
889pub fn split_images_at(
890    images: &[ImageAnchor],
891    byte_offset: u32,
892) -> (Vec<ImageAnchor>, Vec<ImageAnchor>) {
893    let mut left = Vec::new();
894    let mut right = Vec::new();
895    for img in images {
896        if img.byte_offset < byte_offset {
897            left.push(img.clone());
898        } else {
899            let mut new = img.clone();
900            new.byte_offset -= byte_offset;
901            right.push(new);
902        }
903    }
904    (left, right)
905}
906
907// ─────────────────────────────────────────────────────────────────────
908// View synthesis: build a Vec<InlineSegment> from format_runs + images.
909// ─────────────────────────────────────────────────────────────────────
910
911/// Copy the `fmt_*` fields of an `InlineSegment` into a `CharacterFormat`.
912pub fn character_format_from_segment(seg: &InlineSegment) -> CharacterFormat {
913    CharacterFormat {
914        font_family: seg.fmt_font_family.clone(),
915        font_point_size: seg.fmt_font_point_size,
916        font_weight: seg.fmt_font_weight,
917        font_bold: seg.fmt_font_bold,
918        font_italic: seg.fmt_font_italic,
919        font_underline: seg.fmt_font_underline,
920        font_overline: seg.fmt_font_overline,
921        font_strikeout: seg.fmt_font_strikeout,
922        letter_spacing: seg.fmt_letter_spacing,
923        word_spacing: seg.fmt_word_spacing,
924        anchor_href: seg.fmt_anchor_href.clone(),
925        anchor_names: seg.fmt_anchor_names.clone(),
926        is_anchor: seg.fmt_is_anchor,
927        tooltip: seg.fmt_tooltip.clone(),
928        underline_style: seg.fmt_underline_style.clone(),
929        vertical_alignment: seg.fmt_vertical_alignment.clone(),
930    }
931}
932
933/// Apply a `CharacterFormat` onto an `InlineSegment`'s fmt_* fields.
934pub fn apply_character_format_to_segment(seg: &mut InlineSegment, fmt: &CharacterFormat) {
935    seg.fmt_font_family = fmt.font_family.clone();
936    seg.fmt_font_point_size = fmt.font_point_size;
937    seg.fmt_font_weight = fmt.font_weight;
938    seg.fmt_font_bold = fmt.font_bold;
939    seg.fmt_font_italic = fmt.font_italic;
940    seg.fmt_font_underline = fmt.font_underline;
941    seg.fmt_font_overline = fmt.font_overline;
942    seg.fmt_font_strikeout = fmt.font_strikeout;
943    seg.fmt_letter_spacing = fmt.letter_spacing;
944    seg.fmt_word_spacing = fmt.word_spacing;
945    seg.fmt_anchor_href = fmt.anchor_href.clone();
946    seg.fmt_anchor_names = fmt.anchor_names.clone();
947    seg.fmt_is_anchor = fmt.is_anchor;
948    seg.fmt_tooltip = fmt.tooltip.clone();
949    seg.fmt_underline_style = fmt.underline_style.clone();
950    seg.fmt_vertical_alignment = fmt.vertical_alignment.clone();
951}
952
953/// One ordered piece of a block's inline content: a run of text, or an image.
954///
955/// Produced by [`merge_runs_and_anchors`] and mapped by each caller into its own
956/// output type.
957#[derive(Debug, Clone, PartialEq)]
958pub enum InlinePiece<'a> {
959    /// Byte range `[start, end)` of the block's `plain_text`. `format` is
960    /// `None` for bytes no format run covers.
961    Text {
962        start: u32,
963        end: u32,
964        format: Option<&'a CharacterFormat>,
965    },
966    /// An image anchored at this position. Contributes one logical character
967    /// and zero bytes.
968    Image(&'a ImageAnchor),
969    /// A footnote reference anchored at this position. Contributes one logical
970    /// character and zero bytes, exactly as an image does.
971    FootnoteRef(&'a FootnoteRefAnchor),
972}
973
974/// Interleave a block's format runs and image anchors into one ordered stream.
975///
976/// A block stores three parallel things — the text bytes, a list of formatted
977/// byte ranges, and a list of image anchors keyed by byte offset — and every
978/// reader has to weave them back into document order. That weave is fiddly in
979/// exactly one place: an image anchored *inside* a formatted run has to split
980/// the run, emitting the text before it, then the image, then the rest of the
981/// run with the same format.
982///
983/// This function exists because that weave was previously written out twice, by
984/// hand, in two crates, and the two copies disagreed. The version behind
985/// `inline_segments_view` only checked `img.byte_offset < run.byte_start`, so an
986/// image inside a run was skipped by the run loop entirely and swept up by the
987/// trailing loop — landing **after every run in the block**. Insert a picture
988/// into the middle of a bold sentence and every exporter, and the fragment/copy
989/// path, moved it to the end of the paragraph.
990///
991/// Callers map the returned pieces to their own types; nobody re-derives the
992/// ordering.
993pub fn merge_runs_and_anchors<'a>(
994    plain_text: &str,
995    runs: &'a [FormatRun],
996    anchors: &[BlockAnchor<'a>],
997) -> Vec<InlinePiece<'a>> {
998    let text_len = plain_text.len() as u32;
999    let mut out: Vec<InlinePiece<'a>> = Vec::new();
1000    let mut img_iter = anchors.iter().peekable();
1001    // Highest byte offset already emitted. Never moves backwards.
1002    let mut cursor: u32 = 0;
1003
1004    /// Bytes an anchor occupies in `plain_text`.
1005    ///
1006    /// `insert_image` mirrors a `U+FFFC` OBJECT REPLACEMENT CHARACTER into the
1007    /// rope at the anchor's `byte_offset`, so an image's own character sits in
1008    /// the text and text resuming after the image must step over it. Emitting
1009    /// both the image piece *and* the sentinel yields the image twice — which
1010    /// is what made a selection across an image read back with a doubled
1011    /// sentinel and a missing following character. A footnote reference is
1012    /// mirrored the same way and steps over the same three bytes.
1013    ///
1014    /// Checked rather than assumed: `U+FFFC` also marks table anchors, and not
1015    /// every writer of an `ImageAnchor` mirrors one (the test harness writes
1016    /// anchors directly). An anchor without a sentinel is still handled, it
1017    /// simply consumes no bytes.
1018    fn sentinel_len(plain_text: &str, byte_offset: u32) -> u32 {
1019        let at = byte_offset as usize;
1020        if plain_text.len() >= at + 3 && plain_text.as_bytes()[at..at + 3] == [0xEF, 0xBF, 0xBC] {
1021            3
1022        } else {
1023            0
1024        }
1025    }
1026
1027    let push_text = |out: &mut Vec<InlinePiece<'a>>,
1028                     start: u32,
1029                     end: u32,
1030                     format: Option<&'a CharacterFormat>| {
1031        if start < end {
1032            out.push(InlinePiece::Text { start, end, format });
1033        }
1034    };
1035
1036    /// The piece an anchor contributes, whichever kind it is.
1037    fn piece<'a>(anchor: &BlockAnchor<'a>) -> InlinePiece<'a> {
1038        match *anchor {
1039            BlockAnchor::Image(i) => InlinePiece::Image(i),
1040            BlockAnchor::FootnoteRef(f) => InlinePiece::FootnoteRef(f),
1041        }
1042    }
1043
1044    for run in runs {
1045        // Anchors strictly before this run: unformatted gap text, then the anchor.
1046        while let Some(anchor) = img_iter.peek() {
1047            let at = anchor.byte_offset();
1048            if at >= run.byte_start {
1049                break;
1050            }
1051            push_text(&mut out, cursor, at, None);
1052            out.push(piece(anchor));
1053            cursor = cursor.max(at + sentinel_len(plain_text, at));
1054            img_iter.next();
1055        }
1056
1057        // Unformatted gap between the last emission and the run's start.
1058        push_text(&mut out, cursor, run.byte_start, None);
1059        cursor = cursor.max(run.byte_start);
1060
1061        // Anchors inside the run split it, keeping the run's format on both
1062        // sides. `<=` so an anchor sitting exactly on the run's end boundary is
1063        // consumed here rather than deferred — deferring it is what produced
1064        // the out-of-order emission described above.
1065        while let Some(anchor) = img_iter.peek() {
1066            let at = anchor.byte_offset();
1067            if at > run.byte_end {
1068                break;
1069            }
1070            push_text(&mut out, cursor, at, Some(&run.format));
1071            out.push(piece(anchor));
1072            cursor = cursor.max(at + sentinel_len(plain_text, at));
1073            img_iter.next();
1074        }
1075
1076        push_text(&mut out, cursor, run.byte_end, Some(&run.format));
1077        cursor = cursor.max(run.byte_end);
1078    }
1079
1080    // Anchors past the last run.
1081    for anchor in img_iter {
1082        let at = anchor.byte_offset();
1083        push_text(&mut out, cursor, at, None);
1084        out.push(piece(anchor));
1085        cursor = cursor.max(at + sentinel_len(plain_text, at));
1086    }
1087
1088    push_text(&mut out, cursor, text_len, None);
1089
1090    out
1091}
1092
1093/// Synthesize a `Vec<InlineSegment>` view of a block from its
1094/// `plain_text`, `format_runs`, and `block_images`. Returns segments
1095/// in document order: a Text segment per format run (with a fallback
1096/// default-format segment for any uncovered bytes), and an Image
1097/// segment per anchor at its byte offset.
1098///
1099/// The canonical reader-side accessor for per-segment data — there is
1100/// no persistent inline-element table; this view is computed fresh
1101/// each call.
1102pub fn inline_segments_view(
1103    plain_text: &str,
1104    runs: &[FormatRun],
1105    images: &[ImageAnchor],
1106    footnote_refs: &[FootnoteRefAnchor],
1107) -> Vec<InlineSegment> {
1108    let bytes = plain_text.as_bytes();
1109    let default_format = CharacterFormat::default();
1110
1111    merge_runs_and_anchors(plain_text, runs, &block_anchors(images, footnote_refs))
1112        .into_iter()
1113        .map(|piece| match piece {
1114            InlinePiece::Text { start, end, format } => {
1115                let slice = &bytes[start as usize..end as usize];
1116                let text = std::str::from_utf8(slice)
1117                    .expect("block plain_text must be valid UTF-8")
1118                    .to_string();
1119                let mut seg = InlineSegment {
1120                    content: InlineContent::Text(text),
1121                    ..Default::default()
1122                };
1123                apply_character_format_to_segment(&mut seg, format.unwrap_or(&default_format));
1124                seg
1125            }
1126            InlinePiece::Image(anchor) => {
1127                let mut seg = InlineSegment {
1128                    content: InlineContent::Image {
1129                        name: anchor.name.clone(),
1130                        alt: anchor.alt.clone(),
1131                        width: anchor.width,
1132                        height: anchor.height,
1133                        quality: anchor.quality,
1134                    },
1135                    ..Default::default()
1136                };
1137                apply_character_format_to_segment(&mut seg, &anchor.format);
1138                seg
1139            }
1140            InlinePiece::FootnoteRef(anchor) => {
1141                let mut seg = InlineSegment {
1142                    content: InlineContent::FootnoteRef {
1143                        label: anchor.label.clone(),
1144                    },
1145                    ..Default::default()
1146                };
1147                apply_character_format_to_segment(&mut seg, &anchor.format);
1148                seg
1149            }
1150        })
1151        .collect()
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    fn run(s: u32, e: u32, bold: bool) -> FormatRun {
1159        FormatRun {
1160            byte_start: s,
1161            byte_end: e,
1162            format: CharacterFormat {
1163                font_bold: Some(bold),
1164                ..Default::default()
1165            },
1166        }
1167    }
1168
1169    #[test]
1170    fn empty_runs_are_well_formed() {
1171        debug_assert_well_formed(&[], 0);
1172        debug_assert_well_formed(&[], 100);
1173    }
1174
1175    // ── merge_runs_and_anchors ────────────────────────────────────────────
1176
1177    fn anchor(at: u32, name: &str) -> ImageAnchor {
1178        ImageAnchor {
1179            byte_offset: at,
1180            name: name.into(),
1181            alt: String::new(),
1182            width: 10,
1183            height: 10,
1184            quality: 100,
1185            format: CharacterFormat::default(),
1186        }
1187    }
1188
1189    fn fn_anchor(at: u32, label: &str) -> FootnoteRefAnchor {
1190        FootnoteRefAnchor {
1191            byte_offset: at,
1192            label: label.into(),
1193            format: CharacterFormat::default(),
1194        }
1195    }
1196
1197    /// Render a merge as a compact string so ordering assertions read clearly:
1198    /// `"hello"` for unformatted text, `"*bold*"` for formatted, `[name]` for
1199    /// an image, `^label^` for a footnote reference.
1200    fn shape(text: &str, runs: &[FormatRun], images: &[ImageAnchor]) -> String {
1201        shape_with(text, runs, images, &[])
1202    }
1203
1204    fn shape_with(
1205        text: &str,
1206        runs: &[FormatRun],
1207        images: &[ImageAnchor],
1208        notes: &[FootnoteRefAnchor],
1209    ) -> String {
1210        merge_runs_and_anchors(text, runs, &block_anchors(images, notes))
1211            .into_iter()
1212            .map(|p| match p {
1213                InlinePiece::Text { start, end, format } => {
1214                    let s = &text[start as usize..end as usize];
1215                    if format.is_some() {
1216                        format!("*{s}*")
1217                    } else {
1218                        s.to_string()
1219                    }
1220                }
1221                InlinePiece::Image(a) => format!("[{}]", a.name),
1222                InlinePiece::FootnoteRef(a) => format!("^{}^", a.label),
1223            })
1224            .collect::<Vec<_>>()
1225            .join("|")
1226    }
1227
1228    /// A footnote reference weaves exactly as an image does — the whole reason
1229    /// the two share one function instead of getting a second copy of it.
1230    #[test]
1231    fn a_footnote_reference_inside_a_run_splits_it() {
1232        let text = "abcdef";
1233        let runs = [run(0, 6, true)];
1234        assert_eq!(
1235            shape_with(text, &runs, &[], &[fn_anchor(3, "n1")]),
1236            "*abc*|^n1^|*def*"
1237        );
1238    }
1239
1240    /// Images and references are stored in separate lists but occupy one
1241    /// stream. Walking the two lists in sequence rather than merging them by
1242    /// offset would emit every image before every note regardless of where they
1243    /// actually sit — the ordering bug this weave exists to prevent, in a new
1244    /// disguise.
1245    #[test]
1246    fn images_and_references_interleave_by_position() {
1247        let text = "abcdefgh";
1248        assert_eq!(
1249            shape_with(
1250                text,
1251                &[],
1252                &[anchor(6, "img")],
1253                &[fn_anchor(2, "early"), fn_anchor(7, "late")]
1254            ),
1255            "ab|^early^|cdef|[img]|g|^late^|h"
1256        );
1257    }
1258
1259    /// **The regression.** An image anchored inside a formatted run used to be
1260    /// skipped by the run loop (which only tested `offset < run.byte_start`)
1261    /// and swept up by the trailing loop, landing after every run in the block.
1262    /// Put a picture mid-sentence in bold text and every exporter moved it to
1263    /// the end of the paragraph.
1264    #[test]
1265    fn an_image_inside_a_run_splits_it_instead_of_jumping_to_the_end() {
1266        let text = "abcdef";
1267        let runs = [run(0, 6, true)];
1268        let images = [anchor(3, "img")];
1269        assert_eq!(shape(text, &runs, &images), "*abc*|[img]|*def*");
1270    }
1271
1272    #[test]
1273    fn an_image_on_a_run_start_boundary_stays_in_place() {
1274        let text = "abcdef";
1275        let runs = [run(3, 6, true)];
1276        assert_eq!(shape(text, &runs, &[anchor(3, "i")]), "abc|[i]|*def*");
1277    }
1278
1279    /// The `<=` in the in-run loop: an image exactly on a run's end boundary is
1280    /// consumed with that run rather than deferred to the trailing sweep.
1281    #[test]
1282    fn an_image_on_a_run_end_boundary_stays_in_place() {
1283        let text = "abcdef";
1284        let runs = [run(0, 3, true)];
1285        assert_eq!(shape(text, &runs, &[anchor(3, "i")]), "*abc*|[i]|def");
1286    }
1287
1288    #[test]
1289    fn an_image_between_two_runs_lands_between_them() {
1290        let text = "abcdef";
1291        let runs = [run(0, 3, true), run(3, 6, false)];
1292        let out = shape(text, &runs, &[anchor(3, "i")]);
1293        assert_eq!(out, "*abc*|[i]|*def*");
1294    }
1295
1296    #[test]
1297    fn several_images_inside_one_run_keep_their_order() {
1298        let text = "abcdefgh";
1299        let runs = [run(0, 8, true)];
1300        let images = [anchor(2, "a"), anchor(5, "b")];
1301        assert_eq!(shape(text, &runs, &images), "*ab*|[a]|*cde*|[b]|*fgh*");
1302    }
1303
1304    #[test]
1305    fn two_images_at_the_same_offset_both_survive_in_order() {
1306        let text = "abcd";
1307        let runs = [run(0, 4, true)];
1308        let images = [anchor(2, "a"), anchor(2, "b")];
1309        assert_eq!(shape(text, &runs, &images), "*ab*|[a]|[b]|*cd*");
1310    }
1311
1312    #[test]
1313    fn images_with_no_runs_at_all_are_ordered_with_their_gaps() {
1314        let text = "abcdef";
1315        let images = [anchor(0, "a"), anchor(3, "b"), anchor(6, "c")];
1316        assert_eq!(shape(text, &[], &images), "[a]|abc|[b]|def|[c]");
1317    }
1318
1319    #[test]
1320    fn text_uncovered_by_any_run_stays_unformatted() {
1321        let text = "abcdef";
1322        let runs = [run(2, 4, true)];
1323        assert_eq!(shape(text, &runs, &[]), "ab|*cd*|ef");
1324    }
1325
1326    #[test]
1327    fn a_block_with_neither_runs_nor_images_is_one_plain_piece() {
1328        assert_eq!(shape("abc", &[], &[]), "abc");
1329        assert_eq!(shape("", &[], &[]), "");
1330    }
1331
1332    /// Whatever the arrangement, the merge must reproduce the block's bytes
1333    /// exactly once, in order — never dropping or duplicating a slice. The
1334    /// old implementation could regress its cursor and re-emit text twice.
1335    #[test]
1336    fn every_byte_is_emitted_exactly_once_and_in_order() {
1337        let text = "abcdefghij";
1338        let arrangements: [(&[FormatRun], &[ImageAnchor]); 6] = [
1339            (&[], &[]),
1340            (&[run(0, 10, true)], &[anchor(5, "m")]),
1341            (&[run(2, 5, true), run(5, 8, false)], &[anchor(5, "m")]),
1342            (&[run(2, 5, true)], &[anchor(0, "a"), anchor(10, "z")]),
1343            (&[run(0, 3, true), run(7, 10, true)], &[anchor(3, "m")]),
1344            (
1345                &[run(1, 4, true), run(4, 9, false)],
1346                &[anchor(1, "a"), anchor(4, "b"), anchor(9, "c")],
1347            ),
1348        ];
1349        for (i, (runs, images)) in arrangements.iter().enumerate() {
1350            let pieces = merge_runs_and_anchors(text, runs, &block_anchors(images, &[]));
1351            let mut cursor = 0u32;
1352            let mut rebuilt = String::new();
1353            for piece in &pieces {
1354                if let InlinePiece::Text { start, end, .. } = piece {
1355                    assert_eq!(*start, cursor, "arrangement {i}: gap or overlap");
1356                    assert!(start < end, "arrangement {i}: empty piece emitted");
1357                    rebuilt.push_str(&text[*start as usize..*end as usize]);
1358                    cursor = *end;
1359                }
1360            }
1361            assert_eq!(cursor, text.len() as u32, "arrangement {i}: truncated");
1362            assert_eq!(rebuilt, text, "arrangement {i}");
1363            let img_count = pieces
1364                .iter()
1365                .filter(|p| matches!(p, InlinePiece::Image(_)))
1366                .count();
1367            assert_eq!(img_count, images.len(), "arrangement {i}: lost an image");
1368        }
1369    }
1370
1371    /// `inline_segments_view` is built on the merge, so it inherits the fix.
1372    #[test]
1373    fn inline_segments_view_places_a_mid_run_image_correctly() {
1374        let segs = inline_segments_view("abcdef", &[run(0, 6, true)], &[anchor(3, "img")], &[]);
1375        assert_eq!(segs.len(), 3);
1376        assert!(matches!(&segs[0].content, InlineContent::Text(t) if t == "abc"));
1377        assert!(matches!(&segs[1].content, InlineContent::Image { name, .. } if name == "img"));
1378        assert!(matches!(&segs[2].content, InlineContent::Text(t) if t == "def"));
1379        // The split keeps the run's formatting on both sides of the image.
1380        assert_eq!(segs[0].fmt_font_bold, Some(true));
1381        assert_eq!(segs[2].fmt_font_bold, Some(true));
1382    }
1383
1384    #[test]
1385    fn inline_segments_view_carries_alt_text_through() {
1386        let mut a = anchor(1, "img");
1387        a.alt = "a black cat".into();
1388        let segs = inline_segments_view("ab", &[], &[a], &[]);
1389        let alt = segs.iter().find_map(|s| match &s.content {
1390            InlineContent::Image { alt, .. } => Some(alt.clone()),
1391            _ => None,
1392        });
1393        assert_eq!(alt.as_deref(), Some("a black cat"));
1394    }
1395
1396    #[test]
1397    fn coalesce_merges_adjacent_equal_runs() {
1398        let mut rs = vec![run(0, 5, true), run(5, 10, true), run(10, 15, false)];
1399        coalesce_in_place(&mut rs);
1400        assert_eq!(rs.len(), 2);
1401        assert_eq!(rs[0].byte_end, 10);
1402    }
1403
1404    #[test]
1405    fn coalesce_leaves_disjoint_runs_alone() {
1406        let mut rs = vec![run(0, 5, true), run(7, 10, true)];
1407        coalesce_in_place(&mut rs);
1408        assert_eq!(rs.len(), 2);
1409    }
1410
1411    #[test]
1412    fn splice_range_clips_straddling_runs() {
1413        let mut rs = vec![run(0, 20, true)];
1414        splice_range(&mut rs, 5..15, vec![run(5, 15, false)]);
1415        assert_eq!(rs.len(), 3);
1416        assert_eq!(rs[0].byte_end, 5);
1417        assert_eq!(rs[1].format.font_bold, Some(false));
1418        assert_eq!(rs[2].byte_start, 15);
1419    }
1420
1421    #[test]
1422    fn splice_range_empty_replacement_removes_inner_runs() {
1423        let mut rs = vec![run(0, 5, true), run(5, 10, false), run(10, 15, true)];
1424        splice_range(&mut rs, 5..10, vec![]);
1425        // 0..5 bold, then 10..15 bold — after coalesce these are NOT adjacent
1426        // (there's a gap from 5..10 in the run table, meaning "no format").
1427        assert_eq!(rs.len(), 2);
1428        assert_eq!(rs[0].byte_end, 5);
1429        assert_eq!(rs[1].byte_start, 10);
1430    }
1431
1432    #[test]
1433    fn shift_after_moves_downstream() {
1434        let mut rs = vec![run(0, 5, true), run(10, 15, false)];
1435        shift_after(&mut rs, 5, 3);
1436        assert_eq!(rs[0].byte_start, 0); // unchanged
1437        assert_eq!(rs[1].byte_start, 13);
1438        assert_eq!(rs[1].byte_end, 18);
1439    }
1440}
1441
1442/// The adversarial corpus for [`shift_runs_for_replace`].
1443///
1444/// Replace is the one edit that rewrites a writer's prose *in bulk*, so its formatting
1445/// behaviour has to be pinned rather than inherited by accident. Before this module
1446/// there were 11 replace tests in the repo and **not one** of them mentioned
1447/// `FormatRun`: the behaviour was emergent, undecided, and untested.
1448#[cfg(test)]
1449mod replace_policy_tests {
1450    use super::*;
1451
1452    fn fmt(tag: &str) -> CharacterFormat {
1453        CharacterFormat {
1454            font_bold: Some(tag == "B"),
1455            font_italic: Some(tag == "I"),
1456            ..Default::default()
1457        }
1458    }
1459    fn r(start: u32, end: u32, tag: &str) -> FormatRun {
1460        FormatRun {
1461            byte_start: start,
1462            byte_end: end,
1463            format: fmt(tag),
1464        }
1465    }
1466    /// Compact "0..5=B 8..12=I" rendering, so a failure shows the whole run list.
1467    fn show(runs: &[FormatRun]) -> String {
1468        if runs.is_empty() {
1469            return "[]".to_string();
1470        }
1471        runs.iter()
1472            .map(|x| {
1473                let tag = if x.format.font_bold == Some(true) {
1474                    "B"
1475                } else if x.format.font_italic == Some(true) {
1476                    "I"
1477                } else {
1478                    "p"
1479                };
1480                format!("{}..{}={tag}", x.byte_start, x.byte_end)
1481            })
1482            .collect::<Vec<_>>()
1483            .join(" ")
1484    }
1485    fn replace(
1486        runs: &[FormatRun],
1487        start: u32,
1488        end: u32,
1489        n: u32,
1490        policy: ReplaceFormatPolicy,
1491    ) -> Vec<FormatRun> {
1492        let mut runs = runs.to_vec();
1493        shift_runs_for_replace(&mut runs, start, end, n, policy).expect("valid replace");
1494        runs
1495    }
1496
1497    /// **The spec-conformance test.** `InheritPreceding` must be byte-for-byte what
1498    /// today's `shift_runs_for_delete` + `shift_runs_for_insert` produces — anything
1499    /// else silently rewrites the formatting of every replace that ever shipped.
1500    ///
1501    /// Differential, not hand-transcribed: it runs the historical primitives directly
1502    /// and compares, so it stays honest even if their behaviour is ever changed.
1503    #[test]
1504    fn inherit_preceding_matches_the_historical_delete_then_insert() {
1505        let corpus: Vec<(&str, Vec<FormatRun>, u32, u32, u32)> = vec![
1506            ("run ends exactly at start", vec![r(0, 5, "B")], 5, 10, 3),
1507            ("run begins exactly at start", vec![r(5, 8, "B")], 5, 10, 3),
1508            (
1509                "run begins at start, outlives end",
1510                vec![r(5, 20, "B")],
1511                5,
1512                10,
1513                3,
1514            ),
1515            (
1516                "run straddles the whole range",
1517                vec![r(0, 20, "B")],
1518                5,
1519                10,
1520                3,
1521            ),
1522            ("no run touches the start", vec![r(12, 20, "B")], 5, 10, 3),
1523            ("bold tail inside the range", vec![r(9, 13, "B")], 5, 13, 4),
1524            ("pure delete", vec![r(0, 20, "B")], 5, 10, 0),
1525            ("pure insert", vec![r(0, 20, "B")], 5, 5, 3),
1526            (
1527                "same format either side coalesces",
1528                vec![r(0, 5, "B"), r(10, 15, "B")],
1529                5,
1530                10,
1531                3,
1532            ),
1533            (
1534                "different formats either side",
1535                vec![r(0, 5, "B"), r(10, 15, "I")],
1536                5,
1537                10,
1538                3,
1539            ),
1540            ("empty run list", vec![], 5, 10, 3),
1541            ("the only run is consumed", vec![r(5, 10, "B")], 5, 10, 3),
1542            (
1543                "replacement longer than the range",
1544                vec![r(0, 5, "B")],
1545                5,
1546                10,
1547                20,
1548            ),
1549            (
1550                "three runs straddled",
1551                vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")],
1552                2,
1553                7,
1554                4,
1555            ),
1556            (
1557                "gap between two same-format runs is deleted",
1558                vec![r(0, 5, "B"), r(8, 13, "B")],
1559                5,
1560                8,
1561                0,
1562            ),
1563        ];
1564
1565        for (name, runs, start, end, n) in corpus {
1566            // The historical composition, run for real.
1567            let mut expected = runs.clone();
1568            shift_runs_for_delete(&mut expected, start, end);
1569            shift_runs_for_insert(&mut expected, start, n);
1570
1571            let got = replace(&runs, start, end, n, ReplaceFormatPolicy::InheritPreceding);
1572
1573            assert_eq!(
1574                show(&got),
1575                show(&expected),
1576                "InheritPreceding diverged from delete+insert for {name:?} \
1577                 (replace {start}..{end}, n={n})\n  before:   {}\n  historical: {}\n  got:        {}",
1578                show(&runs),
1579                show(&expected),
1580                show(&got),
1581            );
1582        }
1583    }
1584
1585    /// The motivating data loss: renaming a character whose name reads `Auré**lien**`.
1586    /// The default drops the bold — that is what shipped, and it is now visible and
1587    /// chosen rather than emergent. Every other policy is a way to not lose it.
1588    #[test]
1589    fn the_four_policies_diverge_on_a_partly_bold_name() {
1590        // "Auré" plain (bytes 0..5, é is two bytes), "lien" bold (5..9).
1591        let runs = vec![r(5, 9, "B")];
1592        let (start, end, n) = (0, 9, 9); // rename the whole name, same length
1593
1594        use ReplaceFormatPolicy::*;
1595        assert_eq!(
1596            show(&replace(&runs, start, end, n, InheritPreceding)),
1597            "[]",
1598            "the historical default destroys the bold — pinned, not endorsed"
1599        );
1600        assert_eq!(
1601            show(&replace(&runs, start, end, n, PreserveNothing)),
1602            "[]",
1603            "explicitly unformatted"
1604        );
1605        assert_eq!(
1606            show(&replace(&runs, start, end, n, PreserveIfFullyCovered)),
1607            "[]",
1608            "no SINGLE run covers 0..9 — it must fall back to inheritance, not guess"
1609        );
1610        // Bold covers 4 of the 9 bytes, plain covers 5 → plain dominates.
1611        assert_eq!(
1612            show(&replace(&runs, start, end, n, KeepDominantRun)),
1613            "[]",
1614            "plain covers more of the name than the bold does"
1615        );
1616
1617        // …but when the bold covers MOST of the name, KeepDominantRun keeps it.
1618        let mostly_bold = vec![r(1, 9, "B")];
1619        assert_eq!(
1620            show(&replace(&mostly_bold, 0, 9, 9, KeepDominantRun)),
1621            "0..9=B",
1622            "bold covers 8 of 9 bytes — the rename must keep it"
1623        );
1624    }
1625
1626    /// "Fully covered" means ONE run covers the range — not "the runs jointly span it".
1627    /// A gapless Italic+Bold union spanning the range exactly must NOT be treated as
1628    /// covered, or the replacement silently inherits whichever run was looked at first.
1629    #[test]
1630    fn fully_covered_means_a_single_run_not_a_gapless_union() {
1631        let two = vec![r(0, 3, "I"), r(3, 10, "B")];
1632        assert_eq!(
1633            show(&replace(
1634                &two,
1635                0,
1636                10,
1637                4,
1638                ReplaceFormatPolicy::PreserveIfFullyCovered
1639            )),
1640            "[]",
1641            "two different-format runs jointly spanning the range are not 'covered'; \
1642             with no run preceding the start, the fallback is unformatted"
1643        );
1644
1645        // One run that really does cover it, and begins exactly at the start — the case
1646        // InheritPreceding cannot see (a run at `start` never inherits).
1647        let one = vec![r(5, 20, "B")];
1648        assert_eq!(
1649            show(&replace(
1650                &one,
1651                5,
1652                10,
1653                3,
1654                ReplaceFormatPolicy::PreserveIfFullyCovered
1655            )),
1656            "5..18=B",
1657            "a single covering run keeps its format across the rename"
1658        );
1659        assert_eq!(
1660            show(&replace(
1661                &one,
1662                5,
1663                10,
1664                3,
1665                ReplaceFormatPolicy::InheritPreceding
1666            )),
1667            "8..18=B",
1668            "…which the default would have lost: the replacement lands unformatted"
1669        );
1670    }
1671
1672    /// A run that merely *touches* the range must not leak its format to the whole
1673    /// replacement.
1674    #[test]
1675    fn a_partially_overlapping_run_does_not_count_as_covering() {
1676        let runs = vec![r(0, 8, "B")]; // covers only 5..8 of the range 5..12
1677        assert_eq!(
1678            show(&replace(
1679                &runs,
1680                5,
1681                12,
1682                4,
1683                ReplaceFormatPolicy::PreserveIfFullyCovered
1684            )),
1685            "0..9=B",
1686            "not covered → falls back to inheritance, which extends the preceding bold; \
1687             it must NOT format the whole replacement as though bold had covered it"
1688        );
1689    }
1690
1691    /// Ties between two runs resolve to the EARLIEST, deterministically.
1692    ///
1693    /// `Iterator::max_by_key` returns the LAST maximum, so the obvious one-liner would
1694    /// have silently picked the other run here.
1695    #[test]
1696    fn a_dominance_tie_between_two_runs_goes_to_the_earlier() {
1697        let runs = vec![r(0, 3, "B"), r(3, 6, "I")]; // 3 bytes each
1698        assert_eq!(
1699            show(&replace(
1700                &runs,
1701                0,
1702                6,
1703                4,
1704                ReplaceFormatPolicy::KeepDominantRun
1705            )),
1706            "0..4=B",
1707            "a true tie must resolve to the earlier run, not to whichever the iterator \
1708             happened to visit last"
1709        );
1710    }
1711
1712    /// A tie between a run and the unformatted gap goes to the run: losing formatting is
1713    /// the destructive outcome, so it needs a strict majority of *plain* to win.
1714    #[test]
1715    fn a_dominance_tie_against_plain_text_keeps_the_formatting() {
1716        let runs = vec![r(4, 8, "B")]; // 4 bold bytes, 4 plain bytes in 0..8
1717        assert_eq!(
1718            show(&replace(
1719                &runs,
1720                0,
1721                8,
1722                5,
1723                ReplaceFormatPolicy::KeepDominantRun
1724            )),
1725            "0..5=B",
1726            "an even split must keep the formatting rather than silently drop it"
1727        );
1728    }
1729
1730    /// An empty range replaces nothing, so it is an insertion — and every policy that
1731    /// reasons about "what was covered" must defer to the insert convention instead of
1732    /// stripping the format the typed text would have inherited.
1733    #[test]
1734    fn an_empty_range_is_an_insert_and_no_coverage_policy_overrides_it() {
1735        let runs = vec![r(0, 5, "B"), r(5, 10, "I")];
1736        use ReplaceFormatPolicy::*;
1737        for policy in [InheritPreceding, PreserveIfFullyCovered, KeepDominantRun] {
1738            assert_eq!(
1739                show(&replace(&runs, 5, 5, 2, policy)),
1740                "0..7=B 7..12=I",
1741                "{policy:?}: typing at a boundary must inherit the run to the LEFT (Qt \
1742                 convention) — an empty range destroyed no formatting, so there is \
1743                 nothing for a coverage policy to override"
1744            );
1745        }
1746        // The one policy that is an explicit request, not an inference, still applies.
1747        assert_eq!(
1748            show(&replace(&runs, 5, 5, 2, PreserveNothing)),
1749            "0..5=B 7..12=I",
1750            "PreserveNothing asks for unformatted text, and means it even on an insert"
1751        );
1752    }
1753
1754    /// Nothing to do must mean nothing done — no fabricated runs, no lost ones.
1755    #[test]
1756    fn a_zero_width_zero_length_replace_is_the_identity() {
1757        let runs = vec![r(0, 5, "B"), r(7, 12, "I")];
1758        for policy in [
1759            ReplaceFormatPolicy::InheritPreceding,
1760            ReplaceFormatPolicy::PreserveIfFullyCovered,
1761            ReplaceFormatPolicy::KeepDominantRun,
1762            ReplaceFormatPolicy::PreserveNothing,
1763        ] {
1764            assert_eq!(
1765                show(&replace(&runs, 6, 6, 0, policy)),
1766                "0..5=B 7..12=I",
1767                "{policy:?} changed a no-op edit"
1768            );
1769        }
1770    }
1771
1772    /// `PreserveNothing` must leave the region genuinely *unformatted* — not covered by
1773    /// a fabricated run carrying `CharacterFormat::default()`, which is a different
1774    /// thing and would defeat coalescing forever after.
1775    #[test]
1776    fn preserve_nothing_fabricates_no_default_run() {
1777        let runs = vec![r(0, 5, "B")];
1778        let got = replace(&runs, 7, 9, 2, ReplaceFormatPolicy::PreserveNothing);
1779        assert_eq!(show(&got), "0..5=B", "no run may be invented for the gap");
1780        assert!(
1781            got.iter().all(|x| x.byte_start < 7 || x.byte_end > 9),
1782            "the replaced span must carry no run at all"
1783        );
1784    }
1785
1786    /// Offsets are BYTES, not chars. A 4-byte emoji replaced by 2 ASCII bytes must
1787    /// shift downstream runs by -2, not by -1 (chars) or 0.
1788    #[test]
1789    fn offsets_are_bytes_not_characters() {
1790        // "🎉" (4 bytes, bold) + " " + "abcd" (italic).
1791        let runs = vec![r(0, 4, "B"), r(5, 9, "I")];
1792        let got = replace(&runs, 0, 4, 2, ReplaceFormatPolicy::KeepDominantRun);
1793        assert_eq!(
1794            show(&got),
1795            "0..2=B 3..7=I",
1796            "the trailing italic must shift back by the BYTE delta (4 -> 2 = -2)"
1797        );
1798    }
1799
1800    /// Every policy must leave the run list well-formed — sorted, non-overlapping,
1801    /// coalesced, inside the block. This is the invariant a release build no longer
1802    /// merely asserts.
1803    #[test]
1804    fn every_policy_leaves_the_runs_well_formed() {
1805        let setups: Vec<(Vec<FormatRun>, u32, u32, u32, usize)> = vec![
1806            (vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")], 2, 7, 4, 8),
1807            (vec![r(0, 5, "B"), r(8, 13, "B")], 5, 8, 0, 10),
1808            (vec![r(0, 5, "B"), r(7, 12, "B")], 8, 10, 2, 12),
1809            (vec![r(3, 7, "B")], 3, 7, 0, 6),
1810            (vec![], 2, 6, 3, 9),
1811        ];
1812        for (runs, start, end, n, text_len) in setups {
1813            for policy in [
1814                ReplaceFormatPolicy::InheritPreceding,
1815                ReplaceFormatPolicy::PreserveIfFullyCovered,
1816                ReplaceFormatPolicy::KeepDominantRun,
1817                ReplaceFormatPolicy::PreserveNothing,
1818            ] {
1819                let got = replace(&runs, start, end, n, policy);
1820                check_well_formed(&got, text_len).unwrap_or_else(|e| {
1821                    panic!(
1822                        "{policy:?} produced malformed runs from {} (replace {start}..{end}, \
1823                         n={n}): {} — {e}",
1824                        show(&runs),
1825                        show(&got)
1826                    )
1827                });
1828            }
1829        }
1830    }
1831
1832    /// Two same-format runs separated by an untouched gap must stay separate — coalescing
1833    /// is for *adjacent* runs, and over-eager merging would swallow the plain text between
1834    /// them.
1835    #[test]
1836    fn an_untouched_gap_between_equal_runs_survives() {
1837        let runs = vec![r(0, 5, "B"), r(7, 12, "B")];
1838        assert_eq!(
1839            show(&replace(
1840                &runs,
1841                8,
1842                10,
1843                2,
1844                ReplaceFormatPolicy::InheritPreceding
1845            )),
1846            "0..5=B 7..12=B",
1847            "the plain gap at 5..7 must not be swallowed"
1848        );
1849    }
1850
1851    /// An inverted range is refused, not asserted away.
1852    #[test]
1853    fn an_inverted_range_is_an_error_not_a_panic() {
1854        let mut runs = vec![r(0, 5, "B")];
1855        let err =
1856            shift_runs_for_replace(&mut runs, 10, 5, 3, ReplaceFormatPolicy::InheritPreceding)
1857                .expect_err("an inverted range must be rejected");
1858        assert!(matches!(
1859            err,
1860            FormatRunError::ReversedRange { start: 10, end: 5 }
1861        ));
1862        assert_eq!(show(&runs), "0..5=B", "a refused edit must change nothing");
1863    }
1864}
1865
1866/// The runtime guards that replaced the release-mode-invisible `debug_assert!`s.
1867#[cfg(test)]
1868mod invariant_check_tests {
1869    use super::*;
1870
1871    fn run(start: u32, end: u32, bold: bool) -> FormatRun {
1872        FormatRun {
1873            byte_start: start,
1874            byte_end: end,
1875            format: CharacterFormat {
1876                font_bold: Some(bold),
1877                ..Default::default()
1878            },
1879        }
1880    }
1881
1882    /// The whole point: in a **release** build `debug_assert!` is gone, so a violating
1883    /// splice used to sail straight through and build a malformed run list. Now it is
1884    /// reported, and the caller's runs are left exactly as they were.
1885    #[test]
1886    fn a_replacement_outside_the_range_is_rejected_without_mutating() {
1887        let mut runs = vec![run(0, 20, true)];
1888        let before = runs.clone();
1889
1890        let err = try_splice_range(&mut runs, 5..10, vec![run(5, 15, false)])
1891            .expect_err("a replacement run reaching past range.end must be rejected");
1892
1893        assert!(matches!(
1894            err,
1895            FormatRunError::ReplacementOutsideRange {
1896                run_end: 15,
1897                range_end: 10,
1898                ..
1899            }
1900        ));
1901        assert_eq!(
1902            runs, before,
1903            "a rejected splice must not half-apply — validation happens before mutation"
1904        );
1905    }
1906
1907    #[test]
1908    // The inverted range is the whole point of the test — it is what a caller must not
1909    // be able to sneak past a release build.
1910    #[allow(clippy::reversed_empty_ranges)]
1911    fn a_reversed_range_is_rejected() {
1912        let mut runs = vec![run(0, 20, true)];
1913        assert!(matches!(
1914            try_splice_range(&mut runs, 10..5, vec![]),
1915            Err(FormatRunError::ReversedRange { start: 10, end: 5 })
1916        ));
1917    }
1918
1919    #[test]
1920    fn a_legal_splice_still_works_through_the_checked_path() {
1921        let mut runs = vec![run(0, 20, true)];
1922        try_splice_range(&mut runs, 5..15, vec![run(5, 15, false)]).expect("legal");
1923        assert_eq!(runs.len(), 3);
1924        assert_eq!(runs[1].format.font_bold, Some(false));
1925    }
1926
1927    #[test]
1928    fn check_well_formed_catches_what_debug_assert_used_to() {
1929        assert!(check_well_formed(&[], 0).is_ok());
1930        assert!(check_well_formed(&[run(0, 5, true)], 5).is_ok());
1931
1932        assert!(matches!(
1933            check_well_formed(&[run(5, 5, true)], 10),
1934            Err(FormatRunError::EmptyRun { .. })
1935        ));
1936        assert!(matches!(
1937            check_well_formed(&[run(0, 8, true), run(5, 10, false)], 10),
1938            Err(FormatRunError::RunsOverlap { .. })
1939        ));
1940        assert!(matches!(
1941            check_well_formed(&[run(0, 5, true), run(5, 10, true)], 10),
1942            Err(FormatRunError::RunsNotCoalesced { .. })
1943        ));
1944        assert!(matches!(
1945            check_well_formed(&[run(0, 20, true)], 10),
1946            Err(FormatRunError::RunPastEndOfBlock { text_len: 10, .. })
1947        ));
1948    }
1949}