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