Skip to main content

docspec_blocknote_writer/
lib.rs

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