Skip to main content

docspec_blocknote_writer/
lib.rs

1//! `DocSpec` event stream to `BlockNote` JSON writer.
2//!
3//! This crate provides a streaming [`BlockNoteWriter`] that implements [`EventSink`] to convert
4//! `DocSpec` event streams into `BlockNote` JSON format. `BlockNote` is a block-based rich text
5//! editor format.
6//!
7//! # Design
8//!
9//! The writer emits JSON tokens directly to the underlying `Write` as events arrive using
10//! `docspec-json` for streaming JSON output. For text and URI-based images, memory usage is
11//! constant regardless of document size. Asset-based images (`ImageSource::Asset`) are
12//! base64-encoded into an in-memory data URI before writing, so memory scales with individual
13//! asset size.
14//!
15//! # Supported Events
16//!
17//! - `StartDocument` / `EndDocument` — array start/end
18//! - `StartHeading` / `EndHeading` — heading blocks
19//! - `StartParagraph` / `EndParagraph` — paragraph blocks
20//! - `StartBlockQuote` / `EndBlockQuote` — quote blocks
21//! - `StartPreformatted` / `EndPreformatted` — code blocks
22//! - `StartTable` / `EndTable` — table blocks
23//! - `StartTableRow` / `EndTableRow` — table rows
24//! - `StartTableCell` / `EndTableCell` — table cells (data)
25//! - `StartTableHeader` / `EndTableHeader` — table cells (header, emitted identically to data cells)
26//! - `Text` — inline text content with bold/italic/code/strikethrough/underline styles
27//! - `Image` — image blocks
28//! - `LineBreak` / `SoftBreak` — line breaks within content blocks
29//! - `ThematicBreak` — divider blocks
30//! - `StartOrderedListItem` / `EndOrderedListItem` — `numberedListItem` blocks with optional `start` prop
31//! - `StartUnorderedListItem` / `EndUnorderedListItem` — `bulletListItem` blocks
32//!
33//! # Table Cell Content Semantics
34//!
35//! `BlockNote`'s `tableCell.content` is `InlineContent[]` — it cannot hold block-level types.
36//! `EVENTS.md` declares that `DocSpec` cells may contain any block element, so this writer
37//! flattens block-level events that appear inside a cell:
38//!
39//! - **Preserved**: [`Text`](docspec_core::Event::Text) (with all inline styles), [`LineBreak`](docspec_core::Event::LineBreak), [`SoftBreak`](docspec_core::Event::SoftBreak)
40//! - **Absorbed silently**: [`StartParagraph`](docspec_core::Event::StartParagraph) / [`EndParagraph`](docspec_core::Event::EndParagraph) (paragraph boundaries are dropped — adjacent paragraphs concatenate without separator)
41//! - **Dropped**: [`Image`](docspec_core::Event::Image), [`StartBlockQuote`](docspec_core::Event::StartBlockQuote), [`StartPreformatted`](docspec_core::Event::StartPreformatted), [`StartHeading`](docspec_core::Event::StartHeading), [`ThematicBreak`](docspec_core::Event::ThematicBreak), nested [`StartTable`](docspec_core::Event::StartTable) and their children — silently discarded
42//!
43//! Nested tables (a `StartTable` inside a cell) are entirely dropped: their rows, cells, text,
44//! and closing events are all absorbed. Only the outer table is emitted. The current markdown
45//! reader never produces multi-block cell content or nested tables — these guards exist for
46//! future DOCX/ODT readers.
47//!
48//! # List Support
49//!
50//! **Required**: wrap `BlockNoteWriter` in [`StackTrackingSink`](docspec_core::StackTrackingSink)
51//! before feeding list events. Raw list events without that wrapper are undefined behavior.
52//! `StackTrackingSink` auto-inserts `StartParagraph` inside list items, which is how the writer
53//! knows where each item's inline content begins.
54//!
55//! `DocSpec` list events translate to `BlockNote` block types as follows:
56//!
57//! - [`StartUnorderedListItem`](docspec_core::Event::StartUnorderedListItem) → `bulletListItem`
58//! - [`StartOrderedListItem`](docspec_core::Event::StartOrderedListItem) → `numberedListItem`
59//!   (the `start` field, when present on the first item, becomes the `start` prop)
60//!
61//! Nesting uses `BlockNote`'s native `children: Block[]` arrays. `DocSpec`'s `level: u32` field
62//! drives the nesting depth: a level increase opens a new `children` array; a level decrease
63//! closes the appropriate number of open items and children arrays.
64//!
65//! **Multi-paragraph items**: the first paragraph's inline content populates the list item's
66//! `content[]` array. Each subsequent paragraph becomes a child `paragraph` block inside the
67//! item's `children[]` array.
68//!
69//! **Non-paragraph blocks inside list items** (headings, images, code blocks, blockquotes,
70//! tables, thematic breaks) are dropped silently along with all of their inline contents —
71//! including any `Text` events nested within them. The drop applies anywhere inside a list
72//! item: both in the inline `content[]` slot (around the first paragraph) and after the item
73//! has transitioned to `children[]` (for multi-paragraph items or items containing nested
74//! lists). This differs from the table cell policy: cells preserve `Text` while absorbing
75//! block boundaries (so a heading inside a cell becomes plain text), whereas list items
76//! suppress text inside dropped blocks entirely.
77//!
78//! ## Container Interactions
79//!
80//! - **Inside a table cell**: list items are dropped entirely, consistent with other block-level
81//!   content inside cells.
82//! - **Inside a blockquote**: the blockquote is force-closed and the list item is emitted at the
83//!   top level as a sibling. This matches the existing sibling-emit behavior for headings and
84//!   images inside blockquotes.
85//!
86//! ## Out of Scope
87//!
88//! - **`checkListItem`**: requires upstream `DocSpec` event support not yet defined.
89//! - **`toggleListItem`**: no `DocSpec` event equivalent exists.
90//! - **Custom `style_type` markers**: `BlockNote`'s default schema has no equivalent field; the
91//!   `style_type` value from `StartOrderedListItem` / `StartUnorderedListItem` is silently dropped.
92//!
93//! # Example
94//!
95//! ```
96//! use docspec_blocknote_writer::BlockNoteWriter;
97//! use docspec_core::{Event, EventSink, ListStyleType, StackTrackingSink, TextStyle};
98//!
99//! let mut buf = Vec::<u8>::new();
100//! let mut writer = StackTrackingSink::new(BlockNoteWriter::new(&mut buf));
101//!
102//! writer.handle_event(Event::StartDocument { id: None, language: None, metadata: None })?;
103//!
104//! // Plain paragraph
105//! writer.handle_event(Event::StartParagraph { alignment: None, id: None })?;
106//! writer.handle_event(Event::Text {
107//!     content: "Hello".to_string(),
108//!     style: TextStyle::default(),
109//! })?;
110//! writer.handle_event(Event::EndParagraph)?;
111//!
112//! // Unordered list item (StackTrackingSink auto-inserts the paragraph)
113//! writer.handle_event(Event::StartUnorderedListItem {
114//!     id: None,
115//!     level: 0,
116//!     style_type: ListStyleType::Disc,
117//! })?;
118//! writer.handle_event(Event::Text {
119//!     content: "First bullet".to_string(),
120//!     style: TextStyle::default(),
121//! })?;
122//! writer.handle_event(Event::EndUnorderedListItem)?;
123//!
124//! // Ordered list item with explicit start number
125//! writer.handle_event(Event::StartOrderedListItem {
126//!     id: None,
127//!     level: 0,
128//!     start: Some(1),
129//!     style_type: ListStyleType::Decimal,
130//! })?;
131//! writer.handle_event(Event::Text {
132//!     content: "Step one".to_string(),
133//!     style: TextStyle::default(),
134//! })?;
135//! writer.handle_event(Event::EndOrderedListItem)?;
136//!
137//! writer.handle_event(Event::EndDocument)?;
138//! writer.finish()?;
139//!
140//! let json = String::from_utf8(buf)?;
141//! assert!(json.starts_with('['));
142//! # Ok::<(), Box<dyn std::error::Error>>(())
143//! ```
144//!
145//! [`EventSink`]: docspec_core::EventSink
146
147use std::io::Write;
148
149use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
150use base64::write::EncoderWriter as Base64Encoder;
151use docspec_core::{AssetProvider, Depth, Error, Event, EventSink, ImageSource, Result, TextStyle};
152use docspec_json::{JsonEmitter, Null, StrusonBackend};
153
154macro_rules! close_text_block {
155    ($writer:expr) => {{
156        $writer.close_open_link_if_any()?;
157        $writer.close_content_block()?;
158        $writer.context.in_text_block = false;
159        Ok(())
160    }};
161}
162
163macro_rules! return_if_table_cell {
164    ($writer:expr) => {
165        if $writer.context.in_table_cell {
166            return Ok(());
167        }
168    };
169}
170
171macro_rules! drop_block_in_list_start {
172    ($writer:expr) => {
173        if $writer.in_any_list_item() || $writer.drop_inside_list_depth.is_positive() {
174            $writer.drop_inside_list_depth.inc();
175            return Ok(());
176        }
177    };
178}
179
180macro_rules! drop_block_in_list_end {
181    ($writer:expr) => {
182        if $writer.drop_inside_list_depth.is_positive() {
183            $writer.drop_inside_list_depth.dec();
184            return Ok(());
185        }
186    };
187}
188
189/// Represents the kind of list (ordered or unordered).
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum ListKind {
192    /// Ordered list (numbered).
193    Ordered,
194    /// Unordered list (bulleted).
195    Unordered,
196}
197
198/// Represents a single entry in the list stack, tracking list nesting state.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200struct ListStackEntry {
201    /// Whether the children array for this list has been opened.
202    children_array_open: bool,
203    /// Whether the content array for this list has been opened.
204    content_array_open: bool,
205    /// Whether the first paragraph in this list item has been consumed.
206    first_paragraph_consumed: bool,
207    /// The kind of list (ordered or unordered).
208    kind: ListKind,
209    /// The nesting level of this list (0-based).
210    level: u32,
211}
212
213#[derive(Default)]
214struct BlockContext {
215    blockquote_has_content: bool,
216    in_table_cell: bool,
217    in_text_block: bool,
218}
219
220/// A streaming `BlockNote` JSON writer.
221///
222/// Writes JSON tokens directly to the underlying `Write` as events arrive using `docspec-json`.
223/// Implements [`EventSink`] for integration with the `DocSpec` pipeline.
224///
225/// Use [`BlockNoteWriter::with_assets`] to provide an [`AssetProvider`] for resolving
226/// embedded asset images as base64 data URIs.
227///
228/// # Type Parameters
229///
230/// * `W` - Any type implementing [`Write`]
231pub struct BlockNoteWriter<'a, W: Write> {
232    assets: Option<&'a dyn AssetProvider>,
233    blockquote_depth: Depth,
234    blockquote_force_closed_count: Depth,
235    context: BlockContext,
236    drop_inside_list_depth: Depth,
237    dropped_list_depth: Depth,
238    /// Whether the writer is currently inside an open link inline container.
239    in_link: bool,
240    json: JsonEmitter<StrusonBackend<W>>,
241    /// Whether at least one `StyledText` has been emitted into the current link's content array.
242    link_emitted_styled_text: bool,
243    list_stack: Vec<ListStackEntry>,
244    table_depth: Depth,
245}
246
247impl<'a, W: Write> BlockNoteWriter<'a, W> {
248    fn close_blockquote_for_sibling(&mut self) -> Result<()> {
249        self.close_open_link_if_any()?;
250        self.close_content_block()?;
251        self.blockquote_depth.dec();
252        self.blockquote_force_closed_count.inc();
253        self.context.in_text_block = self.blockquote_depth.is_positive();
254        Ok(())
255    }
256
257    fn close_content_block(&mut self) -> Result<()> {
258        self.json.close_array()?;
259        self.json.key("children").array(|_| Ok(()))?;
260        self.json.close_object()
261    }
262
263    fn close_current_list_item_object(&mut self) -> Result<()> {
264        let popped_entry = self.list_stack.pop();
265        if let Some(list_entry) = popped_entry {
266            if list_entry.content_array_open {
267                self.close_open_link_if_any()?;
268                self.json.close_array()?;
269            }
270            if list_entry.children_array_open {
271                self.json.close_array()?;
272            } else {
273                self.json.key("children").array(|_| Ok(()))?;
274            }
275            self.json.close_object()?;
276        }
277        Ok(())
278    }
279
280    fn close_for_block_sibling(&mut self) -> Result<()> {
281        if !self.list_stack.is_empty() {
282            self.close_open_list_items()?;
283        }
284        if self.blockquote_depth.is_positive() {
285            return self.close_blockquote_for_sibling();
286        }
287        if self.context.in_text_block {
288            self.close_open_link_if_any()?;
289            self.close_content_block()?;
290            self.context.in_text_block = false;
291        }
292        Ok(())
293    }
294
295    /// Defensive: close any open inline link before closing a surrounding block.
296    ///
297    /// Under the canonical reader + `StackTrackingSink` contract this is a no-op,
298    /// but it hardens the writer against direct API misuse where a block may end
299    /// while a link is still open.
300    fn close_open_link_if_any(&mut self) -> Result<()> {
301        if self.in_link {
302            self.handle_end_link()?;
303        }
304        Ok(())
305    }
306
307    fn close_open_list_items(&mut self) -> Result<()> {
308        while !self.list_stack.is_empty() {
309            self.close_current_list_item_object()?;
310        }
311        Ok(())
312    }
313
314    /// Resolves `asset_id` through the configured provider and encodes the asset bytes
315    /// as a `data:<content-type>;base64,…` URI.
316    ///
317    /// Returns `Err` if no `AssetProvider` is configured, the asset cannot be found,
318    /// or the underlying I/O fails while streaming the asset bytes through the
319    /// base64 encoder.
320    fn encode_asset_as_data_uri(&self, asset_id: &str) -> Result<String> {
321        let provider = self.assets.ok_or_else(|| Error::Other {
322            message: "no AssetProvider configured".to_string(),
323        })?;
324        let content_type = provider
325            .content_type(asset_id)
326            .ok_or_else(|| Error::Other {
327                message: format!("asset not found: {asset_id}"),
328            })?;
329        let prefix = format!("data:{content_type};base64,");
330        let mut data_uri = Vec::with_capacity(prefix.len());
331        data_uri.extend_from_slice(prefix.as_bytes());
332        {
333            let mut enc = Base64Encoder::new(&mut data_uri, &BASE64_STANDARD);
334            provider
335                .stream_to(asset_id, &mut enc)
336                .ok_or_else(|| Error::Other {
337                    message: format!("asset not found: {asset_id}"),
338                })?
339                .map_err(Error::from)?;
340            enc.finish().map_err(Error::from)?
341        };
342        String::from_utf8(data_uri).map_err(|e| Error::Other {
343            message: format!("base64 encoding produced invalid UTF-8: {e}"),
344        })
345    }
346
347    fn handle_blockquote(&mut self, id: Option<&String>) -> Result<()> {
348        self.json.open_object()?;
349        self.json.key("type").value("quote")?;
350        self.write_id(id)?;
351        self.json.key("content").open_array()?;
352        self.blockquote_depth.inc();
353        self.context.blockquote_has_content = false;
354        self.context.in_text_block = true;
355        Ok(())
356    }
357
358    fn handle_divider(&mut self, id: Option<&String>) -> Result<()> {
359        self.json.object(|j| {
360            j.key("type").value("divider")?;
361            if let Some(id_val) = id {
362                j.key("id").value(id_val.as_str())?;
363            }
364            Ok(())
365        })
366    }
367
368    /// Closes the current inline link object.
369    ///
370    /// If no `StyledText` was emitted into the link's `content` array, inserts an empty
371    /// `StyledText` (`{"type":"text","text":"","styles":{}}`) to satisfy the `BlockNote`
372    /// schema (links must have at least one content item).
373    fn handle_end_link(&mut self) -> Result<()> {
374        if !self.in_link {
375            return Ok(());
376        }
377        if !self.link_emitted_styled_text {
378            self.json.open_object()?;
379            self.json.key("type").value("text")?;
380            self.json.key("text").value("")?;
381            self.json.key("styles").open_object()?;
382            self.json.close_object()?;
383            self.json.close_object()?;
384        }
385        self.json.close_array()?;
386        self.json.close_object()?;
387        self.in_link = false;
388        self.link_emitted_styled_text = false;
389        Ok(())
390    }
391
392    fn handle_end_list_item(&mut self) -> Result<()> {
393        if self.dropped_list_depth.is_positive() {
394            self.dropped_list_depth.dec();
395            return Ok(());
396        }
397        if self.list_stack.is_empty() {
398            return Ok(());
399        }
400        self.close_current_list_item_object()
401    }
402
403    fn handle_end_paragraph(&mut self) -> Result<()> {
404        if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
405            return Ok(());
406        }
407        if !self.list_stack.is_empty()
408            && self
409                .list_stack
410                .last()
411                .is_some_and(|e| e.first_paragraph_consumed)
412            && self.context.in_text_block
413        {
414            self.close_open_link_if_any()?;
415            self.json.close_array()?;
416            self.json.key("children").array(|_| Ok(()))?;
417            self.json.close_object()?;
418            self.context.in_text_block = false;
419            return Ok(());
420        }
421        if self.in_list_item_content() {
422            if let Some(entry) = self.list_stack.last_mut() {
423                entry.first_paragraph_consumed = true;
424            }
425            return Ok(());
426        }
427        if self.blockquote_depth.is_positive()
428            || !self.context.in_text_block
429            || self.context.in_table_cell
430        {
431            return Ok(());
432        }
433        close_text_block!(self)
434    }
435
436    fn handle_end_table(&mut self) -> Result<()> {
437        drop_block_in_list_end!(self);
438        if self.table_depth.is_zero() {
439            return Ok(());
440        }
441        if self.table_depth.get() > 1 {
442            self.table_depth.dec();
443            return Ok(());
444        }
445        self.json.close_array()?;
446        self.json.close_object()?;
447        self.json.key("children").array(|_| Ok(()))?;
448        self.json.close_object()?;
449        self.table_depth.reset();
450        Ok(())
451    }
452
453    fn handle_end_table_cell(&mut self) -> Result<()> {
454        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
455            return Ok(());
456        }
457        self.close_open_link_if_any()?;
458        self.json.close_array()?;
459        self.json.close_object()?;
460        self.context.in_table_cell = false;
461        Ok(())
462    }
463
464    fn handle_end_table_row(&mut self) -> Result<()> {
465        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
466            return Ok(());
467        }
468        self.json.close_array()?;
469        self.json.close_object()
470    }
471
472    fn handle_heading(&mut self, level: u8, id: Option<&String>) -> Result<()> {
473        self.json.open_object()?;
474        self.json.key("type").value("heading")?;
475        self.write_id(id)?;
476        self.json.key("props").object(|j| {
477            j.key("level").value(level)?;
478            j.key("textAlignment").value("left")
479        })?;
480        self.json.key("content").open_array()?;
481        self.context.in_text_block = true;
482        Ok(())
483    }
484
485    fn handle_image(
486        &mut self,
487        source: ImageSource,
488        alt: Option<String>,
489        id: Option<&String>,
490    ) -> Result<()> {
491        if self.context.in_table_cell || self.drop_inside_list_depth.is_positive() {
492            return Ok(());
493        }
494        if self.in_any_list_item() {
495            return Ok(());
496        }
497        self.close_for_block_sibling()?;
498        let url = match source {
499            ImageSource::Uri { uri } => uri,
500            ImageSource::Asset { asset_id } => self.encode_asset_as_data_uri(&asset_id)?,
501            _ => return Ok(()),
502        };
503        let caption = alt.unwrap_or_default();
504
505        self.json.object(|j| {
506            if let Some(id_val) = id {
507                j.key("id").value(id_val.as_str())?;
508            }
509            j.key("type").value("image")?;
510            j.key("props").object(|p| {
511                p.key("url").value(url.as_str())?;
512                p.key("caption").value(caption.as_str())
513            })?;
514            j.key("content").value(Null)?;
515            j.key("children").array(|_| Ok(()))
516        })
517    }
518
519    fn handle_line_break(&mut self) -> Result<()> {
520        if self.drop_inside_list_depth.is_positive() {
521            return Ok(());
522        }
523        if (self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content())
524            && self.table_depth.get() <= 1
525        {
526            self.handle_text("\n", &TextStyle::default())
527        } else {
528            Ok(())
529        }
530    }
531
532    fn handle_paragraph(&mut self, id: Option<&String>) -> Result<()> {
533        // Inside a table cell, BlockNote's content type is InlineContent[] — block-level events are dropped.
534        if self.context.in_table_cell {
535            return Ok(());
536        }
537        // Paragraphs nested inside a dropped block must not mutate list state or emit JSON;
538        // they are absorbed along with the surrounding dropped block.
539        if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
540            return Ok(());
541        }
542        // Second and subsequent paragraphs inside a list item dispatch as child paragraph blocks
543        // in the item's children[] array (T11). Must be checked before in_list_item_content()
544        // because content_array_open may still be true when first_paragraph_consumed is set.
545        if !self.list_stack.is_empty()
546            && self
547                .list_stack
548                .last()
549                .is_some_and(|e| e.first_paragraph_consumed)
550        {
551            if self.list_stack.last().is_some_and(|e| e.content_array_open) {
552                self.json.close_array()?;
553                if let Some(e) = self.list_stack.last_mut() {
554                    e.content_array_open = false;
555                }
556            }
557            if !self
558                .list_stack
559                .last()
560                .is_some_and(|e| e.children_array_open)
561            {
562                self.json.key("children").open_array()?;
563                if let Some(e) = self.list_stack.last_mut() {
564                    e.children_array_open = true;
565                }
566            }
567            self.json.open_object()?;
568            self.json.key("type").value("paragraph")?;
569            self.json
570                .key("props")
571                .object(|j| j.key("textAlignment").value("left"))?;
572            self.json.key("content").open_array()?;
573            self.context.in_text_block = true;
574            return Ok(());
575        }
576        if self.in_list_item_content() {
577            return Ok(());
578        }
579        if !self.list_stack.is_empty() {
580            self.close_open_list_items()?;
581        }
582        if self.blockquote_depth.is_positive() {
583            if self.context.blockquote_has_content {
584                self.handle_text("\n\n", &TextStyle::default())?;
585            }
586            return Ok(());
587        }
588        self.json.open_object()?;
589        self.write_id(id)?;
590        self.json.key("type").value("paragraph")?;
591        self.json
592            .key("props")
593            .object(|j| j.key("textAlignment").value("left"))?;
594        self.json.key("content").open_array()?;
595        self.context.in_text_block = true;
596        Ok(())
597    }
598
599    fn handle_preformatted(&mut self, id: Option<&String>, syntax: Option<&String>) -> Result<()> {
600        self.json.open_object()?;
601        self.json.key("type").value("codeBlock")?;
602        self.write_id(id)?;
603        if let Some(lang) = syntax {
604            self.json
605                .key("props")
606                .object(|j| j.key("language").value(lang.as_str()))?;
607        }
608        self.json.key("content").open_array()?;
609        self.context.in_text_block = true;
610        Ok(())
611    }
612
613    /// Opens a `BlockNote` inline link object and its `content` array.
614    ///
615    /// Drops `title` and `id` — `BlockNote`'s inline link schema has no slot for these.
616    fn handle_start_link(&mut self, href: &str) -> Result<()> {
617        if self.drop_inside_list_depth.is_positive()
618            || self.dropped_list_depth.is_positive()
619            || (!self.context.in_text_block
620                && !self.context.in_table_cell
621                && !self.in_list_item_content())
622            || self.table_depth.get() > 1
623        {
624            return Ok(());
625        }
626        if self.in_link {
627            return Ok(());
628        }
629        if self.blockquote_depth.is_positive() {
630            self.context.blockquote_has_content = true;
631        }
632        self.json.open_object()?;
633        self.json.key("type").value("link")?;
634        self.json.key("href").value(href)?;
635        self.json.key("content").open_array()?;
636        self.in_link = true;
637        self.link_emitted_styled_text = false;
638        Ok(())
639    }
640
641    fn handle_start_list_item(
642        &mut self,
643        kind: ListKind,
644        id: Option<&String>,
645        level: u32,
646        start: Option<u64>,
647    ) -> Result<()> {
648        if self.context.in_table_cell
649            || self.table_depth.get() > 1
650            || self.drop_inside_list_depth.is_positive()
651        {
652            self.dropped_list_depth.inc();
653            return Ok(());
654        }
655        if self.blockquote_depth.is_positive() {
656            self.close_blockquote_for_sibling()?;
657        }
658        if self.list_stack.is_empty() {
659            self.close_for_block_sibling()?;
660            self.open_list_item_object(kind, id, level, start)?;
661            return Ok(());
662        }
663
664        let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
665
666        // Level-jump clamping: silently absorb invalid multi-level forward jumps from broken
667        // source documents by treating any skip-ahead as a single step beyond the current top.
668        let effective_level = if level > stack_top_level.saturating_add(1) {
669            stack_top_level.saturating_add(1)
670        } else {
671            level
672        };
673
674        if effective_level > stack_top_level {
675            self.open_current_list_item_children()?;
676            self.open_list_item_object(kind, id, effective_level, start)?;
677            return Ok(());
678        }
679
680        if effective_level == stack_top_level {
681            self.close_current_list_item_object()?;
682            if self.list_stack.is_empty() {
683                self.close_for_block_sibling()?;
684            }
685            self.open_list_item_object(kind, id, effective_level, start)?;
686            return Ok(());
687        }
688
689        // Level-down: pop stack entries until the top's level is strictly below effective_level
690        // (i.e., at effective_level - 1, the parent). Then open the new item as a sibling.
691        while let Some(top) = self.list_stack.last() {
692            if top.level < effective_level {
693                break;
694            }
695            self.close_current_list_item_object()?;
696        }
697        if self.list_stack.is_empty() {
698            self.close_for_block_sibling()?;
699        }
700        self.open_list_item_object(kind, id, effective_level, start)?;
701        Ok(())
702    }
703
704    fn handle_start_table(&mut self, id: Option<&String>) -> Result<()> {
705        drop_block_in_list_start!(self);
706        if self.table_depth.is_positive() {
707            self.table_depth.inc();
708            return Ok(());
709        }
710        self.close_for_block_sibling()?;
711        self.json.open_object()?;
712        self.json.key("type").value("table")?;
713        self.write_id(id)?;
714        self.json
715            .key("props")
716            .object(|p| p.key("textColor").value("default"))?;
717        self.json.key("content").open_object()?;
718        self.json.key("type").value("tableContent")?;
719        self.json.key("columnWidths").array(|_| Ok(()))?;
720        self.json.key("rows").open_array()?;
721        self.table_depth.inc();
722        self.context.in_text_block = false;
723        Ok(())
724    }
725
726    fn handle_start_table_row(&mut self, id: Option<&String>) -> Result<()> {
727        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
728            return Ok(());
729        }
730        self.json.open_object()?;
731        self.write_id(id)?;
732        self.json.key("cells").open_array()
733    }
734
735    fn handle_table_cell(&mut self, id: Option<&String>) -> Result<()> {
736        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
737            return Ok(());
738        }
739        self.json.open_object()?;
740        self.json.key("type").value("tableCell")?;
741        self.write_id(id)?;
742        self.json.key("props").object(|p| {
743            p.key("backgroundColor").value("default")?;
744            p.key("textColor").value("default")?;
745            p.key("textAlignment").value("left")
746        })?;
747        self.json.key("content").open_array()?;
748        self.context.in_table_cell = true;
749        self.context.in_text_block = false;
750        Ok(())
751    }
752
753    fn handle_text(&mut self, content: &str, style: &TextStyle) -> Result<()> {
754        if self.drop_inside_list_depth.is_positive()
755            || self.dropped_list_depth.is_positive()
756            || (!self.context.in_text_block
757                && !self.context.in_table_cell
758                && !self.in_list_item_content())
759            || self.table_depth.get() > 1
760        {
761            return Ok(());
762        }
763        if self.blockquote_depth.is_positive() {
764            self.context.blockquote_has_content = true;
765        }
766        self.json.object(|j| {
767            j.key("type").value("text")?;
768            j.key("text").value(content)?;
769            j.key("styles").object(|s| {
770                for (key, enabled) in [
771                    ("bold", style.bold),
772                    ("italic", style.italic),
773                    ("code", style.code),
774                    ("strike", style.strikethrough),
775                    ("underline", style.underline),
776                ] {
777                    if enabled {
778                        s.key(key).value(true)?;
779                    }
780                }
781                Ok(())
782            })
783        })?;
784        if self.in_link {
785            self.link_emitted_styled_text = true;
786        }
787        Ok(())
788    }
789
790    fn handle_text_event(&mut self, content: &str, style: &TextStyle) -> Result<()> {
791        // Auto-open paragraph for orphan text (e.g., text after image closed paragraph)
792        if !self.context.in_text_block
793            && self.blockquote_depth.is_zero()
794            && !self.context.in_table_cell
795            && !self.in_list_item_content()
796        {
797            self.handle_paragraph(None)?;
798        }
799        self.handle_text(content, style)
800    }
801
802    /// Returns true when any list item is currently open on the stack, regardless of whether
803    /// its `content[]` or `children[]` array is the active emission target. Drop trigger for
804    /// non-paragraph block events that must be suppressed anywhere inside a list item per the
805    /// module-level policy (headings, images, code blocks, blockquotes, tables, thematic
806    /// breaks). Broader than `in_list_item_content`, which is true only while `content[]` is
807    /// open — `in_any_list_item` also returns true after a multi-paragraph or nested-list
808    /// transition has moved emission into `children[]`.
809    fn in_any_list_item(&self) -> bool {
810        !self.list_stack.is_empty()
811    }
812
813    fn in_list_item_content(&self) -> bool {
814        self.list_stack
815            .last()
816            .is_some_and(|entry| entry.content_array_open)
817    }
818
819    /// Creates a new `BlockNoteWriter` that writes to the given writer.
820    ///
821    /// # Arguments
822    ///
823    /// * `writer` - The underlying writer to emit JSON to
824    #[inline]
825    #[must_use]
826    pub fn new(writer: W) -> Self {
827        Self {
828            assets: None,
829            blockquote_depth: Depth::default(),
830            blockquote_force_closed_count: Depth::default(),
831            context: BlockContext::default(),
832            drop_inside_list_depth: Depth::default(),
833            dropped_list_depth: Depth::default(),
834            in_link: false,
835            json: JsonEmitter::new(StrusonBackend::new(writer)),
836            link_emitted_styled_text: false,
837            list_stack: Vec::new(),
838            table_depth: Depth::default(),
839        }
840    }
841
842    fn open_current_list_item_children(&mut self) -> Result<()> {
843        let content_array_open = self
844            .list_stack
845            .last()
846            .is_some_and(|entry| entry.content_array_open);
847        if content_array_open {
848            self.json.close_array()?;
849            if let Some(entry) = self.list_stack.last_mut() {
850                entry.content_array_open = false;
851            }
852        }
853
854        let children_array_open = self
855            .list_stack
856            .last()
857            .is_some_and(|entry| entry.children_array_open);
858        if !children_array_open {
859            self.json.key("children").open_array()?;
860            if let Some(entry) = self.list_stack.last_mut() {
861                entry.children_array_open = true;
862            }
863        }
864        Ok(())
865    }
866
867    fn open_list_item_object(
868        &mut self,
869        kind: ListKind,
870        id: Option<&String>,
871        level: u32,
872        start: Option<u64>,
873    ) -> Result<()> {
874        self.json.open_object()?;
875        self.write_id(id)?;
876        let type_name = match kind {
877            ListKind::Ordered => "numberedListItem",
878            ListKind::Unordered => "bulletListItem",
879        };
880        self.json.key("type").value(type_name)?;
881        self.json.key("props").object(|j| {
882            j.key("backgroundColor").value("default")?;
883            j.key("textColor").value("default")?;
884            j.key("textAlignment").value("left")?;
885            if kind == ListKind::Ordered {
886                if let Some(start_value) = start {
887                    let start_prop = u32::try_from(start_value).map_err(|err| Error::Other {
888                        message: format!(
889                            "ordered list start value out of range: {start_value}: {err}"
890                        ),
891                    })?;
892                    j.key("start").value(start_prop)?;
893                }
894            }
895            Ok(())
896        })?;
897        self.json.key("content").open_array()?;
898        self.list_stack.push(ListStackEntry {
899            children_array_open: false,
900            content_array_open: true,
901            first_paragraph_consumed: false,
902            kind,
903            level,
904        });
905        Ok(())
906    }
907
908    /// Creates a new `BlockNoteWriter` with an [`AssetProvider`] for resolving embedded assets.
909    ///
910    /// When an [`Event::Image`] with [`ImageSource::Asset`] is encountered, the provider is called
911    /// to resolve the asset bytes. The bytes are base64-encoded and written as a data URI
912    /// (`data:{content_type};base64,{encoded}`) in the `BlockNote` JSON `url` field.
913    ///
914    /// # Arguments
915    ///
916    /// * `writer` - The underlying writer to emit JSON to
917    /// * `assets` - The asset provider for resolving embedded asset references
918    #[inline]
919    #[must_use]
920    pub fn with_assets(writer: W, assets: &'a dyn AssetProvider) -> Self {
921        Self {
922            assets: Some(assets),
923            blockquote_depth: Depth::default(),
924            blockquote_force_closed_count: Depth::default(),
925            context: BlockContext::default(),
926            drop_inside_list_depth: Depth::default(),
927            dropped_list_depth: Depth::default(),
928            in_link: false,
929            json: JsonEmitter::new(StrusonBackend::new(writer)),
930            link_emitted_styled_text: false,
931            list_stack: Vec::new(),
932            table_depth: Depth::default(),
933        }
934    }
935
936    fn write_id(&mut self, id: Option<&String>) -> Result<()> {
937        if let Some(id_val) = id {
938            self.json.key("id").value(id_val.as_str())?;
939        }
940        Ok(())
941    }
942}
943
944impl<W: Write> EventSink for BlockNoteWriter<'_, W> {
945    #[inline]
946    fn finish(self) -> Result<()> {
947        self.json.finish().map(|_| ())
948    }
949
950    #[inline]
951    fn handle_event(&mut self, event: Event) -> Result<()> {
952        match event {
953            Event::StartDocument { .. } => self.json.open_array(),
954            Event::EndDocument => {
955                while !self.list_stack.is_empty() {
956                    self.close_current_list_item_object()?;
957                }
958                self.json.close_array()
959            }
960            Event::StartHeading { level, id, .. } => {
961                return_if_table_cell!(self);
962                drop_block_in_list_start!(self);
963                self.close_for_block_sibling()?;
964                self.handle_heading(level, id.as_ref())
965            }
966            Event::EndHeading => {
967                drop_block_in_list_end!(self);
968                if !self.context.in_text_block {
969                    return Ok(());
970                }
971                close_text_block!(self)
972            }
973            Event::EndPreformatted => {
974                drop_block_in_list_end!(self);
975                return_if_table_cell!(self);
976                if !self.context.in_text_block {
977                    return Ok(());
978                }
979                close_text_block!(self)
980            }
981            Event::StartParagraph { id, .. } => self.handle_paragraph(id.as_ref()),
982            Event::EndParagraph => self.handle_end_paragraph(),
983            Event::StartBlockQuote { id, .. } => {
984                return_if_table_cell!(self);
985                drop_block_in_list_start!(self);
986                self.close_for_block_sibling()?;
987                self.handle_blockquote(id.as_ref())
988            }
989            Event::EndBlockQuote => {
990                drop_block_in_list_end!(self);
991                return_if_table_cell!(self);
992                if self.blockquote_force_closed_count.is_positive() {
993                    self.blockquote_force_closed_count.dec();
994                    return Ok(());
995                }
996                self.close_open_link_if_any()?;
997                self.close_content_block()?;
998                self.blockquote_depth.dec();
999                self.context.in_text_block = self.blockquote_depth.is_positive();
1000                Ok(())
1001            }
1002            Event::StartPreformatted { id, syntax, .. } => {
1003                return_if_table_cell!(self);
1004                drop_block_in_list_start!(self);
1005                self.close_for_block_sibling()?;
1006                self.handle_preformatted(id.as_ref(), syntax.as_ref())
1007            }
1008            Event::ThematicBreak { id, .. } => {
1009                return_if_table_cell!(self);
1010                if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1011                    return Ok(());
1012                }
1013                self.close_for_block_sibling()?;
1014                self.handle_divider(id.as_ref())
1015            }
1016            Event::Text { content, style, .. } => self.handle_text_event(&content, &style),
1017            Event::Image {
1018                source, alt, id, ..
1019            } => self.handle_image(source, alt, id.as_ref()),
1020            Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1021            Event::StartOrderedListItem {
1022                id, level, start, ..
1023            } => self.handle_start_list_item(ListKind::Ordered, id.as_ref(), level, start),
1024            Event::StartUnorderedListItem { id, level, .. } => {
1025                self.handle_start_list_item(ListKind::Unordered, id.as_ref(), level, None)
1026            }
1027            Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1028            Event::StartTable { id, .. } => self.handle_start_table(id.as_ref()),
1029            Event::EndTable => self.handle_end_table(),
1030            Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_ref()),
1031            Event::EndTableRow => self.handle_end_table_row(),
1032            Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1033                self.handle_table_cell(id.as_ref())
1034            }
1035            Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1036            Event::StartLink { href, .. } => self.handle_start_link(&href),
1037            Event::EndLink => self.handle_end_link(),
1038            Event::EndCaption
1039            | Event::EndDefinitionDetail
1040            | Event::EndDefinitionList
1041            | Event::EndDefinitionTerm
1042            | Event::EndFootnote
1043            | Event::FootnoteRef { .. }
1044            | Event::StartCaption { .. }
1045            | Event::StartDefinitionDetail { .. }
1046            | Event::StartDefinitionList { .. }
1047            | Event::StartDefinitionTerm { .. }
1048            | Event::StartFootnote { .. }
1049            | _ => Ok(()),
1050        }
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    #[test]
1059    fn list_stack_empty_after_new() {
1060        let mut buf = Vec::new();
1061        let writer = BlockNoteWriter::new(&mut buf);
1062        assert!(writer.list_stack.is_empty());
1063    }
1064
1065    #[test]
1066    fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1067        // Drives close_open_list_items call inside close_for_block_sibling (line 264).
1068        // After opening a list item, list_stack is non-empty; calling the private
1069        // method directly exercises the !list_stack.is_empty() branch.
1070        let mut buf = Vec::new();
1071        let mut writer = BlockNoteWriter::new(&mut buf);
1072        assert!(writer
1073            .handle_event(Event::StartDocument {
1074                id: None,
1075                language: None,
1076                metadata: None,
1077            })
1078            .is_ok());
1079        assert!(writer
1080            .handle_event(Event::StartUnorderedListItem {
1081                id: None,
1082                level: 0,
1083                style_type: docspec_core::ListStyleType::Disc,
1084            })
1085            .is_ok());
1086        assert!(
1087            !writer.list_stack.is_empty(),
1088            "list_stack must be non-empty before calling close_for_block_sibling"
1089        );
1090        assert!(writer.close_for_block_sibling().is_ok());
1091        assert!(
1092            writer.list_stack.is_empty(),
1093            "close_for_block_sibling must drain list_stack via close_open_list_items"
1094        );
1095        assert!(writer.handle_event(Event::EndDocument).is_ok());
1096        assert!(writer.finish().is_ok());
1097    }
1098}