Skip to main content

text_document/
text_block.rs

1//! Read-only block (paragraph) handle.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use frontend::commands::{block_commands, frame_commands, inline_element_commands, list_commands};
8use frontend::common::types::EntityId;
9use frontend::inline_element::dtos::InlineContent;
10
11use crate::convert::to_usize;
12use crate::flow::{BlockSnapshot, FragmentContent, ListInfo, TableCellContext, TableCellRef};
13use crate::inner::TextDocumentInner;
14use crate::text_frame::TextFrame;
15use crate::text_list::TextList;
16use crate::text_table::TextTable;
17use crate::{BlockFormat, ListStyle, TextFormat};
18
19/// A lightweight, read-only handle to a single block (paragraph).
20///
21/// Holds a stable entity ID — the handle remains valid across edits
22/// that insert or remove other blocks. Each method acquires the
23/// document lock independently. For consistent reads across multiple
24/// fields, use [`snapshot()`](TextBlock::snapshot).
25#[derive(Clone)]
26pub struct TextBlock {
27    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
28    pub(crate) block_id: usize,
29}
30
31impl TextBlock {
32    // ── Content ──────────────────────────────────────────────
33
34    /// Block's plain text. O(1).
35    pub fn text(&self) -> String {
36        let inner = self.doc.lock();
37        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
38            .ok()
39            .flatten()
40            .map(|b| b.plain_text)
41            .unwrap_or_default()
42    }
43
44    /// Character count. O(1).
45    pub fn length(&self) -> usize {
46        let inner = self.doc.lock();
47        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
48            .ok()
49            .flatten()
50            .map(|b| to_usize(b.text_length))
51            .unwrap_or(0)
52    }
53
54    /// `length() == 0`. O(1).
55    pub fn is_empty(&self) -> bool {
56        let inner = self.doc.lock();
57        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
58            .ok()
59            .flatten()
60            .map(|b| b.text_length == 0)
61            .unwrap_or(true)
62    }
63
64    /// Block entity still exists in the database. O(1).
65    pub fn is_valid(&self) -> bool {
66        let inner = self.doc.lock();
67        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
68            .ok()
69            .flatten()
70            .is_some()
71    }
72
73    // ── Identity and Position ────────────────────────────────
74
75    /// Stable entity ID (stored in the handle). O(1).
76    pub fn id(&self) -> usize {
77        self.block_id
78    }
79
80    /// Character offset from `Block.document_position`. O(1).
81    pub fn position(&self) -> usize {
82        let inner = self.doc.lock();
83        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
84            .ok()
85            .flatten()
86            .map(|b| to_usize(b.document_position))
87            .unwrap_or(0)
88    }
89
90    /// Global 0-indexed block number. **O(n)**: requires scanning all blocks
91    /// sorted by `document_position`. Prefer [`id()`](TextBlock::id) for
92    /// identity and [`position()`](TextBlock::position) for ordering.
93    pub fn block_number(&self) -> usize {
94        let inner = self.doc.lock();
95        compute_block_number(&inner, self.block_id as u64)
96    }
97
98    /// The next block in document order. **O(n)**.
99    /// Returns `None` if this is the last block.
100    pub fn next(&self) -> Option<TextBlock> {
101        let inner = self.doc.lock();
102        let all_blocks = block_commands::get_all_block(&inner.ctx).ok()?;
103        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
104        sorted.sort_by_key(|b| b.document_position);
105        let idx = sorted.iter().position(|b| b.id == self.block_id as u64)?;
106        sorted.get(idx + 1).map(|b| TextBlock {
107            doc: Arc::clone(&self.doc),
108            block_id: b.id as usize,
109        })
110    }
111
112    /// The previous block in document order. **O(n)**.
113    /// Returns `None` if this is the first block.
114    pub fn previous(&self) -> Option<TextBlock> {
115        let inner = self.doc.lock();
116        let all_blocks = block_commands::get_all_block(&inner.ctx).ok()?;
117        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
118        sorted.sort_by_key(|b| b.document_position);
119        let idx = sorted.iter().position(|b| b.id == self.block_id as u64)?;
120        if idx == 0 {
121            return None;
122        }
123        sorted.get(idx - 1).map(|b| TextBlock {
124            doc: Arc::clone(&self.doc),
125            block_id: b.id as usize,
126        })
127    }
128
129    // ── Structural Context ───────────────────────────────────
130
131    /// Parent frame. O(1).
132    pub fn frame(&self) -> TextFrame {
133        let inner = self.doc.lock();
134        let frame_id = find_parent_frame(&inner, self.block_id as u64);
135        TextFrame {
136            doc: Arc::clone(&self.doc),
137            frame_id: frame_id.map(|id| id as usize).unwrap_or(0),
138        }
139    }
140
141    /// If inside a table cell, returns table and cell coordinates.
142    ///
143    /// Finds the block's parent frame, then checks if any table cell
144    /// references that frame as its `cell_frame`. If so, identifies the
145    /// owning table.
146    pub fn table_cell(&self) -> Option<TableCellRef> {
147        let inner = self.doc.lock();
148        let frame_id = find_parent_frame(&inner, self.block_id as u64)?;
149
150        // Check if this frame is referenced as a cell_frame by any table cell.
151        // First try the fast path: if the frame has a `table` field, use it.
152        let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
153            .ok()
154            .flatten()?;
155
156        if let Some(table_entity_id) = frame_dto.table {
157            // This frame is a table anchor frame (not a cell frame).
158            // Anchor frames don't contain blocks directly — cell frames do.
159            // So this path shouldn't match, but check cells just in case.
160            let table_dto =
161                frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
162                    .ok()
163                    .flatten()?;
164            for &cell_id in &table_dto.cells {
165                if let Some(cell_dto) =
166                    frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{
167                        cell_id
168                    })
169                    .ok()
170                    .flatten()
171                    && cell_dto.cell_frame == Some(frame_id)
172                {
173                    return Some(TableCellRef {
174                        table: TextTable {
175                            doc: Arc::clone(&self.doc),
176                            table_id: table_entity_id as usize,
177                        },
178                        row: to_usize(cell_dto.row),
179                        column: to_usize(cell_dto.column),
180                    });
181                }
182            }
183        }
184
185        // Slow path: this frame has no `table` field (cell frames don't).
186        // Scan all tables to find if any cell references this frame.
187        let all_tables =
188            frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
189        for table_dto in &all_tables {
190            for &cell_id in &table_dto.cells {
191                if let Some(cell_dto) =
192                    frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{
193                        cell_id
194                    })
195                    .ok()
196                    .flatten()
197                    && cell_dto.cell_frame == Some(frame_id)
198                {
199                    return Some(TableCellRef {
200                        table: TextTable {
201                            doc: Arc::clone(&self.doc),
202                            table_id: table_dto.id as usize,
203                        },
204                        row: to_usize(cell_dto.row),
205                        column: to_usize(cell_dto.column),
206                    });
207                }
208            }
209        }
210
211        None
212    }
213
214    // ── Formatting ──────────────────────────────────────────
215
216    /// Block format (alignment, margins, indent, heading level, marker, tabs). O(1).
217    pub fn block_format(&self) -> BlockFormat {
218        let inner = self.doc.lock();
219        block_commands::get_block(&inner.ctx, &(self.block_id as u64))
220            .ok()
221            .flatten()
222            .map(|b| BlockFormat::from(&b))
223            .unwrap_or_default()
224    }
225
226    /// Character format at a block-relative character offset. **O(k)**
227    /// where k = number of InlineElements.
228    ///
229    /// Returns the [`TextFormat`] of the fragment containing the given
230    /// offset. Returns `None` if the offset is out of range or the
231    /// block has no fragments.
232    pub fn char_format_at(&self, offset: usize) -> Option<TextFormat> {
233        let inner = self.doc.lock();
234        let fragments = build_fragments(&inner, self.block_id as u64);
235        for frag in &fragments {
236            match frag {
237                FragmentContent::Text {
238                    format,
239                    offset: frag_offset,
240                    length,
241                    ..
242                } => {
243                    if offset >= *frag_offset && offset < frag_offset + length {
244                        return Some(format.clone());
245                    }
246                }
247                FragmentContent::Image {
248                    format,
249                    offset: frag_offset,
250                    ..
251                } => {
252                    if offset == *frag_offset {
253                        return Some(format.clone());
254                    }
255                }
256            }
257        }
258        None
259    }
260
261    // ── Fragments ───────────────────────────────────────────
262
263    /// All formatting runs in one call. O(k) where k = number of InlineElements.
264    pub fn fragments(&self) -> Vec<FragmentContent> {
265        let inner = self.doc.lock();
266        build_fragments(&inner, self.block_id as u64)
267    }
268
269    // ── List Membership ─────────────────────────────────────
270
271    /// List this block belongs to. O(1).
272    pub fn list(&self) -> Option<TextList> {
273        let inner = self.doc.lock();
274        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
275            .ok()
276            .flatten()?;
277        let list_id = block_dto.list?;
278        Some(TextList {
279            doc: Arc::clone(&self.doc),
280            list_id: list_id as usize,
281        })
282    }
283
284    /// 0-based position within its list. **O(n)** where n = total blocks.
285    pub fn list_item_index(&self) -> Option<usize> {
286        let inner = self.doc.lock();
287        let block_dto = block_commands::get_block(&inner.ctx, &(self.block_id as u64))
288            .ok()
289            .flatten()?;
290        let list_id = block_dto.list?;
291        Some(compute_list_item_index(
292            &inner,
293            list_id,
294            self.block_id as u64,
295        ))
296    }
297
298    // ── Snapshot ─────────────────────────────────────────────
299
300    /// All layout-relevant data in one lock acquisition. O(k+n).
301    pub fn snapshot(&self) -> BlockSnapshot {
302        let inner = self.doc.lock();
303        build_block_snapshot(&inner, self.block_id as u64).unwrap_or_else(|| BlockSnapshot {
304            block_id: self.block_id,
305            position: 0,
306            length: 0,
307            text: String::new(),
308            fragments: Vec::new(),
309            block_format: BlockFormat::default(),
310            list_info: None,
311            parent_frame_id: None,
312            table_cell: None,
313        })
314    }
315}
316
317// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
318// Internal helpers (called while lock is held)
319// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
320
321/// Find the parent frame of a block by scanning all frames.
322fn find_parent_frame(inner: &TextDocumentInner, block_id: u64) -> Option<EntityId> {
323    let all_frames = frame_commands::get_all_frame(&inner.ctx).ok()?;
324    let block_entity_id = block_id as EntityId;
325    for frame in &all_frames {
326        if frame.blocks.contains(&block_entity_id) {
327            return Some(frame.id as EntityId);
328        }
329    }
330    None
331}
332
333/// Find table cell context for a block (snapshot-friendly, no live handles).
334/// Returns `None` if the block is not inside a table cell.
335fn find_table_cell_context(inner: &TextDocumentInner, block_id: u64) -> Option<TableCellContext> {
336    let frame_id = find_parent_frame(inner, block_id)?;
337
338    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
339        .ok()
340        .flatten()?;
341
342    // Fast path: anchor frame with `table` field set
343    if let Some(table_entity_id) = frame_dto.table {
344        let table_dto =
345            frontend::commands::table_commands::get_table(&inner.ctx, &{ table_entity_id })
346                .ok()
347                .flatten()?;
348        for &cell_id in &table_dto.cells {
349            if let Some(cell_dto) =
350                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
351                    .ok()
352                    .flatten()
353                && cell_dto.cell_frame == Some(frame_id)
354            {
355                return Some(TableCellContext {
356                    table_id: table_entity_id as usize,
357                    row: to_usize(cell_dto.row),
358                    column: to_usize(cell_dto.column),
359                });
360            }
361        }
362    }
363
364    // Slow path: scan all tables for a cell referencing this frame
365    let all_tables =
366        frontend::commands::table_commands::get_all_table(&inner.ctx).unwrap_or_default();
367    for table_dto in &all_tables {
368        for &cell_id in &table_dto.cells {
369            if let Some(cell_dto) =
370                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &{ cell_id })
371                    .ok()
372                    .flatten()
373                && cell_dto.cell_frame == Some(frame_id)
374            {
375                return Some(TableCellContext {
376                    table_id: table_dto.id as usize,
377                    row: to_usize(cell_dto.row),
378                    column: to_usize(cell_dto.column),
379                });
380            }
381        }
382    }
383
384    None
385}
386
387/// Compute 0-indexed block number by scanning all blocks sorted by document_position.
388fn compute_block_number(inner: &TextDocumentInner, block_id: u64) -> usize {
389    let all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
390    let mut sorted: Vec<_> = all_blocks.iter().collect();
391    sorted.sort_by_key(|b| b.document_position);
392    sorted.iter().position(|b| b.id == block_id).unwrap_or(0)
393}
394
395/// Build fragments for a block from its InlineElements, with highlight
396/// spans merged in when a syntax highlighter is attached.
397pub(crate) fn build_fragments(inner: &TextDocumentInner, block_id: u64) -> Vec<FragmentContent> {
398    let fragments = build_raw_fragments(inner, block_id);
399
400    if let Some(ref hl) = inner.highlight
401        && let Some(block_hl) = hl.blocks.get(&(block_id as usize))
402        && !block_hl.spans.is_empty()
403    {
404        return crate::highlight::merge_highlight_spans(fragments, &block_hl.spans);
405    }
406
407    fragments
408}
409
410/// Build raw fragments from InlineElements (no highlight merge).
411fn build_raw_fragments(inner: &TextDocumentInner, block_id: u64) -> Vec<FragmentContent> {
412    let block_dto = match block_commands::get_block(&inner.ctx, &block_id)
413        .ok()
414        .flatten()
415    {
416        Some(b) => b,
417        None => return Vec::new(),
418    };
419
420    let element_ids = &block_dto.elements;
421    let elements: Vec<_> = element_ids
422        .iter()
423        .filter_map(|&id| {
424            inline_element_commands::get_inline_element(&inner.ctx, &{ id })
425                .ok()
426                .flatten()
427        })
428        .collect();
429
430    let mut fragments = Vec::with_capacity(elements.len());
431    let mut offset: usize = 0;
432
433    for el in &elements {
434        let format = TextFormat::from(el);
435        match &el.content {
436            InlineContent::Text(text) => {
437                let length = text.chars().count();
438                let word_starts = compute_word_starts(text);
439                fragments.push(FragmentContent::Text {
440                    text: text.clone(),
441                    format,
442                    offset,
443                    length,
444                    element_id: el.id,
445                    word_starts,
446                });
447                offset += length;
448            }
449            InlineContent::Image {
450                name,
451                width,
452                height,
453                quality,
454            } => {
455                fragments.push(FragmentContent::Image {
456                    name: name.clone(),
457                    width: *width as u32,
458                    height: *height as u32,
459                    quality: *quality as u32,
460                    format,
461                    offset,
462                    element_id: el.id,
463                });
464                offset += 1; // images take 1 character position
465            }
466            InlineContent::Empty => {
467                // Empty elements don't produce fragments
468            }
469        }
470    }
471
472    fragments
473}
474
475/// Compute character-index-based word starts for a text slice,
476/// following Unicode Standard Annex #29. Returned indices are
477/// positions within `text.chars()`, NOT byte offsets — matches
478/// AccessKit's `word_starts` contract where each entry is an index
479/// into `character_lengths`.
480fn compute_word_starts(text: &str) -> Vec<u8> {
481    use unicode_segmentation::UnicodeSegmentation;
482    let mut result = Vec::new();
483    // `unicode_word_indices` yields (byte_offset, word_slice) for each
484    // Unicode-word match. Convert each byte offset to a character
485    // index by counting `char_indices` up to that offset.
486    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
487    for (ci, (bi, _)) in text.char_indices().enumerate() {
488        byte_to_char.push((bi, ci));
489    }
490    for (byte_off, _word) in text.unicode_word_indices() {
491        let char_idx = byte_to_char
492            .iter()
493            .find(|(bi, _)| *bi == byte_off)
494            .map(|(_, ci)| *ci)
495            .unwrap_or(0);
496        // Saturating cast — text runs longer than 255 chars get their
497        // later word starts dropped. That's the AccessKit contract:
498        // `word_starts` is Box<[u8]>. Runs longer than ~255 chars are
499        // unusual for a single format run, and the first 255 word
500        // starts cover the viewport almost always. Documented in the
501        // plan.
502        if let Ok(idx) = u8::try_from(char_idx) {
503            result.push(idx);
504        } else {
505            break;
506        }
507    }
508    result
509}
510
511/// Compute 0-based index of a block within its list.
512fn compute_list_item_index(inner: &TextDocumentInner, list_id: EntityId, block_id: u64) -> usize {
513    let all_blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
514    let mut list_blocks: Vec<_> = all_blocks
515        .iter()
516        .filter(|b| b.list == Some(list_id))
517        .collect();
518    list_blocks.sort_by_key(|b| b.document_position);
519    list_blocks
520        .iter()
521        .position(|b| b.id == block_id)
522        .unwrap_or(0)
523}
524
525/// Format a list marker for the given item index.
526pub(crate) fn format_list_marker(
527    list_dto: &frontend::list::dtos::ListDto,
528    item_index: usize,
529) -> String {
530    let number = item_index + 1; // 1-based for display
531    let marker_body = match list_dto.style {
532        ListStyle::Disc => "\u{2022}".to_string(),   // •
533        ListStyle::Circle => "\u{25E6}".to_string(), // ◦
534        ListStyle::Square => "\u{25AA}".to_string(), // ▪
535        ListStyle::Decimal => format!("{number}"),
536        ListStyle::LowerAlpha => {
537            if number <= 26 {
538                ((b'a' + (number as u8 - 1)) as char).to_string()
539            } else {
540                format!("{number}")
541            }
542        }
543        ListStyle::UpperAlpha => {
544            if number <= 26 {
545                ((b'A' + (number as u8 - 1)) as char).to_string()
546            } else {
547                format!("{number}")
548            }
549        }
550        ListStyle::LowerRoman => to_roman_lower(number),
551        ListStyle::UpperRoman => to_roman_upper(number),
552    };
553    format!("{}{marker_body}{}", list_dto.prefix, list_dto.suffix)
554}
555
556fn to_roman_upper(mut n: usize) -> String {
557    const VALUES: &[(usize, &str)] = &[
558        (1000, "M"),
559        (900, "CM"),
560        (500, "D"),
561        (400, "CD"),
562        (100, "C"),
563        (90, "XC"),
564        (50, "L"),
565        (40, "XL"),
566        (10, "X"),
567        (9, "IX"),
568        (5, "V"),
569        (4, "IV"),
570        (1, "I"),
571    ];
572    let mut result = String::new();
573    for &(val, sym) in VALUES {
574        while n >= val {
575            result.push_str(sym);
576            n -= val;
577        }
578    }
579    result
580}
581
582fn to_roman_lower(n: usize) -> String {
583    to_roman_upper(n).to_lowercase()
584}
585
586/// Build a ListInfo for a block. Called while lock is held.
587fn build_list_info(
588    inner: &TextDocumentInner,
589    block_dto: &frontend::block::dtos::BlockDto,
590) -> Option<ListInfo> {
591    let list_id = block_dto.list?;
592    let list_dto = list_commands::get_list(&inner.ctx, &{ list_id })
593        .ok()
594        .flatten()?;
595
596    let item_index = compute_list_item_index(inner, list_id, block_dto.id);
597    let marker = format_list_marker(&list_dto, item_index);
598
599    Some(ListInfo {
600        list_id: list_id as usize,
601        style: list_dto.style.clone(),
602        indent: list_dto.indent as u8,
603        marker,
604        item_index,
605    })
606}
607
608/// Build a BlockSnapshot for a block. Called while lock is held.
609pub(crate) fn build_block_snapshot(
610    inner: &TextDocumentInner,
611    block_id: u64,
612) -> Option<BlockSnapshot> {
613    build_block_snapshot_with_position(inner, block_id, None)
614}
615
616/// Build a BlockSnapshot, optionally overriding the position with a computed value.
617/// When `computed_position` is Some, it's used instead of `block_dto.document_position`
618/// (which may be stale if position updates are deferred).
619pub(crate) fn build_block_snapshot_with_position(
620    inner: &TextDocumentInner,
621    block_id: u64,
622    computed_position: Option<usize>,
623) -> Option<BlockSnapshot> {
624    let block_dto = block_commands::get_block(&inner.ctx, &block_id)
625        .ok()
626        .flatten()?;
627
628    let fragments = build_fragments(inner, block_id);
629    let block_format = BlockFormat::from(&block_dto);
630    let list_info = build_list_info(inner, &block_dto);
631
632    let parent_frame_id = find_parent_frame(inner, block_id).map(|id| id as usize);
633    let table_cell = find_table_cell_context(inner, block_id);
634
635    let position = computed_position.unwrap_or_else(|| to_usize(block_dto.document_position));
636
637    Some(BlockSnapshot {
638        block_id: block_id as usize,
639        position,
640        length: to_usize(block_dto.text_length),
641        text: block_dto.plain_text,
642        fragments,
643        block_format,
644        list_info,
645        parent_frame_id,
646        table_cell,
647    })
648}
649
650/// Build BlockSnapshots for all blocks in a frame, sorted by document_position.
651pub(crate) fn build_blocks_snapshot_for_frame(
652    inner: &TextDocumentInner,
653    frame_id: u64,
654) -> Vec<BlockSnapshot> {
655    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
656        .ok()
657        .flatten()
658    {
659        Some(f) => f,
660        None => return Vec::new(),
661    };
662
663    let mut block_dtos: Vec<_> = frame_dto
664        .blocks
665        .iter()
666        .filter_map(|&id| {
667            block_commands::get_block(&inner.ctx, &{ id })
668                .ok()
669                .flatten()
670        })
671        .collect();
672    block_dtos.sort_by_key(|b| b.document_position);
673
674    block_dtos
675        .iter()
676        .filter_map(|b| build_block_snapshot(inner, b.id))
677        .collect()
678}
679
680/// Build BlockSnapshots with computed positions starting from `start_pos`.
681///
682/// Returns `(snapshots, running_pos_after_last_block)`.
683/// Positions are computed sequentially from `start_pos` using each block's
684/// `text_length`, matching the logic in `find_block_at_position_sequential`.
685pub(crate) fn build_blocks_snapshot_for_frame_with_positions(
686    inner: &TextDocumentInner,
687    frame_id: u64,
688    start_pos: usize,
689) -> (Vec<BlockSnapshot>, usize) {
690    let frame_dto = match frame_commands::get_frame(&inner.ctx, &(frame_id as EntityId))
691        .ok()
692        .flatten()
693    {
694        Some(f) => f,
695        None => return (Vec::new(), start_pos),
696    };
697
698    let mut block_dtos: Vec<_> = frame_dto
699        .blocks
700        .iter()
701        .filter_map(|&id| {
702            block_commands::get_block(&inner.ctx, &{ id })
703                .ok()
704                .flatten()
705        })
706        .collect();
707    block_dtos.sort_by_key(|b| b.document_position);
708
709    let mut running_pos = start_pos;
710    let mut snapshots = Vec::with_capacity(block_dtos.len());
711    for b in &block_dtos {
712        if let Some(snap) = build_block_snapshot_with_position(inner, b.id, Some(running_pos)) {
713            running_pos += snap.length + 1; // +1 for block separator
714            snapshots.push(snap);
715        }
716    }
717    (snapshots, running_pos)
718}