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/// [`InlinePiece`], but positioned in the document's own **addressable character space**
1094/// rather than as byte offsets local to the block's `plain_text`.
1095///
1096/// `InlinePiece::Text { start, end, .. }` is a byte range good for slicing `plain_text` and
1097/// nothing else: it carries no notion of where in the *document* that range sits. Every
1098/// offset the rest of this crate deals out — `TextDocument::to_addressable_text()`, `find_all`
1099/// match positions, a block's own `document_position` — lives in one **character** space, and
1100/// a caller that reconstructs a document position by summing `InlinePiece` text lengths drifts
1101/// the moment a block holds a multi-byte character before the point in question, or an inline
1102/// image/footnote reference at all — their `U+FFFC` sentinel is three bytes but one char (see
1103/// `sentinel_len` on [`merge_runs_and_anchors`]). That is the same "offset from one space,
1104/// string from another" bug class the public API's `TextDocument::to_addressable_text()`
1105/// exists to close for whole-document offsets (see its doc comment), one level down, inside
1106/// a single block.
1107///
1108/// Built by [`addressable_inline_pieces`] (pure — block-local character space, `base_char_offset`
1109/// left at `0`) or [`crate::format_runs_query::addressable_inline_pieces_for_block`]
1110/// (store-aware — adds the block's own `document_position` so `start`/`end` land in the
1111/// *document's* space). Every downstream consumer that needs a piece boundary in that space —
1112/// a comment's stored quote, a DOCX/ODT comment marker split at an arbitrary character — reads
1113/// `start`/`end` straight off this type instead of re-deriving them by hand.
1114#[derive(Debug, Clone, PartialEq)]
1115pub struct AddressableInlinePiece {
1116 /// `[start, end)` in the addressable character space. `end - start` is always `1` for
1117 /// [`InlineContent::Image`] and [`InlineContent::FootnoteRef`] — the sentinel counts as
1118 /// exactly one character, matching how the document itself counts it — and equals the
1119 /// piece's own text's `chars().count()` for [`InlineContent::Text`].
1120 pub start: u32,
1121 pub end: u32,
1122 pub content: InlineContent,
1123 pub format: CharacterFormat,
1124}
1125
1126/// Build a block's [`AddressableInlinePiece`]s from its `plain_text`, format runs, and
1127/// anchors, offset by `base_char_offset` — the character position this block's own first
1128/// piece should start at.
1129///
1130/// Layered on [`merge_runs_and_anchors`] rather than duplicating its weave: the only thing
1131/// added here is the byte→char conversion (`plain_text[start..end].chars().count()` is the
1132/// only correct way to size a UTF-8 slice in the character space every other offset in this
1133/// crate uses — NOT `end - start`, which is a byte count) and a running `base_char_offset`
1134/// accumulator. Pass `0` to get offsets relative to this block alone, which is what this
1135/// module's own tests do; [`crate::format_runs_query::addressable_inline_pieces_for_block`]
1136/// passes the block's own `document_position` instead, to land in document-wide space.
1137pub fn addressable_inline_pieces(
1138 plain_text: &str,
1139 runs: &[FormatRun],
1140 anchors: &[BlockAnchor<'_>],
1141 base_char_offset: u32,
1142) -> Vec<AddressableInlinePiece> {
1143 let pieces = merge_runs_and_anchors(plain_text, runs, anchors);
1144 let mut out = Vec::with_capacity(pieces.len());
1145 let mut cursor = base_char_offset;
1146 for piece in pieces {
1147 match piece {
1148 InlinePiece::Text { start, end, format } => {
1149 let text = &plain_text[start as usize..end as usize];
1150 let len = text.chars().count() as u32;
1151 out.push(AddressableInlinePiece {
1152 start: cursor,
1153 end: cursor + len,
1154 content: InlineContent::Text(text.to_string()),
1155 format: format.cloned().unwrap_or_default(),
1156 });
1157 cursor += len;
1158 }
1159 InlinePiece::Image(anchor) => {
1160 out.push(AddressableInlinePiece {
1161 start: cursor,
1162 end: cursor + 1,
1163 content: InlineContent::Image {
1164 name: anchor.name.clone(),
1165 alt: anchor.alt.clone(),
1166 width: anchor.width,
1167 height: anchor.height,
1168 quality: anchor.quality,
1169 },
1170 format: anchor.format.clone(),
1171 });
1172 cursor += 1;
1173 }
1174 InlinePiece::FootnoteRef(anchor) => {
1175 out.push(AddressableInlinePiece {
1176 start: cursor,
1177 end: cursor + 1,
1178 content: InlineContent::FootnoteRef {
1179 label: anchor.label.clone(),
1180 },
1181 format: anchor.format.clone(),
1182 });
1183 cursor += 1;
1184 }
1185 }
1186 }
1187 out
1188}
1189
1190/// Synthesize a `Vec<InlineSegment>` view of a block from its
1191/// `plain_text`, `format_runs`, and `block_images`. Returns segments
1192/// in document order: a Text segment per format run (with a fallback
1193/// default-format segment for any uncovered bytes), and an Image
1194/// segment per anchor at its byte offset.
1195///
1196/// The canonical reader-side accessor for per-segment data — there is
1197/// no persistent inline-element table; this view is computed fresh
1198/// each call.
1199pub fn inline_segments_view(
1200 plain_text: &str,
1201 runs: &[FormatRun],
1202 images: &[ImageAnchor],
1203 footnote_refs: &[FootnoteRefAnchor],
1204) -> Vec<InlineSegment> {
1205 let bytes = plain_text.as_bytes();
1206 let default_format = CharacterFormat::default();
1207
1208 merge_runs_and_anchors(plain_text, runs, &block_anchors(images, footnote_refs))
1209 .into_iter()
1210 .map(|piece| match piece {
1211 InlinePiece::Text { start, end, format } => {
1212 let slice = &bytes[start as usize..end as usize];
1213 let text = std::str::from_utf8(slice)
1214 .expect("block plain_text must be valid UTF-8")
1215 .to_string();
1216 let mut seg = InlineSegment {
1217 content: InlineContent::Text(text),
1218 ..Default::default()
1219 };
1220 apply_character_format_to_segment(&mut seg, format.unwrap_or(&default_format));
1221 seg
1222 }
1223 InlinePiece::Image(anchor) => {
1224 let mut seg = InlineSegment {
1225 content: InlineContent::Image {
1226 name: anchor.name.clone(),
1227 alt: anchor.alt.clone(),
1228 width: anchor.width,
1229 height: anchor.height,
1230 quality: anchor.quality,
1231 },
1232 ..Default::default()
1233 };
1234 apply_character_format_to_segment(&mut seg, &anchor.format);
1235 seg
1236 }
1237 InlinePiece::FootnoteRef(anchor) => {
1238 let mut seg = InlineSegment {
1239 content: InlineContent::FootnoteRef {
1240 label: anchor.label.clone(),
1241 },
1242 ..Default::default()
1243 };
1244 apply_character_format_to_segment(&mut seg, &anchor.format);
1245 seg
1246 }
1247 })
1248 .collect()
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254
1255 fn run(s: u32, e: u32, bold: bool) -> FormatRun {
1256 FormatRun {
1257 byte_start: s,
1258 byte_end: e,
1259 format: CharacterFormat {
1260 font_bold: Some(bold),
1261 ..Default::default()
1262 },
1263 }
1264 }
1265
1266 #[test]
1267 fn empty_runs_are_well_formed() {
1268 debug_assert_well_formed(&[], 0);
1269 debug_assert_well_formed(&[], 100);
1270 }
1271
1272 // ── merge_runs_and_anchors ────────────────────────────────────────────
1273
1274 fn anchor(at: u32, name: &str) -> ImageAnchor {
1275 ImageAnchor {
1276 byte_offset: at,
1277 name: name.into(),
1278 alt: String::new(),
1279 width: 10,
1280 height: 10,
1281 quality: 100,
1282 format: CharacterFormat::default(),
1283 }
1284 }
1285
1286 fn fn_anchor(at: u32, label: &str) -> FootnoteRefAnchor {
1287 FootnoteRefAnchor {
1288 byte_offset: at,
1289 label: label.into(),
1290 format: CharacterFormat::default(),
1291 }
1292 }
1293
1294 /// Render a merge as a compact string so ordering assertions read clearly:
1295 /// `"hello"` for unformatted text, `"*bold*"` for formatted, `[name]` for
1296 /// an image, `^label^` for a footnote reference.
1297 fn shape(text: &str, runs: &[FormatRun], images: &[ImageAnchor]) -> String {
1298 shape_with(text, runs, images, &[])
1299 }
1300
1301 fn shape_with(
1302 text: &str,
1303 runs: &[FormatRun],
1304 images: &[ImageAnchor],
1305 notes: &[FootnoteRefAnchor],
1306 ) -> String {
1307 merge_runs_and_anchors(text, runs, &block_anchors(images, notes))
1308 .into_iter()
1309 .map(|p| match p {
1310 InlinePiece::Text { start, end, format } => {
1311 let s = &text[start as usize..end as usize];
1312 if format.is_some() {
1313 format!("*{s}*")
1314 } else {
1315 s.to_string()
1316 }
1317 }
1318 InlinePiece::Image(a) => format!("[{}]", a.name),
1319 InlinePiece::FootnoteRef(a) => format!("^{}^", a.label),
1320 })
1321 .collect::<Vec<_>>()
1322 .join("|")
1323 }
1324
1325 /// A footnote reference weaves exactly as an image does — the whole reason
1326 /// the two share one function instead of getting a second copy of it.
1327 #[test]
1328 fn a_footnote_reference_inside_a_run_splits_it() {
1329 let text = "abcdef";
1330 let runs = [run(0, 6, true)];
1331 assert_eq!(
1332 shape_with(text, &runs, &[], &[fn_anchor(3, "n1")]),
1333 "*abc*|^n1^|*def*"
1334 );
1335 }
1336
1337 /// Images and references are stored in separate lists but occupy one
1338 /// stream. Walking the two lists in sequence rather than merging them by
1339 /// offset would emit every image before every note regardless of where they
1340 /// actually sit — the ordering bug this weave exists to prevent, in a new
1341 /// disguise.
1342 #[test]
1343 fn images_and_references_interleave_by_position() {
1344 let text = "abcdefgh";
1345 assert_eq!(
1346 shape_with(
1347 text,
1348 &[],
1349 &[anchor(6, "img")],
1350 &[fn_anchor(2, "early"), fn_anchor(7, "late")]
1351 ),
1352 "ab|^early^|cdef|[img]|g|^late^|h"
1353 );
1354 }
1355
1356 /// **The regression.** An image anchored inside a formatted run used to be
1357 /// skipped by the run loop (which only tested `offset < run.byte_start`)
1358 /// and swept up by the trailing loop, landing after every run in the block.
1359 /// Put a picture mid-sentence in bold text and every exporter moved it to
1360 /// the end of the paragraph.
1361 #[test]
1362 fn an_image_inside_a_run_splits_it_instead_of_jumping_to_the_end() {
1363 let text = "abcdef";
1364 let runs = [run(0, 6, true)];
1365 let images = [anchor(3, "img")];
1366 assert_eq!(shape(text, &runs, &images), "*abc*|[img]|*def*");
1367 }
1368
1369 #[test]
1370 fn an_image_on_a_run_start_boundary_stays_in_place() {
1371 let text = "abcdef";
1372 let runs = [run(3, 6, true)];
1373 assert_eq!(shape(text, &runs, &[anchor(3, "i")]), "abc|[i]|*def*");
1374 }
1375
1376 /// The `<=` in the in-run loop: an image exactly on a run's end boundary is
1377 /// consumed with that run rather than deferred to the trailing sweep.
1378 #[test]
1379 fn an_image_on_a_run_end_boundary_stays_in_place() {
1380 let text = "abcdef";
1381 let runs = [run(0, 3, true)];
1382 assert_eq!(shape(text, &runs, &[anchor(3, "i")]), "*abc*|[i]|def");
1383 }
1384
1385 #[test]
1386 fn an_image_between_two_runs_lands_between_them() {
1387 let text = "abcdef";
1388 let runs = [run(0, 3, true), run(3, 6, false)];
1389 let out = shape(text, &runs, &[anchor(3, "i")]);
1390 assert_eq!(out, "*abc*|[i]|*def*");
1391 }
1392
1393 #[test]
1394 fn several_images_inside_one_run_keep_their_order() {
1395 let text = "abcdefgh";
1396 let runs = [run(0, 8, true)];
1397 let images = [anchor(2, "a"), anchor(5, "b")];
1398 assert_eq!(shape(text, &runs, &images), "*ab*|[a]|*cde*|[b]|*fgh*");
1399 }
1400
1401 #[test]
1402 fn two_images_at_the_same_offset_both_survive_in_order() {
1403 let text = "abcd";
1404 let runs = [run(0, 4, true)];
1405 let images = [anchor(2, "a"), anchor(2, "b")];
1406 assert_eq!(shape(text, &runs, &images), "*ab*|[a]|[b]|*cd*");
1407 }
1408
1409 #[test]
1410 fn images_with_no_runs_at_all_are_ordered_with_their_gaps() {
1411 let text = "abcdef";
1412 let images = [anchor(0, "a"), anchor(3, "b"), anchor(6, "c")];
1413 assert_eq!(shape(text, &[], &images), "[a]|abc|[b]|def|[c]");
1414 }
1415
1416 #[test]
1417 fn text_uncovered_by_any_run_stays_unformatted() {
1418 let text = "abcdef";
1419 let runs = [run(2, 4, true)];
1420 assert_eq!(shape(text, &runs, &[]), "ab|*cd*|ef");
1421 }
1422
1423 #[test]
1424 fn a_block_with_neither_runs_nor_images_is_one_plain_piece() {
1425 assert_eq!(shape("abc", &[], &[]), "abc");
1426 assert_eq!(shape("", &[], &[]), "");
1427 }
1428
1429 /// Whatever the arrangement, the merge must reproduce the block's bytes
1430 /// exactly once, in order — never dropping or duplicating a slice. The
1431 /// old implementation could regress its cursor and re-emit text twice.
1432 #[test]
1433 fn every_byte_is_emitted_exactly_once_and_in_order() {
1434 let text = "abcdefghij";
1435 let arrangements: [(&[FormatRun], &[ImageAnchor]); 6] = [
1436 (&[], &[]),
1437 (&[run(0, 10, true)], &[anchor(5, "m")]),
1438 (&[run(2, 5, true), run(5, 8, false)], &[anchor(5, "m")]),
1439 (&[run(2, 5, true)], &[anchor(0, "a"), anchor(10, "z")]),
1440 (&[run(0, 3, true), run(7, 10, true)], &[anchor(3, "m")]),
1441 (
1442 &[run(1, 4, true), run(4, 9, false)],
1443 &[anchor(1, "a"), anchor(4, "b"), anchor(9, "c")],
1444 ),
1445 ];
1446 for (i, (runs, images)) in arrangements.iter().enumerate() {
1447 let pieces = merge_runs_and_anchors(text, runs, &block_anchors(images, &[]));
1448 let mut cursor = 0u32;
1449 let mut rebuilt = String::new();
1450 for piece in &pieces {
1451 if let InlinePiece::Text { start, end, .. } = piece {
1452 assert_eq!(*start, cursor, "arrangement {i}: gap or overlap");
1453 assert!(start < end, "arrangement {i}: empty piece emitted");
1454 rebuilt.push_str(&text[*start as usize..*end as usize]);
1455 cursor = *end;
1456 }
1457 }
1458 assert_eq!(cursor, text.len() as u32, "arrangement {i}: truncated");
1459 assert_eq!(rebuilt, text, "arrangement {i}");
1460 let img_count = pieces
1461 .iter()
1462 .filter(|p| matches!(p, InlinePiece::Image(_)))
1463 .count();
1464 assert_eq!(img_count, images.len(), "arrangement {i}: lost an image");
1465 }
1466 }
1467
1468 /// `inline_segments_view` is built on the merge, so it inherits the fix.
1469 #[test]
1470 fn inline_segments_view_places_a_mid_run_image_correctly() {
1471 let segs = inline_segments_view("abcdef", &[run(0, 6, true)], &[anchor(3, "img")], &[]);
1472 assert_eq!(segs.len(), 3);
1473 assert!(matches!(&segs[0].content, InlineContent::Text(t) if t == "abc"));
1474 assert!(matches!(&segs[1].content, InlineContent::Image { name, .. } if name == "img"));
1475 assert!(matches!(&segs[2].content, InlineContent::Text(t) if t == "def"));
1476 // The split keeps the run's formatting on both sides of the image.
1477 assert_eq!(segs[0].fmt_font_bold, Some(true));
1478 assert_eq!(segs[2].fmt_font_bold, Some(true));
1479 }
1480
1481 #[test]
1482 fn inline_segments_view_carries_alt_text_through() {
1483 let mut a = anchor(1, "img");
1484 a.alt = "a black cat".into();
1485 let segs = inline_segments_view("ab", &[], &[a], &[]);
1486 let alt = segs.iter().find_map(|s| match &s.content {
1487 InlineContent::Image { alt, .. } => Some(alt.clone()),
1488 _ => None,
1489 });
1490 assert_eq!(alt.as_deref(), Some("a black cat"));
1491 }
1492
1493 // ── addressable_inline_pieces ───────────────────────────────────────
1494
1495 /// Render an `addressable_inline_pieces` result as `(start, end, label)` triples, so a
1496 /// failing assertion shows the whole span list at once instead of one field at a time.
1497 fn pieces_shape(out: &[AddressableInlinePiece]) -> Vec<(u32, u32, String)> {
1498 out.iter()
1499 .map(|p| {
1500 let label = match &p.content {
1501 InlineContent::Text(t) => t.clone(),
1502 InlineContent::Image { name, .. } => format!("[{name}]"),
1503 InlineContent::FootnoteRef { label } => format!("^{label}^"),
1504 InlineContent::Empty => String::new(),
1505 };
1506 (p.start, p.end, label)
1507 })
1508 .collect()
1509 }
1510
1511 #[test]
1512 fn a_lone_text_piece_spans_its_char_length() {
1513 let out = addressable_inline_pieces("hello", &[], &[], 0);
1514 assert_eq!(pieces_shape(&out), vec![(0, 5, "hello".to_string())]);
1515 }
1516
1517 #[test]
1518 fn base_char_offset_shifts_every_piece_by_the_same_amount() {
1519 let out = addressable_inline_pieces("hello", &[], &[], 100);
1520 assert_eq!(pieces_shape(&out), vec![(100, 105, "hello".to_string())]);
1521 }
1522
1523 /// The regression this type exists to prevent: a multi-byte character before an anchor
1524 /// must not leak its extra BYTE into the anchor's CHAR position. "café" is 5 bytes (é is
1525 /// 2 bytes in UTF-8) but 4 chars — code that summed byte lengths instead of counting
1526 /// chars would place the image at char 5, one past where it actually sits, and every
1527 /// offset after it would carry the same one-character drift.
1528 #[test]
1529 fn a_multibyte_character_before_an_image_does_not_leak_its_extra_byte_into_the_char_position() {
1530 let text = "café";
1531 assert_eq!(text.len(), 5, "test assumes café is 5 bytes");
1532 assert_eq!(text.chars().count(), 4, "test assumes café is 4 chars");
1533 let images = [anchor(5, "pic")]; // byte_offset 5 == right after "café"'s 5 bytes
1534 let anchors = block_anchors(&images, &[]);
1535 let out = addressable_inline_pieces(text, &[], &anchors, 0);
1536 assert_eq!(
1537 pieces_shape(&out),
1538 vec![(0, 4, "café".to_string()), (4, 5, "[pic]".to_string())]
1539 );
1540 }
1541
1542 /// An image sitting inside a formatted run gets its own one-char span, and the text
1543 /// before/after it still lands at the right char offsets — the boundary this milestone's
1544 /// comment-export use case cares about most: "a comment boundary landing immediately
1545 /// after an inline image."
1546 #[test]
1547 fn an_image_mid_run_gets_a_one_char_span_and_the_trailing_text_resumes_right_after_it() {
1548 let text = "abcdef";
1549 let runs = [run(0, 6, true)];
1550 let images = [anchor(3, "img")];
1551 let anchors = block_anchors(&images, &[]);
1552 let out = addressable_inline_pieces(text, &runs, &anchors, 0);
1553 assert_eq!(
1554 pieces_shape(&out),
1555 vec![
1556 (0, 3, "abc".to_string()),
1557 (3, 4, "[img]".to_string()),
1558 (4, 7, "def".to_string()),
1559 ]
1560 );
1561 }
1562
1563 /// A footnote reference gets a one-char span exactly like an image — the boundary
1564 /// case "a comment boundary landing immediately after a footnote reference."
1565 #[test]
1566 fn a_footnote_reference_gets_a_one_char_span_too() {
1567 let text = "abcdef";
1568 let notes = [fn_anchor(3, "n1")];
1569 let anchors = block_anchors(&[], ¬es);
1570 let out = addressable_inline_pieces(text, &[], &anchors, 0);
1571 assert_eq!(
1572 pieces_shape(&out),
1573 vec![
1574 (0, 3, "abc".to_string()),
1575 (3, 4, "^n1^".to_string()),
1576 (4, 7, "def".to_string()),
1577 ]
1578 );
1579 }
1580
1581 /// Whatever the arrangement of runs and anchors, the pieces must tile the block's char
1582 /// space with no gap, no overlap, and no dropped anchor — the char-space analogue of
1583 /// `every_byte_is_emitted_exactly_once_and_in_order` above.
1584 #[test]
1585 fn pieces_are_contiguous_in_char_space_with_no_gaps_or_overlaps() {
1586 let text = "abcdefghij";
1587 let arrangements: [(&[FormatRun], &[ImageAnchor]); 4] = [
1588 (&[], &[]),
1589 (&[run(0, 10, true)], &[anchor(5, "m")]),
1590 (&[run(2, 5, true), run(5, 8, false)], &[anchor(5, "m")]),
1591 (&[run(1, 4, true), run(4, 9, false)], &[anchor(9, "c")]),
1592 ];
1593 for (i, (runs, images)) in arrangements.iter().enumerate() {
1594 let anchors = block_anchors(images, &[]);
1595 let out = addressable_inline_pieces(text, runs, &anchors, 7);
1596 let mut cursor = 7u32;
1597 for piece in &out {
1598 assert_eq!(piece.start, cursor, "arrangement {i}: gap or overlap");
1599 assert!(piece.start < piece.end, "arrangement {i}: empty piece");
1600 cursor = piece.end;
1601 }
1602 // Each anchor is a logical character with no bytes of its own in `text`, so
1603 // it adds one char beyond `text`'s own count — same accounting `block_char_length`
1604 // uses for a real (sentinel-bearing) block, just without a sentinel to count here.
1605 assert_eq!(
1606 cursor,
1607 7 + text.chars().count() as u32 + images.len() as u32,
1608 "arrangement {i}: did not tile the whole block"
1609 );
1610 }
1611 }
1612
1613 #[test]
1614 fn coalesce_merges_adjacent_equal_runs() {
1615 let mut rs = vec![run(0, 5, true), run(5, 10, true), run(10, 15, false)];
1616 coalesce_in_place(&mut rs);
1617 assert_eq!(rs.len(), 2);
1618 assert_eq!(rs[0].byte_end, 10);
1619 }
1620
1621 #[test]
1622 fn coalesce_leaves_disjoint_runs_alone() {
1623 let mut rs = vec![run(0, 5, true), run(7, 10, true)];
1624 coalesce_in_place(&mut rs);
1625 assert_eq!(rs.len(), 2);
1626 }
1627
1628 #[test]
1629 fn splice_range_clips_straddling_runs() {
1630 let mut rs = vec![run(0, 20, true)];
1631 splice_range(&mut rs, 5..15, vec![run(5, 15, false)]);
1632 assert_eq!(rs.len(), 3);
1633 assert_eq!(rs[0].byte_end, 5);
1634 assert_eq!(rs[1].format.font_bold, Some(false));
1635 assert_eq!(rs[2].byte_start, 15);
1636 }
1637
1638 #[test]
1639 fn splice_range_empty_replacement_removes_inner_runs() {
1640 let mut rs = vec![run(0, 5, true), run(5, 10, false), run(10, 15, true)];
1641 splice_range(&mut rs, 5..10, vec![]);
1642 // 0..5 bold, then 10..15 bold — after coalesce these are NOT adjacent
1643 // (there's a gap from 5..10 in the run table, meaning "no format").
1644 assert_eq!(rs.len(), 2);
1645 assert_eq!(rs[0].byte_end, 5);
1646 assert_eq!(rs[1].byte_start, 10);
1647 }
1648
1649 #[test]
1650 fn shift_after_moves_downstream() {
1651 let mut rs = vec![run(0, 5, true), run(10, 15, false)];
1652 shift_after(&mut rs, 5, 3);
1653 assert_eq!(rs[0].byte_start, 0); // unchanged
1654 assert_eq!(rs[1].byte_start, 13);
1655 assert_eq!(rs[1].byte_end, 18);
1656 }
1657}
1658
1659/// The adversarial corpus for [`shift_runs_for_replace`].
1660///
1661/// Replace is the one edit that rewrites a writer's prose *in bulk*, so its formatting
1662/// behaviour has to be pinned rather than inherited by accident. Before this module
1663/// there were 11 replace tests in the repo and **not one** of them mentioned
1664/// `FormatRun`: the behaviour was emergent, undecided, and untested.
1665#[cfg(test)]
1666mod replace_policy_tests {
1667 use super::*;
1668
1669 fn fmt(tag: &str) -> CharacterFormat {
1670 CharacterFormat {
1671 font_bold: Some(tag == "B"),
1672 font_italic: Some(tag == "I"),
1673 ..Default::default()
1674 }
1675 }
1676 fn r(start: u32, end: u32, tag: &str) -> FormatRun {
1677 FormatRun {
1678 byte_start: start,
1679 byte_end: end,
1680 format: fmt(tag),
1681 }
1682 }
1683 /// Compact "0..5=B 8..12=I" rendering, so a failure shows the whole run list.
1684 fn show(runs: &[FormatRun]) -> String {
1685 if runs.is_empty() {
1686 return "[]".to_string();
1687 }
1688 runs.iter()
1689 .map(|x| {
1690 let tag = if x.format.font_bold == Some(true) {
1691 "B"
1692 } else if x.format.font_italic == Some(true) {
1693 "I"
1694 } else {
1695 "p"
1696 };
1697 format!("{}..{}={tag}", x.byte_start, x.byte_end)
1698 })
1699 .collect::<Vec<_>>()
1700 .join(" ")
1701 }
1702 fn replace(
1703 runs: &[FormatRun],
1704 start: u32,
1705 end: u32,
1706 n: u32,
1707 policy: ReplaceFormatPolicy,
1708 ) -> Vec<FormatRun> {
1709 let mut runs = runs.to_vec();
1710 shift_runs_for_replace(&mut runs, start, end, n, policy).expect("valid replace");
1711 runs
1712 }
1713
1714 /// **The spec-conformance test.** `InheritPreceding` must be byte-for-byte what
1715 /// today's `shift_runs_for_delete` + `shift_runs_for_insert` produces — anything
1716 /// else silently rewrites the formatting of every replace that ever shipped.
1717 ///
1718 /// Differential, not hand-transcribed: it runs the historical primitives directly
1719 /// and compares, so it stays honest even if their behaviour is ever changed.
1720 #[test]
1721 fn inherit_preceding_matches_the_historical_delete_then_insert() {
1722 let corpus: Vec<(&str, Vec<FormatRun>, u32, u32, u32)> = vec![
1723 ("run ends exactly at start", vec![r(0, 5, "B")], 5, 10, 3),
1724 ("run begins exactly at start", vec![r(5, 8, "B")], 5, 10, 3),
1725 (
1726 "run begins at start, outlives end",
1727 vec![r(5, 20, "B")],
1728 5,
1729 10,
1730 3,
1731 ),
1732 (
1733 "run straddles the whole range",
1734 vec![r(0, 20, "B")],
1735 5,
1736 10,
1737 3,
1738 ),
1739 ("no run touches the start", vec![r(12, 20, "B")], 5, 10, 3),
1740 ("bold tail inside the range", vec![r(9, 13, "B")], 5, 13, 4),
1741 ("pure delete", vec![r(0, 20, "B")], 5, 10, 0),
1742 ("pure insert", vec![r(0, 20, "B")], 5, 5, 3),
1743 (
1744 "same format either side coalesces",
1745 vec![r(0, 5, "B"), r(10, 15, "B")],
1746 5,
1747 10,
1748 3,
1749 ),
1750 (
1751 "different formats either side",
1752 vec![r(0, 5, "B"), r(10, 15, "I")],
1753 5,
1754 10,
1755 3,
1756 ),
1757 ("empty run list", vec![], 5, 10, 3),
1758 ("the only run is consumed", vec![r(5, 10, "B")], 5, 10, 3),
1759 (
1760 "replacement longer than the range",
1761 vec![r(0, 5, "B")],
1762 5,
1763 10,
1764 20,
1765 ),
1766 (
1767 "three runs straddled",
1768 vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")],
1769 2,
1770 7,
1771 4,
1772 ),
1773 (
1774 "gap between two same-format runs is deleted",
1775 vec![r(0, 5, "B"), r(8, 13, "B")],
1776 5,
1777 8,
1778 0,
1779 ),
1780 ];
1781
1782 for (name, runs, start, end, n) in corpus {
1783 // The historical composition, run for real.
1784 let mut expected = runs.clone();
1785 shift_runs_for_delete(&mut expected, start, end);
1786 shift_runs_for_insert(&mut expected, start, n);
1787
1788 let got = replace(&runs, start, end, n, ReplaceFormatPolicy::InheritPreceding);
1789
1790 assert_eq!(
1791 show(&got),
1792 show(&expected),
1793 "InheritPreceding diverged from delete+insert for {name:?} \
1794 (replace {start}..{end}, n={n})\n before: {}\n historical: {}\n got: {}",
1795 show(&runs),
1796 show(&expected),
1797 show(&got),
1798 );
1799 }
1800 }
1801
1802 /// The motivating data loss: renaming a character whose name reads `Auré**lien**`.
1803 /// The default drops the bold — that is what shipped, and it is now visible and
1804 /// chosen rather than emergent. Every other policy is a way to not lose it.
1805 #[test]
1806 fn the_four_policies_diverge_on_a_partly_bold_name() {
1807 // "Auré" plain (bytes 0..5, é is two bytes), "lien" bold (5..9).
1808 let runs = vec![r(5, 9, "B")];
1809 let (start, end, n) = (0, 9, 9); // rename the whole name, same length
1810
1811 use ReplaceFormatPolicy::*;
1812 assert_eq!(
1813 show(&replace(&runs, start, end, n, InheritPreceding)),
1814 "[]",
1815 "the historical default destroys the bold — pinned, not endorsed"
1816 );
1817 assert_eq!(
1818 show(&replace(&runs, start, end, n, PreserveNothing)),
1819 "[]",
1820 "explicitly unformatted"
1821 );
1822 assert_eq!(
1823 show(&replace(&runs, start, end, n, PreserveIfFullyCovered)),
1824 "[]",
1825 "no SINGLE run covers 0..9 — it must fall back to inheritance, not guess"
1826 );
1827 // Bold covers 4 of the 9 bytes, plain covers 5 → plain dominates.
1828 assert_eq!(
1829 show(&replace(&runs, start, end, n, KeepDominantRun)),
1830 "[]",
1831 "plain covers more of the name than the bold does"
1832 );
1833
1834 // …but when the bold covers MOST of the name, KeepDominantRun keeps it.
1835 let mostly_bold = vec![r(1, 9, "B")];
1836 assert_eq!(
1837 show(&replace(&mostly_bold, 0, 9, 9, KeepDominantRun)),
1838 "0..9=B",
1839 "bold covers 8 of 9 bytes — the rename must keep it"
1840 );
1841 }
1842
1843 /// "Fully covered" means ONE run covers the range — not "the runs jointly span it".
1844 /// A gapless Italic+Bold union spanning the range exactly must NOT be treated as
1845 /// covered, or the replacement silently inherits whichever run was looked at first.
1846 #[test]
1847 fn fully_covered_means_a_single_run_not_a_gapless_union() {
1848 let two = vec![r(0, 3, "I"), r(3, 10, "B")];
1849 assert_eq!(
1850 show(&replace(
1851 &two,
1852 0,
1853 10,
1854 4,
1855 ReplaceFormatPolicy::PreserveIfFullyCovered
1856 )),
1857 "[]",
1858 "two different-format runs jointly spanning the range are not 'covered'; \
1859 with no run preceding the start, the fallback is unformatted"
1860 );
1861
1862 // One run that really does cover it, and begins exactly at the start — the case
1863 // InheritPreceding cannot see (a run at `start` never inherits).
1864 let one = vec![r(5, 20, "B")];
1865 assert_eq!(
1866 show(&replace(
1867 &one,
1868 5,
1869 10,
1870 3,
1871 ReplaceFormatPolicy::PreserveIfFullyCovered
1872 )),
1873 "5..18=B",
1874 "a single covering run keeps its format across the rename"
1875 );
1876 assert_eq!(
1877 show(&replace(
1878 &one,
1879 5,
1880 10,
1881 3,
1882 ReplaceFormatPolicy::InheritPreceding
1883 )),
1884 "8..18=B",
1885 "…which the default would have lost: the replacement lands unformatted"
1886 );
1887 }
1888
1889 /// A run that merely *touches* the range must not leak its format to the whole
1890 /// replacement.
1891 #[test]
1892 fn a_partially_overlapping_run_does_not_count_as_covering() {
1893 let runs = vec![r(0, 8, "B")]; // covers only 5..8 of the range 5..12
1894 assert_eq!(
1895 show(&replace(
1896 &runs,
1897 5,
1898 12,
1899 4,
1900 ReplaceFormatPolicy::PreserveIfFullyCovered
1901 )),
1902 "0..9=B",
1903 "not covered → falls back to inheritance, which extends the preceding bold; \
1904 it must NOT format the whole replacement as though bold had covered it"
1905 );
1906 }
1907
1908 /// Ties between two runs resolve to the EARLIEST, deterministically.
1909 ///
1910 /// `Iterator::max_by_key` returns the LAST maximum, so the obvious one-liner would
1911 /// have silently picked the other run here.
1912 #[test]
1913 fn a_dominance_tie_between_two_runs_goes_to_the_earlier() {
1914 let runs = vec![r(0, 3, "B"), r(3, 6, "I")]; // 3 bytes each
1915 assert_eq!(
1916 show(&replace(
1917 &runs,
1918 0,
1919 6,
1920 4,
1921 ReplaceFormatPolicy::KeepDominantRun
1922 )),
1923 "0..4=B",
1924 "a true tie must resolve to the earlier run, not to whichever the iterator \
1925 happened to visit last"
1926 );
1927 }
1928
1929 /// A tie between a run and the unformatted gap goes to the run: losing formatting is
1930 /// the destructive outcome, so it needs a strict majority of *plain* to win.
1931 #[test]
1932 fn a_dominance_tie_against_plain_text_keeps_the_formatting() {
1933 let runs = vec![r(4, 8, "B")]; // 4 bold bytes, 4 plain bytes in 0..8
1934 assert_eq!(
1935 show(&replace(
1936 &runs,
1937 0,
1938 8,
1939 5,
1940 ReplaceFormatPolicy::KeepDominantRun
1941 )),
1942 "0..5=B",
1943 "an even split must keep the formatting rather than silently drop it"
1944 );
1945 }
1946
1947 /// An empty range replaces nothing, so it is an insertion — and every policy that
1948 /// reasons about "what was covered" must defer to the insert convention instead of
1949 /// stripping the format the typed text would have inherited.
1950 #[test]
1951 fn an_empty_range_is_an_insert_and_no_coverage_policy_overrides_it() {
1952 let runs = vec![r(0, 5, "B"), r(5, 10, "I")];
1953 use ReplaceFormatPolicy::*;
1954 for policy in [InheritPreceding, PreserveIfFullyCovered, KeepDominantRun] {
1955 assert_eq!(
1956 show(&replace(&runs, 5, 5, 2, policy)),
1957 "0..7=B 7..12=I",
1958 "{policy:?}: typing at a boundary must inherit the run to the LEFT (Qt \
1959 convention) — an empty range destroyed no formatting, so there is \
1960 nothing for a coverage policy to override"
1961 );
1962 }
1963 // The one policy that is an explicit request, not an inference, still applies.
1964 assert_eq!(
1965 show(&replace(&runs, 5, 5, 2, PreserveNothing)),
1966 "0..5=B 7..12=I",
1967 "PreserveNothing asks for unformatted text, and means it even on an insert"
1968 );
1969 }
1970
1971 /// Nothing to do must mean nothing done — no fabricated runs, no lost ones.
1972 #[test]
1973 fn a_zero_width_zero_length_replace_is_the_identity() {
1974 let runs = vec![r(0, 5, "B"), r(7, 12, "I")];
1975 for policy in [
1976 ReplaceFormatPolicy::InheritPreceding,
1977 ReplaceFormatPolicy::PreserveIfFullyCovered,
1978 ReplaceFormatPolicy::KeepDominantRun,
1979 ReplaceFormatPolicy::PreserveNothing,
1980 ] {
1981 assert_eq!(
1982 show(&replace(&runs, 6, 6, 0, policy)),
1983 "0..5=B 7..12=I",
1984 "{policy:?} changed a no-op edit"
1985 );
1986 }
1987 }
1988
1989 /// `PreserveNothing` must leave the region genuinely *unformatted* — not covered by
1990 /// a fabricated run carrying `CharacterFormat::default()`, which is a different
1991 /// thing and would defeat coalescing forever after.
1992 #[test]
1993 fn preserve_nothing_fabricates_no_default_run() {
1994 let runs = vec![r(0, 5, "B")];
1995 let got = replace(&runs, 7, 9, 2, ReplaceFormatPolicy::PreserveNothing);
1996 assert_eq!(show(&got), "0..5=B", "no run may be invented for the gap");
1997 assert!(
1998 got.iter().all(|x| x.byte_start < 7 || x.byte_end > 9),
1999 "the replaced span must carry no run at all"
2000 );
2001 }
2002
2003 /// Offsets are BYTES, not chars. A 4-byte emoji replaced by 2 ASCII bytes must
2004 /// shift downstream runs by -2, not by -1 (chars) or 0.
2005 #[test]
2006 fn offsets_are_bytes_not_characters() {
2007 // "🎉" (4 bytes, bold) + " " + "abcd" (italic).
2008 let runs = vec![r(0, 4, "B"), r(5, 9, "I")];
2009 let got = replace(&runs, 0, 4, 2, ReplaceFormatPolicy::KeepDominantRun);
2010 assert_eq!(
2011 show(&got),
2012 "0..2=B 3..7=I",
2013 "the trailing italic must shift back by the BYTE delta (4 -> 2 = -2)"
2014 );
2015 }
2016
2017 /// Every policy must leave the run list well-formed — sorted, non-overlapping,
2018 /// coalesced, inside the block. This is the invariant a release build no longer
2019 /// merely asserts.
2020 #[test]
2021 fn every_policy_leaves_the_runs_well_formed() {
2022 let setups: Vec<(Vec<FormatRun>, u32, u32, u32, usize)> = vec![
2023 (vec![r(0, 3, "B"), r(3, 6, "I"), r(6, 9, "B")], 2, 7, 4, 8),
2024 (vec![r(0, 5, "B"), r(8, 13, "B")], 5, 8, 0, 10),
2025 (vec![r(0, 5, "B"), r(7, 12, "B")], 8, 10, 2, 12),
2026 (vec![r(3, 7, "B")], 3, 7, 0, 6),
2027 (vec![], 2, 6, 3, 9),
2028 ];
2029 for (runs, start, end, n, text_len) in setups {
2030 for policy in [
2031 ReplaceFormatPolicy::InheritPreceding,
2032 ReplaceFormatPolicy::PreserveIfFullyCovered,
2033 ReplaceFormatPolicy::KeepDominantRun,
2034 ReplaceFormatPolicy::PreserveNothing,
2035 ] {
2036 let got = replace(&runs, start, end, n, policy);
2037 check_well_formed(&got, text_len).unwrap_or_else(|e| {
2038 panic!(
2039 "{policy:?} produced malformed runs from {} (replace {start}..{end}, \
2040 n={n}): {} — {e}",
2041 show(&runs),
2042 show(&got)
2043 )
2044 });
2045 }
2046 }
2047 }
2048
2049 /// Two same-format runs separated by an untouched gap must stay separate — coalescing
2050 /// is for *adjacent* runs, and over-eager merging would swallow the plain text between
2051 /// them.
2052 #[test]
2053 fn an_untouched_gap_between_equal_runs_survives() {
2054 let runs = vec![r(0, 5, "B"), r(7, 12, "B")];
2055 assert_eq!(
2056 show(&replace(
2057 &runs,
2058 8,
2059 10,
2060 2,
2061 ReplaceFormatPolicy::InheritPreceding
2062 )),
2063 "0..5=B 7..12=B",
2064 "the plain gap at 5..7 must not be swallowed"
2065 );
2066 }
2067
2068 /// An inverted range is refused, not asserted away.
2069 #[test]
2070 fn an_inverted_range_is_an_error_not_a_panic() {
2071 let mut runs = vec![r(0, 5, "B")];
2072 let err =
2073 shift_runs_for_replace(&mut runs, 10, 5, 3, ReplaceFormatPolicy::InheritPreceding)
2074 .expect_err("an inverted range must be rejected");
2075 assert!(matches!(
2076 err,
2077 FormatRunError::ReversedRange { start: 10, end: 5 }
2078 ));
2079 assert_eq!(show(&runs), "0..5=B", "a refused edit must change nothing");
2080 }
2081}
2082
2083/// The runtime guards that replaced the release-mode-invisible `debug_assert!`s.
2084#[cfg(test)]
2085mod invariant_check_tests {
2086 use super::*;
2087
2088 fn run(start: u32, end: u32, bold: bool) -> FormatRun {
2089 FormatRun {
2090 byte_start: start,
2091 byte_end: end,
2092 format: CharacterFormat {
2093 font_bold: Some(bold),
2094 ..Default::default()
2095 },
2096 }
2097 }
2098
2099 /// The whole point: in a **release** build `debug_assert!` is gone, so a violating
2100 /// splice used to sail straight through and build a malformed run list. Now it is
2101 /// reported, and the caller's runs are left exactly as they were.
2102 #[test]
2103 fn a_replacement_outside_the_range_is_rejected_without_mutating() {
2104 let mut runs = vec![run(0, 20, true)];
2105 let before = runs.clone();
2106
2107 let err = try_splice_range(&mut runs, 5..10, vec![run(5, 15, false)])
2108 .expect_err("a replacement run reaching past range.end must be rejected");
2109
2110 assert!(matches!(
2111 err,
2112 FormatRunError::ReplacementOutsideRange {
2113 run_end: 15,
2114 range_end: 10,
2115 ..
2116 }
2117 ));
2118 assert_eq!(
2119 runs, before,
2120 "a rejected splice must not half-apply — validation happens before mutation"
2121 );
2122 }
2123
2124 #[test]
2125 // The inverted range is the whole point of the test — it is what a caller must not
2126 // be able to sneak past a release build.
2127 #[allow(clippy::reversed_empty_ranges)]
2128 fn a_reversed_range_is_rejected() {
2129 let mut runs = vec![run(0, 20, true)];
2130 assert!(matches!(
2131 try_splice_range(&mut runs, 10..5, vec![]),
2132 Err(FormatRunError::ReversedRange { start: 10, end: 5 })
2133 ));
2134 }
2135
2136 #[test]
2137 fn a_legal_splice_still_works_through_the_checked_path() {
2138 let mut runs = vec![run(0, 20, true)];
2139 try_splice_range(&mut runs, 5..15, vec![run(5, 15, false)]).expect("legal");
2140 assert_eq!(runs.len(), 3);
2141 assert_eq!(runs[1].format.font_bold, Some(false));
2142 }
2143
2144 #[test]
2145 fn check_well_formed_catches_what_debug_assert_used_to() {
2146 assert!(check_well_formed(&[], 0).is_ok());
2147 assert!(check_well_formed(&[run(0, 5, true)], 5).is_ok());
2148
2149 assert!(matches!(
2150 check_well_formed(&[run(5, 5, true)], 10),
2151 Err(FormatRunError::EmptyRun { .. })
2152 ));
2153 assert!(matches!(
2154 check_well_formed(&[run(0, 8, true), run(5, 10, false)], 10),
2155 Err(FormatRunError::RunsOverlap { .. })
2156 ));
2157 assert!(matches!(
2158 check_well_formed(&[run(0, 5, true), run(5, 10, true)], 10),
2159 Err(FormatRunError::RunsNotCoalesced { .. })
2160 ));
2161 assert!(matches!(
2162 check_well_formed(&[run(0, 20, true)], 10),
2163 Err(FormatRunError::RunPastEndOfBlock { text_len: 10, .. })
2164 ));
2165 }
2166}