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