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::{
152    AssetProvider, Depth, Error, Event, EventSink, ImageSource, Result, TextAlignment, TextStyle,
153};
154use docspec_json::{JsonEmitter, Null, StrusonBackend};
155
156macro_rules! close_text_block {
157    ($writer:expr) => {{
158        $writer.close_open_link_if_any()?;
159        $writer.close_content_block()?;
160        $writer.context.in_text_block = false;
161        Ok(())
162    }};
163}
164
165macro_rules! return_if_table_cell {
166    ($writer:expr) => {
167        if $writer.context.in_table_cell {
168            return Ok(());
169        }
170    };
171}
172
173macro_rules! drop_block_in_list_start {
174    ($writer:expr) => {
175        if $writer.in_any_list_item() || $writer.drop_inside_list_depth.is_positive() {
176            $writer.drop_inside_list_depth.inc();
177            return Ok(());
178        }
179    };
180}
181
182macro_rules! drop_block_in_list_end {
183    ($writer:expr) => {
184        if $writer.drop_inside_list_depth.is_positive() {
185            $writer.drop_inside_list_depth.dec();
186            return Ok(());
187        }
188    };
189}
190
191/// Represents the kind of list (ordered or unordered).
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ListKind {
194    /// Ordered list (numbered).
195    Ordered,
196    /// Unordered list (bulleted).
197    Unordered,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201enum ListContentState {
202    Pending,
203    Open,
204    Closed,
205}
206
207/// Represents a single entry in the list stack, tracking list nesting state.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209struct ListStackEntry {
210    /// Whether the children array for this list has been opened.
211    children_array_open: bool,
212    /// Current state of this list item's content array.
213    content_state: ListContentState,
214    /// Whether the first paragraph in this list item has been consumed.
215    first_paragraph_consumed: bool,
216    /// The kind of list (ordered or unordered).
217    kind: ListKind,
218    /// The nesting level of this list (0-based).
219    level: u32,
220    /// Starting number for ordered list items.
221    start: Option<u32>,
222}
223
224#[derive(Default)]
225struct BlockContext {
226    blockquote_has_content: bool,
227    in_table_cell: bool,
228    in_text_block: bool,
229}
230
231fn non_default_alignment_value(alignment: Option<&TextAlignment>) -> Option<&'static str> {
232    match alignment {
233        Some(TextAlignment::Center) => Some("center"),
234        Some(TextAlignment::Right) => Some("right"),
235        Some(TextAlignment::Justify) => Some("justify"),
236        _ => None,
237    }
238}
239
240/// A streaming `BlockNote` JSON writer.
241///
242/// Writes JSON tokens directly to the underlying `Write` as events arrive using `docspec-json`.
243/// Implements [`EventSink`] for integration with the `DocSpec` pipeline.
244///
245/// Use [`BlockNoteWriter::with_assets`] to provide an [`AssetProvider`] for resolving
246/// embedded asset images as base64 data URIs.
247///
248/// # Type Parameters
249///
250/// * `W` - Any type implementing [`Write`]
251pub struct BlockNoteWriter<'a, W: Write> {
252    assets: Option<&'a dyn AssetProvider>,
253    blockquote_depth: Depth,
254    blockquote_force_closed_count: Depth,
255    context: BlockContext,
256    drop_inside_list_depth: Depth,
257    dropped_list_depth: Depth,
258    /// Whether the writer is currently inside an open link inline container.
259    in_link: bool,
260    json: JsonEmitter<StrusonBackend<W>>,
261    /// Whether at least one `StyledText` has been emitted into the current link's content array.
262    link_emitted_styled_text: bool,
263    list_stack: Vec<ListStackEntry>,
264    table_depth: Depth,
265}
266
267impl<'a, W: Write> BlockNoteWriter<'a, W> {
268    fn close_blockquote_for_sibling(&mut self) -> Result<()> {
269        self.close_open_link_if_any()?;
270        self.close_content_block()?;
271        self.blockquote_depth.dec();
272        self.blockquote_force_closed_count.inc();
273        self.context.in_text_block = self.blockquote_depth.is_positive();
274        Ok(())
275    }
276
277    fn close_content_block(&mut self) -> Result<()> {
278        self.json.close_array()?;
279        self.json.key("children").array(|_| Ok(()))?;
280        self.json.close_object()
281    }
282
283    fn close_current_list_item_object(&mut self) -> Result<()> {
284        if self
285            .list_stack
286            .last()
287            .is_some_and(|entry| entry.content_state == ListContentState::Pending)
288        {
289            self.initialize_current_list_item_content(None)?;
290        }
291        let popped_entry = self.list_stack.pop();
292        if let Some(list_entry) = popped_entry {
293            if list_entry.content_state == ListContentState::Open {
294                self.close_open_link_if_any()?;
295                self.json.close_array()?;
296            }
297            if list_entry.children_array_open {
298                self.json.close_array()?;
299            } else {
300                self.json.key("children").array(|_| Ok(()))?;
301            }
302            self.json.close_object()?;
303        }
304        Ok(())
305    }
306
307    fn close_for_block_sibling(&mut self) -> Result<()> {
308        if !self.list_stack.is_empty() {
309            self.close_open_list_items()?;
310        }
311        if self.blockquote_depth.is_positive() {
312            return self.close_blockquote_for_sibling();
313        }
314        if self.context.in_text_block {
315            self.close_open_link_if_any()?;
316            self.close_content_block()?;
317            self.context.in_text_block = false;
318        }
319        Ok(())
320    }
321
322    /// Defensive: close any open inline link before closing a surrounding block.
323    ///
324    /// Under the canonical reader + `StackTrackingSink` contract this is a no-op,
325    /// but it hardens the writer against direct API misuse where a block may end
326    /// while a link is still open.
327    fn close_open_link_if_any(&mut self) -> Result<()> {
328        if self.in_link {
329            self.handle_end_link()?;
330        }
331        Ok(())
332    }
333
334    fn close_open_list_items(&mut self) -> Result<()> {
335        while !self.list_stack.is_empty() {
336            self.close_current_list_item_object()?;
337        }
338        Ok(())
339    }
340
341    /// Resolves `asset_id` through the configured provider and encodes the asset bytes
342    /// as a `data:<content-type>;base64,…` URI.
343    ///
344    /// Returns `Err` if no `AssetProvider` is configured, the asset cannot be found,
345    /// or the underlying I/O fails while streaming the asset bytes through the
346    /// base64 encoder.
347    fn encode_asset_as_data_uri(&self, asset_id: &str) -> Result<String> {
348        let provider = self.assets.ok_or_else(|| Error::Other {
349            message: "no AssetProvider configured".to_string(),
350        })?;
351        let content_type = provider
352            .content_type(asset_id)
353            .ok_or_else(|| Error::Other {
354                message: format!("asset not found: {asset_id}"),
355            })?;
356        let prefix = format!("data:{content_type};base64,");
357        let mut data_uri = Vec::with_capacity(prefix.len());
358        data_uri.extend_from_slice(prefix.as_bytes());
359        {
360            let mut enc = Base64Encoder::new(&mut data_uri, &BASE64_STANDARD);
361            provider
362                .stream_to(asset_id, &mut enc)
363                .ok_or_else(|| Error::Other {
364                    message: format!("asset not found: {asset_id}"),
365                })?
366                .map_err(Error::from)?;
367            enc.finish().map_err(Error::from)?
368        };
369        String::from_utf8(data_uri).map_err(|e| Error::Other {
370            message: format!("base64 encoding produced invalid UTF-8: {e}"),
371        })
372    }
373
374    fn handle_blockquote(&mut self, id: Option<&String>) -> Result<()> {
375        self.json.open_object()?;
376        self.json.key("type").value("quote")?;
377        self.write_id(id)?;
378        self.json.key("content").open_array()?;
379        self.blockquote_depth.inc();
380        self.context.blockquote_has_content = false;
381        self.context.in_text_block = true;
382        Ok(())
383    }
384
385    fn handle_divider(&mut self, id: Option<&String>) -> Result<()> {
386        self.json.object(|j| {
387            j.key("type").value("divider")?;
388            if let Some(id_val) = id {
389                j.key("id").value(id_val.as_str())?;
390            }
391            Ok(())
392        })
393    }
394
395    /// Closes the current inline link object.
396    ///
397    /// If no `StyledText` was emitted into the link's `content` array, inserts an empty
398    /// `StyledText` (`{"type":"text","text":"","styles":{}}`) to satisfy the `BlockNote`
399    /// schema (links must have at least one content item).
400    fn handle_end_link(&mut self) -> Result<()> {
401        if !self.in_link {
402            return Ok(());
403        }
404        if !self.link_emitted_styled_text {
405            self.json.open_object()?;
406            self.json.key("type").value("text")?;
407            self.json.key("text").value("")?;
408            self.json.key("styles").open_object()?;
409            self.json.close_object()?;
410            self.json.close_object()?;
411        }
412        self.json.close_array()?;
413        self.json.close_object()?;
414        self.in_link = false;
415        self.link_emitted_styled_text = false;
416        Ok(())
417    }
418
419    fn handle_end_list_item(&mut self) -> Result<()> {
420        if self.dropped_list_depth.is_positive() {
421            self.dropped_list_depth.dec();
422            return Ok(());
423        }
424        if self.list_stack.is_empty() {
425            return Ok(());
426        }
427        self.close_current_list_item_object()
428    }
429
430    fn handle_end_paragraph(&mut self) -> Result<()> {
431        if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
432            return Ok(());
433        }
434        if !self.list_stack.is_empty()
435            && self
436                .list_stack
437                .last()
438                .is_some_and(|e| e.first_paragraph_consumed)
439            && self.context.in_text_block
440        {
441            self.close_open_link_if_any()?;
442            self.json.close_array()?;
443            self.json.key("children").array(|_| Ok(()))?;
444            self.json.close_object()?;
445            self.context.in_text_block = false;
446            return Ok(());
447        }
448        if self.in_list_item_content() {
449            if let Some(entry) = self.list_stack.last_mut() {
450                entry.first_paragraph_consumed = true;
451            }
452            return Ok(());
453        }
454        if self.blockquote_depth.is_positive()
455            || !self.context.in_text_block
456            || self.context.in_table_cell
457        {
458            return Ok(());
459        }
460        close_text_block!(self)
461    }
462
463    fn handle_end_table(&mut self) -> Result<()> {
464        drop_block_in_list_end!(self);
465        if self.table_depth.is_zero() {
466            return Ok(());
467        }
468        if self.table_depth.get() > 1 {
469            self.table_depth.dec();
470            return Ok(());
471        }
472        self.json.close_array()?;
473        self.json.close_object()?;
474        self.json.key("children").array(|_| Ok(()))?;
475        self.json.close_object()?;
476        self.table_depth.reset();
477        Ok(())
478    }
479
480    fn handle_end_table_cell(&mut self) -> Result<()> {
481        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
482            return Ok(());
483        }
484        self.close_open_link_if_any()?;
485        self.json.close_array()?;
486        self.json.close_object()?;
487        self.context.in_table_cell = false;
488        Ok(())
489    }
490
491    fn handle_end_table_row(&mut self) -> Result<()> {
492        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
493            return Ok(());
494        }
495        self.json.close_array()?;
496        self.json.close_object()
497    }
498
499    fn handle_heading(&mut self, level: u8, id: Option<&String>) -> Result<()> {
500        self.json.open_object()?;
501        self.json.key("type").value("heading")?;
502        self.write_id(id)?;
503        self.json
504            .key("props")
505            .object(|j| j.key("level").value(level))?;
506        self.json.key("content").open_array()?;
507        self.context.in_text_block = true;
508        Ok(())
509    }
510
511    fn handle_image(
512        &mut self,
513        source: ImageSource,
514        alt: Option<String>,
515        id: Option<&String>,
516    ) -> Result<()> {
517        if self.context.in_table_cell || self.drop_inside_list_depth.is_positive() {
518            return Ok(());
519        }
520        if self.in_any_list_item() {
521            return Ok(());
522        }
523        self.close_for_block_sibling()?;
524        let url = match source {
525            ImageSource::Uri { uri } => uri,
526            ImageSource::Asset { asset_id } => self.encode_asset_as_data_uri(&asset_id)?,
527            _ => return Ok(()),
528        };
529        let caption = alt.unwrap_or_default();
530
531        self.json.object(|j| {
532            if let Some(id_val) = id {
533                j.key("id").value(id_val.as_str())?;
534            }
535            j.key("type").value("image")?;
536            j.key("props").object(|p| {
537                p.key("url").value(url.as_str())?;
538                p.key("caption").value(caption.as_str())
539            })?;
540            j.key("content").value(Null)?;
541            j.key("children").array(|_| Ok(()))
542        })
543    }
544
545    fn handle_line_break(&mut self) -> Result<()> {
546        if self.drop_inside_list_depth.is_positive() {
547            return Ok(());
548        }
549        if (self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content())
550            && self.table_depth.get() <= 1
551        {
552            self.handle_text("\n", &TextStyle::default())
553        } else {
554            Ok(())
555        }
556    }
557
558    fn handle_paragraph(
559        &mut self,
560        id: Option<&String>,
561        alignment: Option<&TextAlignment>,
562    ) -> Result<()> {
563        // Inside a table cell, BlockNote's content type is InlineContent[] — block-level events are dropped.
564        if self.context.in_table_cell {
565            return Ok(());
566        }
567        // Paragraphs nested inside a dropped block must not mutate list state or emit JSON;
568        // they are absorbed along with the surrounding dropped block.
569        if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
570            return Ok(());
571        }
572        // Second and subsequent paragraphs inside a list item dispatch as child paragraph blocks
573        // in the item's children[] array (T11). Must be checked before in_list_item_content()
574        // because content may still be open when first_paragraph_consumed is set.
575        if !self.list_stack.is_empty()
576            && self
577                .list_stack
578                .last()
579                .is_some_and(|e| e.first_paragraph_consumed)
580        {
581            if self
582                .list_stack
583                .last()
584                .is_some_and(|e| e.content_state == ListContentState::Open)
585            {
586                self.json.close_array()?;
587                if let Some(e) = self.list_stack.last_mut() {
588                    e.content_state = ListContentState::Closed;
589                }
590            }
591            if !self
592                .list_stack
593                .last()
594                .is_some_and(|e| e.children_array_open)
595            {
596                self.json.key("children").open_array()?;
597                if let Some(e) = self.list_stack.last_mut() {
598                    e.children_array_open = true;
599                }
600            }
601            self.json.open_object()?;
602            self.json.key("type").value("paragraph")?;
603            self.write_paragraph_props(alignment)?;
604            self.json.key("content").open_array()?;
605            self.context.in_text_block = true;
606            return Ok(());
607        }
608        if !self.list_stack.is_empty() {
609            self.initialize_current_list_item_content(alignment)?;
610            return Ok(());
611        }
612        if self.blockquote_depth.is_positive() {
613            if self.context.blockquote_has_content {
614                self.handle_text("\n\n", &TextStyle::default())?;
615            }
616            return Ok(());
617        }
618        self.json.open_object()?;
619        self.write_id(id)?;
620        self.json.key("type").value("paragraph")?;
621        self.write_paragraph_props(alignment)?;
622        self.json.key("content").open_array()?;
623        self.context.in_text_block = true;
624        Ok(())
625    }
626
627    fn write_paragraph_props(&mut self, alignment: Option<&TextAlignment>) -> Result<()> {
628        if let Some(value) = non_default_alignment_value(alignment) {
629            self.json
630                .key("props")
631                .object(|j| j.key("textAlignment").value(value))?;
632        }
633        Ok(())
634    }
635
636    fn handle_preformatted(&mut self, id: Option<&String>, syntax: Option<&String>) -> Result<()> {
637        self.json.open_object()?;
638        self.json.key("type").value("codeBlock")?;
639        self.write_id(id)?;
640        if let Some(lang) = syntax {
641            self.json
642                .key("props")
643                .object(|j| j.key("language").value(lang.as_str()))?;
644        }
645        self.json.key("content").open_array()?;
646        self.context.in_text_block = true;
647        Ok(())
648    }
649
650    /// Opens a `BlockNote` inline link object and its `content` array.
651    ///
652    /// Drops `title` and `id` — `BlockNote`'s inline link schema has no slot for these.
653    fn handle_start_link(&mut self, href: &str) -> Result<()> {
654        if self.drop_inside_list_depth.is_positive()
655            || self.dropped_list_depth.is_positive()
656            || self.table_depth.get() > 1
657        {
658            return Ok(());
659        }
660        if self.list_stack.last().is_some_and(|entry| {
661            entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
662        }) {
663            self.initialize_current_list_item_content(None)?;
664        }
665        if !self.context.in_text_block
666            && !self.context.in_table_cell
667            && !self.in_list_item_content()
668        {
669            return Ok(());
670        }
671        if self.in_link {
672            return Ok(());
673        }
674        if self.blockquote_depth.is_positive() {
675            self.context.blockquote_has_content = true;
676        }
677        self.json.open_object()?;
678        self.json.key("type").value("link")?;
679        self.json.key("href").value(href)?;
680        self.json.key("content").open_array()?;
681        self.in_link = true;
682        self.link_emitted_styled_text = false;
683        Ok(())
684    }
685
686    fn handle_start_list_item(
687        &mut self,
688        kind: ListKind,
689        id: Option<&String>,
690        level: u32,
691        start: Option<u64>,
692    ) -> Result<()> {
693        if self.context.in_table_cell
694            || self.table_depth.get() > 1
695            || self.drop_inside_list_depth.is_positive()
696        {
697            self.dropped_list_depth.inc();
698            return Ok(());
699        }
700        if self.blockquote_depth.is_positive() {
701            self.close_blockquote_for_sibling()?;
702        }
703        if self.list_stack.is_empty() {
704            self.close_for_block_sibling()?;
705            self.open_list_item_object(kind, id, level, start)?;
706            return Ok(());
707        }
708
709        let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
710
711        // Level-jump clamping: silently absorb invalid multi-level forward jumps from broken
712        // source documents by treating any skip-ahead as a single step beyond the current top.
713        let effective_level = if level > stack_top_level.saturating_add(1) {
714            stack_top_level.saturating_add(1)
715        } else {
716            level
717        };
718
719        if effective_level > stack_top_level {
720            self.open_current_list_item_children()?;
721            self.open_list_item_object(kind, id, effective_level, start)?;
722            return Ok(());
723        }
724
725        if effective_level == stack_top_level {
726            self.close_current_list_item_object()?;
727            if self.list_stack.is_empty() {
728                self.close_for_block_sibling()?;
729            }
730            self.open_list_item_object(kind, id, effective_level, start)?;
731            return Ok(());
732        }
733
734        // Level-down: pop stack entries until the top's level is strictly below effective_level
735        // (i.e., at effective_level - 1, the parent). Then open the new item as a sibling.
736        while let Some(top) = self.list_stack.last() {
737            if top.level < effective_level {
738                break;
739            }
740            self.close_current_list_item_object()?;
741        }
742        if self.list_stack.is_empty() {
743            self.close_for_block_sibling()?;
744        }
745        self.open_list_item_object(kind, id, effective_level, start)?;
746        Ok(())
747    }
748
749    fn handle_start_table(&mut self, id: Option<&String>) -> Result<()> {
750        drop_block_in_list_start!(self);
751        if self.table_depth.is_positive() {
752            self.table_depth.inc();
753            return Ok(());
754        }
755        self.close_for_block_sibling()?;
756        self.json.open_object()?;
757        self.json.key("type").value("table")?;
758        self.write_id(id)?;
759        self.json.key("content").open_object()?;
760        self.json.key("type").value("tableContent")?;
761        self.json.key("columnWidths").array(|_| Ok(()))?;
762        self.json.key("rows").open_array()?;
763        self.table_depth.inc();
764        self.context.in_text_block = false;
765        Ok(())
766    }
767
768    fn handle_start_table_row(&mut self, id: Option<&String>) -> Result<()> {
769        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
770            return Ok(());
771        }
772        self.json.open_object()?;
773        self.write_id(id)?;
774        self.json.key("cells").open_array()
775    }
776
777    fn handle_table_cell(&mut self, id: Option<&String>) -> Result<()> {
778        if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
779            return Ok(());
780        }
781        self.json.open_object()?;
782        self.json.key("type").value("tableCell")?;
783        self.write_id(id)?;
784        self.json.key("content").open_array()?;
785        self.context.in_table_cell = true;
786        self.context.in_text_block = false;
787        Ok(())
788    }
789
790    fn handle_text(&mut self, content: &str, style: &TextStyle) -> Result<()> {
791        if self.drop_inside_list_depth.is_positive()
792            || self.dropped_list_depth.is_positive()
793            || (!self.context.in_text_block
794                && !self.context.in_table_cell
795                && !self.in_list_item_content())
796            || self.table_depth.get() > 1
797        {
798            return Ok(());
799        }
800        if self.blockquote_depth.is_positive() {
801            self.context.blockquote_has_content = true;
802        }
803        self.json.object(|j| {
804            j.key("type").value("text")?;
805            j.key("text").value(content)?;
806            j.key("styles").object(|s| {
807                for (key, enabled) in [
808                    ("bold", style.bold),
809                    ("italic", style.italic),
810                    ("code", style.code),
811                    ("strike", style.strikethrough),
812                    ("underline", style.underline),
813                ] {
814                    if enabled {
815                        s.key(key).value(true)?;
816                    }
817                }
818                Ok(())
819            })
820        })?;
821        if self.in_link {
822            self.link_emitted_styled_text = true;
823        }
824        Ok(())
825    }
826
827    fn handle_text_event(&mut self, content: &str, style: &TextStyle) -> Result<()> {
828        if self.drop_inside_list_depth.is_positive()
829            || self.dropped_list_depth.is_positive()
830            || self.table_depth.get() > 1
831        {
832            return Ok(());
833        }
834        // Auto-open paragraph for orphan text (e.g., text after image closed paragraph)
835        if self.list_stack.last().is_some_and(|entry| {
836            entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
837        }) {
838            self.initialize_current_list_item_content(None)?;
839        }
840        if !self.context.in_text_block
841            && self.blockquote_depth.is_zero()
842            && !self.context.in_table_cell
843            && !self.in_list_item_content()
844        {
845            self.handle_paragraph(None, None)?;
846        }
847        self.handle_text(content, style)
848    }
849
850    /// Returns true when any list item is currently open on the stack, regardless of whether
851    /// its `content[]` or `children[]` array is the active emission target. Drop trigger for
852    /// non-paragraph block events that must be suppressed anywhere inside a list item per the
853    /// module-level policy (headings, images, code blocks, blockquotes, tables, thematic
854    /// breaks). Broader than `in_list_item_content`, which is true only while `content[]` is
855    /// open — `in_any_list_item` also returns true after a multi-paragraph or nested-list
856    /// transition has moved emission into `children[]`.
857    fn in_any_list_item(&self) -> bool {
858        !self.list_stack.is_empty()
859    }
860
861    fn in_list_item_content(&self) -> bool {
862        self.list_stack
863            .last()
864            .is_some_and(|entry| entry.content_state == ListContentState::Open)
865    }
866
867    /// Creates a new `BlockNoteWriter` that writes to the given writer.
868    ///
869    /// # Arguments
870    ///
871    /// * `writer` - The underlying writer to emit JSON to
872    #[inline]
873    #[must_use]
874    pub fn new(writer: W) -> Self {
875        Self {
876            assets: None,
877            blockquote_depth: Depth::default(),
878            blockquote_force_closed_count: Depth::default(),
879            context: BlockContext::default(),
880            drop_inside_list_depth: Depth::default(),
881            dropped_list_depth: Depth::default(),
882            in_link: false,
883            json: JsonEmitter::new(StrusonBackend::new(writer)),
884            link_emitted_styled_text: false,
885            list_stack: Vec::new(),
886            table_depth: Depth::default(),
887        }
888    }
889
890    fn initialize_current_list_item_content(
891        &mut self,
892        alignment: Option<&TextAlignment>,
893    ) -> Result<()> {
894        let Some(current_entry) = self.list_stack.last() else {
895            return Ok(());
896        };
897        if current_entry.content_state != ListContentState::Pending {
898            return Ok(());
899        }
900        let kind = current_entry.kind;
901        let start = current_entry.start;
902        let alignment_value = non_default_alignment_value(alignment);
903        if alignment_value.is_some() || (kind == ListKind::Ordered && start.is_some()) {
904            self.json.key("props").object(|j| {
905                if let Some(value) = alignment_value {
906                    j.key("textAlignment").value(value)?;
907                }
908                if kind == ListKind::Ordered {
909                    if let Some(start_prop) = start {
910                        j.key("start").value(start_prop)?;
911                    }
912                }
913                Ok(())
914            })?;
915        }
916        self.json.key("content").open_array()?;
917        if let Some(entry) = self.list_stack.last_mut() {
918            entry.content_state = ListContentState::Open;
919        }
920        Ok(())
921    }
922
923    fn open_current_list_item_children(&mut self) -> Result<()> {
924        if self
925            .list_stack
926            .last()
927            .is_some_and(|entry| entry.content_state == ListContentState::Pending)
928        {
929            self.initialize_current_list_item_content(None)?;
930        }
931        let content_array_open = self
932            .list_stack
933            .last()
934            .is_some_and(|entry| entry.content_state == ListContentState::Open);
935        if content_array_open {
936            self.json.close_array()?;
937            if let Some(entry) = self.list_stack.last_mut() {
938                entry.content_state = ListContentState::Closed;
939                entry.first_paragraph_consumed = true;
940            }
941        }
942
943        let children_array_open = self
944            .list_stack
945            .last()
946            .is_some_and(|entry| entry.children_array_open);
947        if !children_array_open {
948            self.json.key("children").open_array()?;
949            if let Some(entry) = self.list_stack.last_mut() {
950                entry.children_array_open = true;
951            }
952        }
953        Ok(())
954    }
955
956    fn open_list_item_object(
957        &mut self,
958        kind: ListKind,
959        id: Option<&String>,
960        level: u32,
961        start: Option<u64>,
962    ) -> Result<()> {
963        self.json.open_object()?;
964        self.write_id(id)?;
965        let type_name = match kind {
966            ListKind::Ordered => "numberedListItem",
967            ListKind::Unordered => "bulletListItem",
968        };
969        self.json.key("type").value(type_name)?;
970        let checked_start = start
971            .map(|start_value| {
972                u32::try_from(start_value).map_err(|err| Error::Other {
973                    message: format!("ordered list start value out of range: {start_value}: {err}"),
974                })
975            })
976            .transpose()?;
977        self.list_stack.push(ListStackEntry {
978            children_array_open: false,
979            content_state: ListContentState::Pending,
980            first_paragraph_consumed: false,
981            kind,
982            level,
983            start: checked_start,
984        });
985        Ok(())
986    }
987
988    /// Creates a new `BlockNoteWriter` with an [`AssetProvider`] for resolving embedded assets.
989    ///
990    /// When an [`Event::Image`] with [`ImageSource::Asset`] is encountered, the provider is called
991    /// to resolve the asset bytes. The bytes are base64-encoded and written as a data URI
992    /// (`data:{content_type};base64,{encoded}`) in the `BlockNote` JSON `url` field.
993    ///
994    /// # Arguments
995    ///
996    /// * `writer` - The underlying writer to emit JSON to
997    /// * `assets` - The asset provider for resolving embedded asset references
998    #[inline]
999    #[must_use]
1000    pub fn with_assets(writer: W, assets: &'a dyn AssetProvider) -> Self {
1001        Self {
1002            assets: Some(assets),
1003            blockquote_depth: Depth::default(),
1004            blockquote_force_closed_count: Depth::default(),
1005            context: BlockContext::default(),
1006            drop_inside_list_depth: Depth::default(),
1007            dropped_list_depth: Depth::default(),
1008            in_link: false,
1009            json: JsonEmitter::new(StrusonBackend::new(writer)),
1010            link_emitted_styled_text: false,
1011            list_stack: Vec::new(),
1012            table_depth: Depth::default(),
1013        }
1014    }
1015
1016    fn handle_end_document(&mut self) -> Result<()> {
1017        while !self.list_stack.is_empty() {
1018            self.close_current_list_item_object()?;
1019        }
1020        self.json.close_array()
1021    }
1022
1023    fn write_id(&mut self, id: Option<&String>) -> Result<()> {
1024        if let Some(id_val) = id {
1025            self.json.key("id").value(id_val.as_str())?;
1026        }
1027        Ok(())
1028    }
1029}
1030
1031impl<W: Write> EventSink for BlockNoteWriter<'_, W> {
1032    #[inline]
1033    fn finish(self) -> Result<()> {
1034        self.json.finish().map(|_| ())
1035    }
1036
1037    #[inline]
1038    fn handle_event(&mut self, event: Event) -> Result<()> {
1039        match event {
1040            Event::StartDocument { .. } => self.json.open_array(),
1041            Event::EndDocument => self.handle_end_document(),
1042            Event::StartHeading { level, id, .. } => {
1043                return_if_table_cell!(self);
1044                drop_block_in_list_start!(self);
1045                self.close_for_block_sibling()?;
1046                self.handle_heading(level, id.as_ref())
1047            }
1048            Event::EndHeading => {
1049                drop_block_in_list_end!(self);
1050                if !self.context.in_text_block {
1051                    return Ok(());
1052                }
1053                close_text_block!(self)
1054            }
1055            Event::EndPreformatted => {
1056                drop_block_in_list_end!(self);
1057                return_if_table_cell!(self);
1058                if !self.context.in_text_block {
1059                    return Ok(());
1060                }
1061                close_text_block!(self)
1062            }
1063            Event::StartParagraph { alignment, id } => {
1064                self.handle_paragraph(id.as_ref(), alignment.as_ref())
1065            }
1066            Event::EndParagraph => self.handle_end_paragraph(),
1067            Event::StartBlockQuote { id, .. } => {
1068                return_if_table_cell!(self);
1069                drop_block_in_list_start!(self);
1070                self.close_for_block_sibling()?;
1071                self.handle_blockquote(id.as_ref())
1072            }
1073            Event::EndBlockQuote => {
1074                drop_block_in_list_end!(self);
1075                return_if_table_cell!(self);
1076                if self.blockquote_force_closed_count.is_positive() {
1077                    self.blockquote_force_closed_count.dec();
1078                    return Ok(());
1079                }
1080                self.close_open_link_if_any()?;
1081                self.close_content_block()?;
1082                self.blockquote_depth.dec();
1083                self.context.in_text_block = self.blockquote_depth.is_positive();
1084                Ok(())
1085            }
1086            Event::StartPreformatted { id, syntax, .. } => {
1087                return_if_table_cell!(self);
1088                drop_block_in_list_start!(self);
1089                self.close_for_block_sibling()?;
1090                self.handle_preformatted(id.as_ref(), syntax.as_ref())
1091            }
1092            Event::ThematicBreak { id, .. } => {
1093                return_if_table_cell!(self);
1094                if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1095                    return Ok(());
1096                }
1097                self.close_for_block_sibling()?;
1098                self.handle_divider(id.as_ref())
1099            }
1100            Event::Text { content, style, .. } => self.handle_text_event(&content, &style),
1101            Event::Image {
1102                source, alt, id, ..
1103            } => self.handle_image(source, alt, id.as_ref()),
1104            Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1105            Event::StartOrderedListItem {
1106                id, level, start, ..
1107            } => self.handle_start_list_item(ListKind::Ordered, id.as_ref(), level, start),
1108            Event::StartUnorderedListItem { id, level, .. } => {
1109                self.handle_start_list_item(ListKind::Unordered, id.as_ref(), level, None)
1110            }
1111            Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1112            Event::StartTable { id, .. } => self.handle_start_table(id.as_ref()),
1113            Event::EndTable => self.handle_end_table(),
1114            Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_ref()),
1115            Event::EndTableRow => self.handle_end_table_row(),
1116            Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1117                self.handle_table_cell(id.as_ref())
1118            }
1119            Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1120            Event::StartLink { href, .. } => self.handle_start_link(&href),
1121            Event::EndLink => self.handle_end_link(),
1122            Event::EndCaption
1123            | Event::EndDefinitionDetail
1124            | Event::EndDefinitionList
1125            | Event::EndDefinitionTerm
1126            | Event::EndFootnote
1127            | Event::FootnoteRef { .. }
1128            | Event::StartCaption { .. }
1129            | Event::StartDefinitionDetail { .. }
1130            | Event::StartDefinitionList { .. }
1131            | Event::StartDefinitionTerm { .. }
1132            | Event::StartFootnote { .. }
1133            | _ => Ok(()),
1134        }
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    #[test]
1143    fn list_stack_empty_after_new() {
1144        let mut buf = Vec::new();
1145        let writer = BlockNoteWriter::new(&mut buf);
1146        assert!(writer.list_stack.is_empty());
1147    }
1148
1149    #[test]
1150    fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1151        // Drives close_open_list_items call inside close_for_block_sibling (line 264).
1152        // After opening a list item, list_stack is non-empty; calling the private
1153        // method directly exercises the !list_stack.is_empty() branch.
1154        let mut buf = Vec::new();
1155        let mut writer = BlockNoteWriter::new(&mut buf);
1156        assert!(writer
1157            .handle_event(Event::StartDocument {
1158                id: None,
1159                language: None,
1160                metadata: None,
1161            })
1162            .is_ok());
1163        assert!(writer
1164            .handle_event(Event::StartUnorderedListItem {
1165                id: None,
1166                level: 0,
1167                style_type: docspec_core::ListStyleType::Disc,
1168            })
1169            .is_ok());
1170        assert!(
1171            !writer.list_stack.is_empty(),
1172            "list_stack must be non-empty before calling close_for_block_sibling"
1173        );
1174        assert!(writer.close_for_block_sibling().is_ok());
1175        assert!(
1176            writer.list_stack.is_empty(),
1177            "close_for_block_sibling must drain list_stack via close_open_list_items"
1178        );
1179        assert!(writer.handle_event(Event::EndDocument).is_ok());
1180        assert!(writer.finish().is_ok());
1181    }
1182}