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        };
502        let caption = alt.unwrap_or_default();
503
504        self.json.object(|j| {
505            if let Some(id_val) = id {
506                j.key("id").value(id_val.as_str())?;
507            }
508            j.key("type").value("image")?;
509            j.key("props").object(|p| {
510                p.key("url").value(url.as_str())?;
511                p.key("caption").value(caption.as_str())
512            })?;
513            j.key("content").value(Null)?;
514            j.key("children").array(|_| Ok(()))
515        })
516    }
517
518    fn handle_line_break(&mut self) -> Result<()> {
519        if self.drop_inside_list_depth.is_positive() {
520            return Ok(());
521        }
522        if (self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content())
523            && self.table_depth.get() <= 1
524        {
525            self.handle_text("\n", &TextStyle::default())
526        } else {
527            Ok(())
528        }
529    }
530
531    fn handle_paragraph(&mut self, id: Option<&String>) -> Result<()> {
532        // Inside a table cell, BlockNote's content type is InlineContent[] — block-level events are dropped.
533        if self.context.in_table_cell {
534            return Ok(());
535        }
536        // Paragraphs nested inside a dropped block must not mutate list state or emit JSON;
537        // they are absorbed along with the surrounding dropped block.
538        if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
539            return Ok(());
540        }
541        // Second and subsequent paragraphs inside a list item dispatch as child paragraph blocks
542        // in the item's children[] array (T11). Must be checked before in_list_item_content()
543        // because content_array_open may still be true when first_paragraph_consumed is set.
544        if !self.list_stack.is_empty()
545            && self
546                .list_stack
547                .last()
548                .is_some_and(|e| e.first_paragraph_consumed)
549        {
550            if self.list_stack.last().is_some_and(|e| e.content_array_open) {
551                self.json.close_array()?;
552                if let Some(e) = self.list_stack.last_mut() {
553                    e.content_array_open = false;
554                }
555            }
556            if !self
557                .list_stack
558                .last()
559                .is_some_and(|e| e.children_array_open)
560            {
561                self.json.key("children").open_array()?;
562                if let Some(e) = self.list_stack.last_mut() {
563                    e.children_array_open = true;
564                }
565            }
566            self.json.open_object()?;
567            self.json.key("type").value("paragraph")?;
568            self.json
569                .key("props")
570                .object(|j| j.key("textAlignment").value("left"))?;
571            self.json.key("content").open_array()?;
572            self.context.in_text_block = true;
573            return Ok(());
574        }
575        if self.in_list_item_content() {
576            return Ok(());
577        }
578        if !self.list_stack.is_empty() {
579            self.close_open_list_items()?;
580        }
581        if self.blockquote_depth.is_positive() {
582            if self.context.blockquote_has_content {
583                self.handle_text("\n\n", &TextStyle::default())?;
584            }
585            return Ok(());
586        }
587        self.json.open_object()?;
588        self.write_id(id)?;
589        self.json.key("type").value("paragraph")?;
590        self.json
591            .key("props")
592            .object(|j| j.key("textAlignment").value("left"))?;
593        self.json.key("content").open_array()?;
594        self.context.in_text_block = true;
595        Ok(())
596    }
597
598    fn handle_preformatted(&mut self, id: Option<&String>, syntax: Option<&String>) -> Result<()> {
599        self.json.open_object()?;
600        self.json.key("type").value("codeBlock")?;
601        self.write_id(id)?;
602        if let Some(lang) = syntax {
603            self.json
604                .key("props")
605                .object(|j| j.key("language").value(lang.as_str()))?;
606        }
607        self.json.key("content").open_array()?;
608        self.context.in_text_block = true;
609        Ok(())
610    }
611
612    /// Opens a `BlockNote` inline link object and its `content` array.
613    ///
614    /// Drops `title` and `id` — `BlockNote`'s inline link schema has no slot for these.
615    fn handle_start_link(&mut self, href: &str) -> Result<()> {
616        if self.drop_inside_list_depth.is_positive()
617            || self.dropped_list_depth.is_positive()
618            || (!self.context.in_text_block
619                && !self.context.in_table_cell
620                && !self.in_list_item_content())
621            || self.table_depth.get() > 1
622        {
623            return Ok(());
624        }
625        if self.in_link {
626            return Ok(());
627        }
628        if self.blockquote_depth.is_positive() {
629            self.context.blockquote_has_content = true;
630        }
631        self.json.open_object()?;
632        self.json.key("type").value("link")?;
633        self.json.key("href").value(href)?;
634        self.json.key("content").open_array()?;
635        self.in_link = true;
636        self.link_emitted_styled_text = false;
637        Ok(())
638    }
639
640    fn handle_start_list_item(
641        &mut self,
642        kind: ListKind,
643        id: Option<&String>,
644        level: u32,
645        start: Option<u64>,
646    ) -> Result<()> {
647        if self.context.in_table_cell
648            || self.table_depth.get() > 1
649            || self.drop_inside_list_depth.is_positive()
650        {
651            self.dropped_list_depth.inc();
652            return Ok(());
653        }
654        if self.blockquote_depth.is_positive() {
655            self.close_blockquote_for_sibling()?;
656        }
657        if self.list_stack.is_empty() {
658            self.close_for_block_sibling()?;
659            self.open_list_item_object(kind, id, level, start)?;
660            return Ok(());
661        }
662
663        let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
664
665        // Level-jump clamping: silently absorb invalid multi-level forward jumps from broken
666        // source documents by treating any skip-ahead as a single step beyond the current top.
667        let effective_level = if level > stack_top_level.saturating_add(1) {
668            stack_top_level.saturating_add(1)
669        } else {
670            level
671        };
672
673        if effective_level > stack_top_level {
674            self.open_current_list_item_children()?;
675            self.open_list_item_object(kind, id, effective_level, start)?;
676            return Ok(());
677        }
678
679        if effective_level == stack_top_level {
680            self.close_current_list_item_object()?;
681            if self.list_stack.is_empty() {
682                self.close_for_block_sibling()?;
683            }
684            self.open_list_item_object(kind, id, effective_level, start)?;
685            return Ok(());
686        }
687
688        // Level-down: pop stack entries until the top's level is strictly below effective_level
689        // (i.e., at effective_level - 1, the parent). Then open the new item as a sibling.
690        while let Some(top) = self.list_stack.last() {
691            if top.level < effective_level {
692                break;
693            }
694            self.close_current_list_item_object()?;
695        }
696        if self.list_stack.is_empty() {
697            self.close_for_block_sibling()?;
698        }
699        self.open_list_item_object(kind, id, effective_level, start)?;
700        Ok(())
701    }
702
703    fn handle_start_table(&mut self, id: Option<&String>) -> Result<()> {
704        drop_block_in_list_start!(self);
705        if self.table_depth.is_positive() {
706            self.table_depth.inc();
707            return Ok(());
708        }
709        self.close_for_block_sibling()?;
710        self.json.open_object()?;
711        self.json.key("type").value("table")?;
712        self.write_id(id)?;
713        self.json
714            .key("props")
715            .object(|p| p.key("textColor").value("default"))?;
716        self.json.key("content").open_object()?;
717        self.json.key("type").value("tableContent")?;
718        self.json.key("columnWidths").array(|_| Ok(()))?;
719        self.json.key("rows").open_array()?;
720        self.table_depth.inc();
721        self.context.in_text_block = false;
722        Ok(())
723    }
724
725    fn handle_start_table_row(&mut self, id: Option<&String>) -> Result<()> {
726        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
727            return Ok(());
728        }
729        self.json.open_object()?;
730        self.write_id(id)?;
731        self.json.key("cells").open_array()
732    }
733
734    fn handle_table_cell(&mut self, id: Option<&String>) -> Result<()> {
735        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
736            return Ok(());
737        }
738        self.json.open_object()?;
739        self.json.key("type").value("tableCell")?;
740        self.write_id(id)?;
741        self.json.key("props").object(|p| {
742            p.key("backgroundColor").value("default")?;
743            p.key("textColor").value("default")?;
744            p.key("textAlignment").value("left")
745        })?;
746        self.json.key("content").open_array()?;
747        self.context.in_table_cell = true;
748        self.context.in_text_block = false;
749        Ok(())
750    }
751
752    fn handle_text(&mut self, content: &str, style: &TextStyle) -> Result<()> {
753        if self.drop_inside_list_depth.is_positive()
754            || self.dropped_list_depth.is_positive()
755            || (!self.context.in_text_block
756                && !self.context.in_table_cell
757                && !self.in_list_item_content())
758            || self.table_depth.get() > 1
759        {
760            return Ok(());
761        }
762        if self.blockquote_depth.is_positive() {
763            self.context.blockquote_has_content = true;
764        }
765        self.json.object(|j| {
766            j.key("type").value("text")?;
767            j.key("text").value(content)?;
768            j.key("styles").object(|s| {
769                for (key, enabled) in [
770                    ("bold", style.bold),
771                    ("italic", style.italic),
772                    ("code", style.code),
773                    ("strike", style.strikethrough),
774                    ("underline", style.underline),
775                ] {
776                    if enabled {
777                        s.key(key).value(true)?;
778                    }
779                }
780                Ok(())
781            })
782        })?;
783        if self.in_link {
784            self.link_emitted_styled_text = true;
785        }
786        Ok(())
787    }
788
789    fn handle_text_event(&mut self, content: &str, style: &TextStyle) -> Result<()> {
790        // Auto-open paragraph for orphan text (e.g., text after image closed paragraph)
791        if !self.context.in_text_block
792            && self.blockquote_depth.is_zero()
793            && !self.context.in_table_cell
794            && !self.in_list_item_content()
795        {
796            self.handle_paragraph(None)?;
797        }
798        self.handle_text(content, style)
799    }
800
801    /// Returns true when any list item is currently open on the stack, regardless of whether
802    /// its `content[]` or `children[]` array is the active emission target. Drop trigger for
803    /// non-paragraph block events that must be suppressed anywhere inside a list item per the
804    /// module-level policy (headings, images, code blocks, blockquotes, tables, thematic
805    /// breaks). Broader than `in_list_item_content`, which is true only while `content[]` is
806    /// open — `in_any_list_item` also returns true after a multi-paragraph or nested-list
807    /// transition has moved emission into `children[]`.
808    fn in_any_list_item(&self) -> bool {
809        !self.list_stack.is_empty()
810    }
811
812    fn in_list_item_content(&self) -> bool {
813        self.list_stack
814            .last()
815            .is_some_and(|entry| entry.content_array_open)
816    }
817
818    /// Creates a new `BlockNoteWriter` that writes to the given writer.
819    ///
820    /// # Arguments
821    ///
822    /// * `writer` - The underlying writer to emit JSON to
823    #[inline]
824    #[must_use]
825    pub fn new(writer: W) -> Self {
826        Self {
827            assets: None,
828            blockquote_depth: Depth::default(),
829            blockquote_force_closed_count: Depth::default(),
830            context: BlockContext::default(),
831            drop_inside_list_depth: Depth::default(),
832            dropped_list_depth: Depth::default(),
833            in_link: false,
834            json: JsonEmitter::new(StrusonBackend::new(writer)),
835            link_emitted_styled_text: false,
836            list_stack: Vec::new(),
837            table_depth: Depth::default(),
838        }
839    }
840
841    fn open_current_list_item_children(&mut self) -> Result<()> {
842        let content_array_open = self
843            .list_stack
844            .last()
845            .is_some_and(|entry| entry.content_array_open);
846        if content_array_open {
847            self.json.close_array()?;
848            if let Some(entry) = self.list_stack.last_mut() {
849                entry.content_array_open = false;
850            }
851        }
852
853        let children_array_open = self
854            .list_stack
855            .last()
856            .is_some_and(|entry| entry.children_array_open);
857        if !children_array_open {
858            self.json.key("children").open_array()?;
859            if let Some(entry) = self.list_stack.last_mut() {
860                entry.children_array_open = true;
861            }
862        }
863        Ok(())
864    }
865
866    fn open_list_item_object(
867        &mut self,
868        kind: ListKind,
869        id: Option<&String>,
870        level: u32,
871        start: Option<u64>,
872    ) -> Result<()> {
873        self.json.open_object()?;
874        self.write_id(id)?;
875        let type_name = match kind {
876            ListKind::Ordered => "numberedListItem",
877            ListKind::Unordered => "bulletListItem",
878        };
879        self.json.key("type").value(type_name)?;
880        self.json.key("props").object(|j| {
881            j.key("backgroundColor").value("default")?;
882            j.key("textColor").value("default")?;
883            j.key("textAlignment").value("left")?;
884            if kind == ListKind::Ordered {
885                if let Some(start_value) = start {
886                    let start_prop = u32::try_from(start_value).map_err(|err| Error::Other {
887                        message: format!(
888                            "ordered list start value out of range: {start_value}: {err}"
889                        ),
890                    })?;
891                    j.key("start").value(start_prop)?;
892                }
893            }
894            Ok(())
895        })?;
896        self.json.key("content").open_array()?;
897        self.list_stack.push(ListStackEntry {
898            children_array_open: false,
899            content_array_open: true,
900            first_paragraph_consumed: false,
901            kind,
902            level,
903        });
904        Ok(())
905    }
906
907    /// Creates a new `BlockNoteWriter` with an [`AssetProvider`] for resolving embedded assets.
908    ///
909    /// When an [`Event::Image`] with [`ImageSource::Asset`] is encountered, the provider is called
910    /// to resolve the asset bytes. The bytes are base64-encoded and written as a data URI
911    /// (`data:{content_type};base64,{encoded}`) in the `BlockNote` JSON `url` field.
912    ///
913    /// # Arguments
914    ///
915    /// * `writer` - The underlying writer to emit JSON to
916    /// * `assets` - The asset provider for resolving embedded asset references
917    #[inline]
918    #[must_use]
919    pub fn with_assets(writer: W, assets: &'a dyn AssetProvider) -> Self {
920        Self {
921            assets: Some(assets),
922            blockquote_depth: Depth::default(),
923            blockquote_force_closed_count: Depth::default(),
924            context: BlockContext::default(),
925            drop_inside_list_depth: Depth::default(),
926            dropped_list_depth: Depth::default(),
927            in_link: false,
928            json: JsonEmitter::new(StrusonBackend::new(writer)),
929            link_emitted_styled_text: false,
930            list_stack: Vec::new(),
931            table_depth: Depth::default(),
932        }
933    }
934
935    fn write_id(&mut self, id: Option<&String>) -> Result<()> {
936        if let Some(id_val) = id {
937            self.json.key("id").value(id_val.as_str())?;
938        }
939        Ok(())
940    }
941}
942
943impl<W: Write> EventSink for BlockNoteWriter<'_, W> {
944    #[inline]
945    fn finish(self) -> Result<()> {
946        self.json.finish().map(|_| ())
947    }
948
949    #[inline]
950    fn handle_event(&mut self, event: Event) -> Result<()> {
951        match event {
952            Event::StartDocument { .. } => self.json.open_array(),
953            Event::EndDocument => {
954                while !self.list_stack.is_empty() {
955                    self.close_current_list_item_object()?;
956                }
957                self.json.close_array()
958            }
959            Event::StartHeading { level, id, .. } => {
960                return_if_table_cell!(self);
961                drop_block_in_list_start!(self);
962                self.close_for_block_sibling()?;
963                self.handle_heading(level, id.as_ref())
964            }
965            Event::EndHeading => {
966                drop_block_in_list_end!(self);
967                if !self.context.in_text_block {
968                    return Ok(());
969                }
970                close_text_block!(self)
971            }
972            Event::EndPreformatted => {
973                drop_block_in_list_end!(self);
974                return_if_table_cell!(self);
975                if !self.context.in_text_block {
976                    return Ok(());
977                }
978                close_text_block!(self)
979            }
980            Event::StartParagraph { id, .. } => self.handle_paragraph(id.as_ref()),
981            Event::EndParagraph => self.handle_end_paragraph(),
982            Event::StartBlockQuote { id, .. } => {
983                return_if_table_cell!(self);
984                drop_block_in_list_start!(self);
985                self.close_for_block_sibling()?;
986                self.handle_blockquote(id.as_ref())
987            }
988            Event::EndBlockQuote => {
989                drop_block_in_list_end!(self);
990                return_if_table_cell!(self);
991                if self.blockquote_force_closed_count.is_positive() {
992                    self.blockquote_force_closed_count.dec();
993                    return Ok(());
994                }
995                self.close_open_link_if_any()?;
996                self.close_content_block()?;
997                self.blockquote_depth.dec();
998                self.context.in_text_block = self.blockquote_depth.is_positive();
999                Ok(())
1000            }
1001            Event::StartPreformatted { id, syntax, .. } => {
1002                return_if_table_cell!(self);
1003                drop_block_in_list_start!(self);
1004                self.close_for_block_sibling()?;
1005                self.handle_preformatted(id.as_ref(), syntax.as_ref())
1006            }
1007            Event::ThematicBreak { id, .. } => {
1008                return_if_table_cell!(self);
1009                if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1010                    return Ok(());
1011                }
1012                self.close_for_block_sibling()?;
1013                self.handle_divider(id.as_ref())
1014            }
1015            Event::Text { content, style, .. } => self.handle_text_event(&content, &style),
1016            Event::Image {
1017                source, alt, id, ..
1018            } => self.handle_image(source, alt, id.as_ref()),
1019            Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1020            Event::StartOrderedListItem {
1021                id, level, start, ..
1022            } => self.handle_start_list_item(ListKind::Ordered, id.as_ref(), level, start),
1023            Event::StartUnorderedListItem { id, level, .. } => {
1024                self.handle_start_list_item(ListKind::Unordered, id.as_ref(), level, None)
1025            }
1026            Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1027            Event::StartTable { id, .. } => self.handle_start_table(id.as_ref()),
1028            Event::EndTable => self.handle_end_table(),
1029            Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_ref()),
1030            Event::EndTableRow => self.handle_end_table_row(),
1031            Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1032                self.handle_table_cell(id.as_ref())
1033            }
1034            Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1035            Event::StartLink { href, .. } => self.handle_start_link(&href),
1036            Event::EndLink => self.handle_end_link(),
1037            Event::EndCaption
1038            | Event::EndDefinitionDetail
1039            | Event::EndDefinitionList
1040            | Event::EndDefinitionTerm
1041            | Event::EndFootnote
1042            | Event::FootnoteRef { .. }
1043            | Event::StartCaption { .. }
1044            | Event::StartDefinitionDetail { .. }
1045            | Event::StartDefinitionList { .. }
1046            | Event::StartDefinitionTerm { .. }
1047            | Event::StartFootnote { .. }
1048            | _ => Ok(()),
1049        }
1050    }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056
1057    #[test]
1058    fn list_stack_empty_after_new() {
1059        let mut buf = Vec::new();
1060        let writer = BlockNoteWriter::new(&mut buf);
1061        assert!(writer.list_stack.is_empty());
1062    }
1063
1064    #[test]
1065    fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1066        // Drives close_open_list_items call inside close_for_block_sibling (line 264).
1067        // After opening a list item, list_stack is non-empty; calling the private
1068        // method directly exercises the !list_stack.is_empty() branch.
1069        let mut buf = Vec::new();
1070        let mut writer = BlockNoteWriter::new(&mut buf);
1071        assert!(writer
1072            .handle_event(Event::StartDocument {
1073                id: None,
1074                language: None,
1075                metadata: None,
1076            })
1077            .is_ok());
1078        assert!(writer
1079            .handle_event(Event::StartUnorderedListItem {
1080                id: None,
1081                level: 0,
1082                style_type: docspec_core::ListStyleType::Disc,
1083            })
1084            .is_ok());
1085        assert!(
1086            !writer.list_stack.is_empty(),
1087            "list_stack must be non-empty before calling close_for_block_sibling"
1088        );
1089        assert!(writer.close_for_block_sibling().is_ok());
1090        assert!(
1091            writer.list_stack.is_empty(),
1092            "close_for_block_sibling must drain list_stack via close_open_list_items"
1093        );
1094        assert!(writer.handle_event(Event::EndDocument).is_ok());
1095        assert!(writer.finish().is_ok());
1096    }
1097}