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