Skip to main content

docspec_blocknote_writer/
lib.rs

1//! `DocSpec` event stream to `BlockNote` JSON writer.
2//!
3//! This crate provides a streaming [`BlockNoteWriter`] that implements [`EventSink`] to convert
4//! `DocSpec` event streams into `BlockNote` JSON format. `BlockNote` is a block-based rich text
5//! editor format.
6//!
7//! # Design
8//!
9//! The writer emits JSON tokens directly to the underlying `Write` as events arrive using
10//! `docspec-json` for streaming JSON output. For text and URI-based images, memory usage is
11//! constant regardless of document size. Local buffering is used only for bounded conversion
12//! details such as asset-based image data URI encoding and lifting nested table event substreams
13//! after their enclosing table closes.
14//!
15//! # Supported Events
16//!
17//! - `StartDocument` / `EndDocument` — array start/end
18//! - `StartHeading` / `EndHeading` — heading blocks
19//! - `StartParagraph` / `EndParagraph` — paragraph blocks
20//! - `StartBlockQuote` / `EndBlockQuote` — quote blocks
21//! - `StartPreformatted` / `EndPreformatted` — code blocks
22//! - `StartTable` / `EndTable` — table blocks
23//! - `StartTableRow` / `EndTableRow` — table rows
24//! - `StartTableCell` / `EndTableCell` — table cells (data)
25//! - `StartTableHeader` / `EndTableHeader` — table cells (header, emitted identically to data cells)
26//! - `StartTextStyle` / `EndTextStyle` — inline style spans
27//! - `Text` — inline text content styled by currently open style spans
28//! - `Image` — image blocks
29//! - `LineBreak` / `SoftBreak` — line breaks within content blocks
30//! - `ThematicBreak` — divider blocks
31//! - `StartOrderedListItem` / `EndOrderedListItem` — `numberedListItem` blocks with optional `start` prop
32//! - `StartUnorderedListItem` / `EndUnorderedListItem` — `bulletListItem` blocks
33//!
34//! # Table Cell Content Semantics
35//!
36//! `BlockNote`'s `tableCell.content` is `InlineContent[]` — it cannot hold block-level types.
37//! The [`docspec_core::event`] well-formedness rules declare that `DocSpec` cells may contain any
38//! block element, so this writer flattens block-level events that appear inside a cell:
39//!
40//! - **Preserved**: [`StartTextStyle`](docspec_core::Event::StartTextStyle) / [`EndTextStyle`](docspec_core::Event::EndTextStyle), [`Text`](docspec_core::Event::Text) (with currently open inline styles), [`LineBreak`](docspec_core::Event::LineBreak), [`SoftBreak`](docspec_core::Event::SoftBreak)
41//! - **Absorbed silently**: [`StartParagraph`](docspec_core::Event::StartParagraph) / [`EndParagraph`](docspec_core::Event::EndParagraph) (paragraph boundaries are dropped — adjacent paragraphs concatenate without separator)
42//! - **Dropped**: [`StartBlockQuote`](docspec_core::Event::StartBlockQuote), [`StartPreformatted`](docspec_core::Event::StartPreformatted), [`StartHeading`](docspec_core::Event::StartHeading), [`ThematicBreak`](docspec_core::Event::ThematicBreak) — silently discarded
43//! - **Lifted**: nested [`StartTable`](docspec_core::Event::StartTable) and its children, and [`Image`](docspec_core::Event::Image) events that appear inside a cell — buffered and replayed as top-level sibling blocks after the enclosing outermost table closes
44//!
45//! Nested tables (a `StartTable` inside a cell) are buffered between the first nested
46//! `StartTable` and its matching `EndTable`, then replayed through the writer after the
47//! enclosing outermost table closes. Each lifted nested table emits as a top-level sibling
48//! block in document order. The buffer empties on every outer `EndTable`, so nesting any
49//! number of levels deep collapses to a flat top-level sequence: `A` containing `B`
50//! containing `C` emits as `A, B, C`. Inline text adjacent to a nested table in the same
51//! outer cell stays in that outer cell.
52//!
53//! Images encountered inside a cell are likewise buffered and replayed as top-level sibling
54//! blocks after the enclosing outermost table closes. Their order in the output mirrors the
55//! order they appeared inside the table (interleaved with any lifted nested tables in
56//! document order). Inline text adjacent to a lifted image in the same cell stays in that
57//! cell — only the image moves out. Images inside a nested cell lift through the recursive
58//! drain: they emit as siblings of the (already-lifted) nested table.
59//!
60//! # List Support
61//!
62//! **Required**: wrap `BlockNoteWriter` in [`StackTrackingSink`](docspec_core::StackTrackingSink)
63//! before feeding list events. Raw list events without that wrapper are undefined behavior.
64//! `StackTrackingSink` auto-inserts `StartParagraph` inside list items, which is how the writer
65//! knows where each item's inline content begins.
66//!
67//! `DocSpec` list events translate to `BlockNote` block types as follows:
68//!
69//! - [`StartUnorderedListItem`](docspec_core::Event::StartUnorderedListItem) → `bulletListItem`
70//! - [`StartOrderedListItem`](docspec_core::Event::StartOrderedListItem) → `numberedListItem`
71//!   (the `start` field, when present on the first item, becomes the `start` prop)
72//!
73//! Nesting uses `BlockNote`'s native `children: Block[]` arrays. `DocSpec`'s `level: u32` field
74//! drives the nesting depth: a level increase opens a new `children` array; a level decrease
75//! closes the appropriate number of open items and children arrays.
76//!
77//! **Multi-paragraph items**: the first paragraph's inline content populates the list item's
78//! `content[]` array. Each subsequent paragraph becomes a child `paragraph` block inside the
79//! item's `children[]` array.
80//!
81//! **Non-paragraph blocks inside list items** (headings, images, code blocks, blockquotes,
82//! tables, thematic breaks) are dropped silently along with all of their inline contents —
83//! including any `Text` events nested within them. The drop applies anywhere inside a list
84//! item: both in the inline `content[]` slot (around the first paragraph) and after the item
85//! has transitioned to `children[]` (for multi-paragraph items or items containing nested
86//! lists). This differs from the table cell policy: cells preserve `Text` while absorbing
87//! block boundaries (so a heading inside a cell becomes plain text), whereas list items
88//! suppress text inside dropped blocks entirely.
89//!
90//! ## Container Interactions
91//!
92//! - **Inside a table cell**: list items are dropped entirely, consistent with other block-level
93//!   content inside cells.
94//! - **Inside a blockquote**: the blockquote is force-closed and the list item is emitted at the
95//!   top level as a sibling. This matches the existing sibling-emit behavior for headings and
96//!   images inside blockquotes.
97//!
98//! ## Out of Scope
99//!
100//! - **`checkListItem`**: requires upstream `DocSpec` event support not yet defined.
101//! - **`toggleListItem`**: no `DocSpec` event equivalent exists.
102//! - **Custom `style_type` markers**: `BlockNote`'s default schema has no equivalent field; the
103//!   `style_type` value from `StartOrderedListItem` / `StartUnorderedListItem` is silently dropped.
104//!
105//! # Example
106//!
107//! ```
108//! use docspec_blocknote_writer::BlockNoteWriter;
109//! use docspec_core::{Event, EventSink, ListStyleType, StackTrackingSink};
110//!
111//! let mut buf = Vec::<u8>::new();
112//! let mut writer = StackTrackingSink::new(BlockNoteWriter::new(&mut buf));
113//!
114//! writer.handle_event(Event::StartDocument { id: None, language: None, metadata: None })?;
115//!
116//! // Plain paragraph
117//! writer.handle_event(Event::StartParagraph { alignment: None, id: None })?;
118//! writer.handle_event(Event::Text {
119//!     content: "Hello".to_string(),
120//! })?;
121//! writer.handle_event(Event::EndParagraph)?;
122//!
123//! // Unordered list item (StackTrackingSink auto-inserts the paragraph)
124//! writer.handle_event(Event::StartUnorderedListItem {
125//!     id: None,
126//!     level: 0,
127//!     style_type: ListStyleType::Disc,
128//! })?;
129//! writer.handle_event(Event::Text {
130//!     content: "First bullet".to_string(),
131//! })?;
132//! writer.handle_event(Event::EndUnorderedListItem)?;
133//!
134//! // Ordered list item with explicit start number
135//! writer.handle_event(Event::StartOrderedListItem {
136//!     id: None,
137//!     level: 0,
138//!     start: Some(1),
139//!     style_type: ListStyleType::Decimal,
140//! })?;
141//! writer.handle_event(Event::Text {
142//!     content: "Step one".to_string(),
143//! })?;
144//! writer.handle_event(Event::EndOrderedListItem)?;
145//!
146//! writer.handle_event(Event::EndDocument)?;
147//! writer.finish()?;
148//!
149//! let json = String::from_utf8(buf)?;
150//! assert!(json.starts_with('['));
151//! # Ok::<(), Box<dyn std::error::Error>>(())
152//! ```
153//!
154//! [`EventSink`]: docspec_core::EventSink
155
156pub mod palette;
157
158use std::io::Write;
159
160use docspec_core::{
161    BlockKind, Depth, Error, Event, EventSink, ImageSource, Result, TextAlignment, TextStyleKind,
162};
163use docspec_json::{JsonEmitter, Null, StrusonBackend};
164
165macro_rules! close_text_block {
166    ($writer:expr) => {{
167        $writer.close_open_link_if_any()?;
168        $writer.close_content_block()?;
169        $writer.context.in_text_block = false;
170        Ok(())
171    }};
172}
173
174/// Represents the kind of list (ordered or unordered).
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum ListKind {
177    /// Ordered list (numbered).
178    Ordered,
179    /// Unordered list (bulleted).
180    Unordered,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum ListContentState {
185    Pending,
186    Open,
187    Closed,
188}
189
190#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
191enum BlockquoteContentState {
192    /// Quote is open but no content has been emitted yet.
193    /// The writer has NOT yet decided whether to open `"content": [` or `"children": [`.
194    #[default]
195    Pending,
196    /// `"content": [` is open and we're emitting inline content into it.
197    InContent,
198    /// `"content": []` has been written and closed; `"children": [` is open
199    /// and we're emitting block children into it.
200    InChildren,
201}
202
203#[derive(Debug)]
204enum DrainDestination {
205    DocumentRoot,
206    Ancestor {
207        ancestor_index: usize,
208        kind: BlockKind,
209    },
210}
211
212/// Block-level events that are leaves (no Start/End counterpart) in the `DocSpec` stream.
213#[derive(Copy, Clone, Debug, Eq, PartialEq)]
214enum BlockLeafKind {
215    Image,
216    Divider,
217}
218
219/// Represents a potential child block kind for the `blocknote_accepts_child` predicate.
220#[derive(Copy, Clone, Debug, Eq, PartialEq)]
221enum ChildKind {
222    Container(BlockKind),
223    Leaf(BlockLeafKind),
224}
225
226/// Returns true iff `BlockNote`'s default schema allows `child` to appear inside `parent`.
227/// Captures the schema constraint, NOT writer policy (drop/lift is the policy layer).
228fn blocknote_accepts_child(parent: Option<BlockKind>, child: ChildKind) -> bool {
229    match parent {
230        None => true,
231        Some(
232            BlockKind::Blockquote
233            | BlockKind::Heading
234            | BlockKind::OrderedListItem
235            | BlockKind::UnorderedListItem,
236        ) => !matches!(
237            child,
238            ChildKind::Container(
239                BlockKind::TableRow | BlockKind::TableCell | BlockKind::TableHeader
240            )
241        ),
242        Some(_) => false,
243    }
244}
245
246const _: BlockLeafKind = BlockLeafKind::Image;
247const _: BlockLeafKind = BlockLeafKind::Divider;
248const _: ChildKind = ChildKind::Container(BlockKind::Paragraph);
249const _: ChildKind = ChildKind::Leaf(BlockLeafKind::Image);
250const _: fn(Option<BlockKind>, ChildKind) -> bool = blocknote_accepts_child;
251
252/// Represents a single entry in the list stack, tracking list nesting state.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254struct ListStackEntry {
255    /// Whether the children array for this list has been opened.
256    children_array_open: bool,
257    /// Current state of this list item's content array.
258    content_state: ListContentState,
259    /// Whether the first paragraph in this list item has been consumed.
260    first_paragraph_consumed: bool,
261    /// The kind of list (ordered or unordered).
262    kind: ListKind,
263    /// The nesting level of this list (0-based).
264    level: u32,
265    /// Starting number for ordered list items.
266    start: Option<u32>,
267}
268
269#[derive(Default)]
270struct BlockContext {
271    blockquote_has_content: bool,
272    in_table_cell: bool,
273    in_text_block: bool,
274}
275
276fn non_default_alignment_value(alignment: Option<&TextAlignment>) -> Option<&'static str> {
277    match alignment {
278        Some(TextAlignment::Center) => Some("center"),
279        Some(TextAlignment::Right) => Some("right"),
280        Some(TextAlignment::Justify) => Some("justify"),
281        _ => None,
282    }
283}
284
285/// A streaming `BlockNote` JSON writer.
286///
287/// Writes JSON tokens directly to the underlying `Write` as events arrive using `docspec-json`.
288/// Implements [`EventSink`] for integration with the `DocSpec` pipeline.
289///
290/// # Type Parameters
291///
292/// * `W` - Any type implementing [`Write`]
293pub struct BlockNoteWriter<W: Write> {
294    blockquote_depth: Depth,
295    /// Per-blockquote content state stack. One entry is pushed on each `StartBlockQuote` and
296    /// popped on the matching `EndBlockQuote`. Tracks whether the current blockquote has opened
297    /// its `"content": [` array (inline content), its `"children": [` array (block children),
298    /// or neither yet (pending).
299    blockquote_content_states: Vec<BlockquoteContentState>,
300    context: BlockContext,
301    /// Whether the writer is currently inside an open link inline container.
302    in_link: bool,
303    json: JsonEmitter<StrusonBackend<W>>,
304    /// Whether at least one `StyledText` has been emitted into the current link's content array.
305    link_emitted_styled_text: bool,
306    /// Events deferred for replay at the outermost `EndTable` as top-level siblings:
307    /// nested-table substreams, in-cell `Image` events, and block-level events started
308    /// inside a table cell. All are illegal inside `BlockNote`'s `tableCell.content`;
309    /// co-buffering preserves their document order.
310    lifted_nested_events: Vec<Event>,
311    /// Tracks nesting depth of non-table block-level subtrees currently being buffered
312    /// for lift (e.g. a heading or blockquote that started inside a table cell). Increments
313    /// on each non-table block-level start event while buffering; decrements on the
314    /// matching end. Zero means we are not currently inside such a subtree.
315    lifted_subtree_depth: Depth,
316    list_stack: Vec<ListStackEntry>,
317    /// Ordered stack of open block containers in emission order.
318    /// Pushed when an outer container is opened in the EMITTED output (not while buffering).
319    /// Used to compute the nearest valid drain destination for lifted events.
320    /// Contains: `Blockquote`, `OrderedListItem`, `UnorderedListItem`, `Table` (tables listed for
321    /// completeness but never serve as drain destinations per predicate).
322    open_block_ancestors: Vec<BlockKind>,
323    open_styles: Vec<TextStyleKind>,
324    table_depth: Depth,
325}
326
327impl<W: Write> BlockNoteWriter<W> {
328    fn close_content_block(&mut self) -> Result<()> {
329        self.json.close_array()?;
330        self.json.key("children").array(|_| Ok(()))?;
331        self.json.close_object()
332    }
333
334    fn close_current_list_item_object(&mut self) -> Result<()> {
335        if self
336            .list_stack
337            .last()
338            .is_some_and(|entry| entry.content_state == ListContentState::Pending)
339        {
340            self.initialize_current_list_item_content(None)?;
341        }
342        let popped_entry = self.list_stack.pop();
343        if let Some(list_entry) = popped_entry {
344            let ancestor_kind = match list_entry.kind {
345                ListKind::Ordered => BlockKind::OrderedListItem,
346                ListKind::Unordered => BlockKind::UnorderedListItem,
347            };
348            if list_entry.content_state == ListContentState::Open {
349                self.close_open_link_if_any()?;
350                self.json.close_array()?;
351            }
352            if list_entry.children_array_open {
353                self.json.close_array()?;
354            } else {
355                self.json.key("children").array(|_| Ok(()))?;
356            }
357            self.json.close_object()?;
358            self.pop_ancestor(ancestor_kind);
359        }
360        Ok(())
361    }
362
363    fn close_for_block_sibling(&mut self) -> Result<()> {
364        if !self.list_stack.is_empty() {
365            self.close_open_list_items()?;
366        }
367        if self.blockquote_depth.is_positive() {
368            return self.prepare_blockquote_children();
369        }
370        if self.context.in_text_block {
371            self.close_open_link_if_any()?;
372            self.close_content_block()?;
373            self.context.in_text_block = false;
374        }
375        Ok(())
376    }
377
378    /// Defensive: close any open inline link before closing a surrounding block.
379    ///
380    /// Under the canonical reader + `StackTrackingSink` contract this is a no-op,
381    /// but it hardens the writer against direct API misuse where a block may end
382    /// while a link is still open.
383    fn close_open_link_if_any(&mut self) -> Result<()> {
384        if self.in_link {
385            self.handle_end_link()?;
386        }
387        Ok(())
388    }
389
390    fn close_open_list_items(&mut self) -> Result<()> {
391        while !self.list_stack.is_empty() {
392            self.close_current_list_item_object()?;
393        }
394        Ok(())
395    }
396
397    fn handle_blockquote(&mut self, id: Option<&str>) -> Result<()> {
398        self.json.open_object()?;
399        self.json.key("type").value("quote")?;
400        self.write_id(id)?;
401        self.blockquote_depth.inc();
402        self.blockquote_content_states
403            .push(BlockquoteContentState::Pending);
404        self.open_block_ancestors.push(BlockKind::Blockquote);
405        self.context.blockquote_has_content = false;
406        Ok(())
407    }
408
409    fn prepare_blockquote_inline_content(&mut self) -> Result<()> {
410        if self.blockquote_content_states.last() == Some(&BlockquoteContentState::Pending) {
411            self.json.key("content").open_array()?;
412            if let Some(state) = self.blockquote_content_states.last_mut() {
413                *state = BlockquoteContentState::InContent;
414            }
415            self.context.in_text_block = true;
416        }
417        Ok(())
418    }
419
420    fn prepare_blockquote_children(&mut self) -> Result<()> {
421        match self.blockquote_content_states.last().copied() {
422            Some(BlockquoteContentState::Pending) => {
423                self.json.key("content").open_array()?;
424                self.json.close_array()?;
425                self.json.key("children").open_array()?;
426                if let Some(state) = self.blockquote_content_states.last_mut() {
427                    *state = BlockquoteContentState::InChildren;
428                }
429            }
430            Some(BlockquoteContentState::InContent) => {
431                self.close_open_link_if_any()?;
432                self.json.close_array()?;
433                self.context.in_text_block = false;
434                self.json.key("children").open_array()?;
435                if let Some(state) = self.blockquote_content_states.last_mut() {
436                    *state = BlockquoteContentState::InChildren;
437                }
438            }
439            Some(BlockquoteContentState::InChildren) | None => {}
440        }
441        Ok(())
442    }
443
444    fn handle_divider(&mut self, id: Option<&str>) -> Result<()> {
445        self.json.object(|j| {
446            j.key("type").value("divider")?;
447            if let Some(id_val) = id {
448                j.key("id").value(id_val)?;
449            }
450            Ok(())
451        })
452    }
453
454    /// Closes the current inline link object.
455    ///
456    /// If no `StyledText` was emitted into the link's `content` array, inserts an empty
457    /// `StyledText` (`{"type":"text","text":"","styles":{}}`) to satisfy the `BlockNote`
458    /// schema (links must have at least one content item).
459    fn handle_end_link(&mut self) -> Result<()> {
460        if !self.in_link {
461            return Ok(());
462        }
463        if !self.link_emitted_styled_text {
464            self.json.open_object()?;
465            self.json.key("type").value("text")?;
466            self.json.key("text").value("")?;
467            self.json.key("styles").open_object()?;
468            self.json.close_object()?;
469            self.json.close_object()?;
470        }
471        self.json.close_array()?;
472        self.json.close_object()?;
473        self.in_link = false;
474        self.link_emitted_styled_text = false;
475        Ok(())
476    }
477
478    fn handle_end_list_item(&mut self) -> Result<()> {
479        if self.list_stack.is_empty() {
480            return Ok(());
481        }
482        self.close_current_list_item_object()
483    }
484
485    fn handle_end_paragraph(&mut self) -> Result<()> {
486        let in_current_blockquote_list_item =
487            self.blockquote_depth.is_positive() && self.current_blockquote_contains_list_item();
488        if !self.list_stack.is_empty()
489            && (self.blockquote_depth.is_zero() || in_current_blockquote_list_item)
490            && self
491                .list_stack
492                .last()
493                .is_some_and(|e| e.first_paragraph_consumed)
494            && self.context.in_text_block
495        {
496            self.close_open_link_if_any()?;
497            self.json.close_array()?;
498            self.json.key("children").array(|_| Ok(()))?;
499            self.json.close_object()?;
500            self.context.in_text_block = false;
501            return Ok(());
502        }
503        if self.blockquote_depth.is_positive()
504            && self.context.in_text_block
505            && self
506                .blockquote_content_states
507                .last()
508                .is_some_and(|state| *state == BlockquoteContentState::InChildren)
509        {
510            return close_text_block!(self);
511        }
512        if self.in_list_item_content() {
513            if let Some(entry) = self.list_stack.last_mut() {
514                entry.first_paragraph_consumed = true;
515            }
516            return Ok(());
517        }
518        if self.blockquote_depth.is_positive()
519            || !self.context.in_text_block
520            || self.context.in_table_cell
521        {
522            return Ok(());
523        }
524        close_text_block!(self)
525    }
526
527    fn handle_end_table(&mut self) -> Result<()> {
528        if self.table_depth.is_zero() {
529            return Ok(());
530        }
531        self.json.close_array()?;
532        self.json.close_object()?;
533        self.json.key("children").array(|_| Ok(()))?;
534        self.json.close_object()?;
535        self.pop_ancestor(BlockKind::Table);
536        self.table_depth.dec();
537        Ok(())
538    }
539
540    fn handle_end_table_cell(&mut self) -> Result<()> {
541        self.close_open_link_if_any()?;
542        self.json.close_array()?;
543        self.json.close_object()?;
544        self.context.in_table_cell = false;
545        Ok(())
546    }
547
548    fn handle_end_table_row(&mut self) -> Result<()> {
549        self.json.close_array()?;
550        self.json.close_object()
551    }
552
553    fn handle_end_blockquote(&mut self) -> Result<()> {
554        if self.blockquote_depth.is_zero() {
555            return Ok(());
556        }
557        let state = self.blockquote_content_states.pop().unwrap_or_default();
558        match state {
559            BlockquoteContentState::Pending => {
560                self.json.key("content").open_array()?;
561                self.json.close_array()?;
562                self.json.key("children").open_array()?;
563                self.json.close_array()?;
564            }
565            BlockquoteContentState::InContent => {
566                self.close_open_link_if_any()?;
567                self.json.close_array()?;
568                self.context.in_text_block = false;
569                self.json.key("children").open_array()?;
570                self.json.close_array()?;
571            }
572            BlockquoteContentState::InChildren => {
573                self.json.close_array()?;
574            }
575        }
576        self.pop_ancestor(BlockKind::Blockquote);
577        self.blockquote_depth.dec();
578        self.context.in_text_block = self
579            .blockquote_content_states
580            .last()
581            .is_some_and(|s| *s == BlockquoteContentState::InContent);
582        self.json.close_object()?;
583        Ok(())
584    }
585
586    fn handle_heading(&mut self, level: u8, id: Option<&str>) -> Result<()> {
587        self.json.open_object()?;
588        self.json.key("type").value("heading")?;
589        self.write_id(id)?;
590        self.json
591            .key("props")
592            .object(|j| j.key("level").value(level))?;
593        self.json.key("content").open_array()?;
594        self.context.in_text_block = true;
595        Ok(())
596    }
597
598    fn handle_image(
599        &mut self,
600        source: ImageSource,
601        alt: Option<String>,
602        id: Option<&str>,
603    ) -> Result<()> {
604        self.prepare_for_child_block()?;
605        let caption = alt.unwrap_or_default();
606
607        match source {
608            ImageSource::Uri { uri } => self.json.object(|j| {
609                if let Some(id_val) = id {
610                    j.key("id").value(id_val)?;
611                }
612                j.key("type").value("image")?;
613                j.key("props").object(|p| {
614                    p.key("url").value(uri.as_str())?;
615                    p.key("caption").value(caption.as_str())
616                })?;
617                j.key("content").value(Null)?;
618                j.key("children").array(|_| Ok(()))
619            }),
620            ImageSource::Asset(handle) => {
621                let content_type = handle
622                    .content_type()
623                    .ok_or_else(|| Error::Other {
624                        message: format!("asset not found: {}", handle.asset_id()),
625                    })?
626                    .into_owned();
627
628                self.json.object(|j| {
629                    if let Some(id_val) = id {
630                        j.key("id").value(id_val)?;
631                    }
632                    j.key("type").value("image")?;
633                    j.key("props").object(|p| {
634                        p.key("url").string_value_streaming(|w| {
635                            write!(w, "data:{content_type};base64,")?;
636                            let mut enc = base64::write::EncoderWriter::new(
637                                w,
638                                &base64::engine::general_purpose::STANDARD,
639                            );
640                            handle.stream_to(&mut enc)?;
641                            enc.finish()?;
642                            Ok(())
643                        })?;
644                        p.key("caption").value(caption.as_str())
645                    })?;
646                    j.key("content").value(Null)?;
647                    j.key("children").array(|_| Ok(()))
648                })
649            }
650            _ => Ok(()),
651        }
652    }
653
654    fn handle_line_break(&mut self) -> Result<()> {
655        if self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content() {
656            self.handle_text("\n")
657        } else {
658            Ok(())
659        }
660    }
661
662    fn handle_paragraph(
663        &mut self,
664        id: Option<&str>,
665        alignment: Option<&TextAlignment>,
666    ) -> Result<()> {
667        // Inside a table cell, BlockNote's content type is InlineContent[] — block-level events are dropped.
668        if self.context.in_table_cell {
669            return Ok(());
670        }
671        let in_current_blockquote_list_item =
672            self.blockquote_depth.is_positive() && self.current_blockquote_contains_list_item();
673        // Second and subsequent paragraphs inside a list item dispatch as child paragraph blocks
674        // in the item's children[] array (T11). Must be checked before in_list_item_content()
675        // because content may still be open when first_paragraph_consumed is set.
676        if !self.list_stack.is_empty()
677            && (self.blockquote_depth.is_zero() || in_current_blockquote_list_item)
678            && self
679                .list_stack
680                .last()
681                .is_some_and(|e| e.first_paragraph_consumed)
682        {
683            if self
684                .list_stack
685                .last()
686                .is_some_and(|e| e.content_state == ListContentState::Open)
687            {
688                self.json.close_array()?;
689                if let Some(e) = self.list_stack.last_mut() {
690                    e.content_state = ListContentState::Closed;
691                }
692            }
693            if !self
694                .list_stack
695                .last()
696                .is_some_and(|e| e.children_array_open)
697            {
698                self.json.key("children").open_array()?;
699                if let Some(e) = self.list_stack.last_mut() {
700                    e.children_array_open = true;
701                }
702            }
703            self.json.open_object()?;
704            self.json.key("type").value("paragraph")?;
705            self.write_paragraph_props(alignment)?;
706            self.json.key("content").open_array()?;
707            self.context.in_text_block = true;
708            return Ok(());
709        }
710        if !self.list_stack.is_empty()
711            && (self.blockquote_depth.is_zero() || in_current_blockquote_list_item)
712        {
713            self.initialize_current_list_item_content(alignment)?;
714            return Ok(());
715        }
716        if self.blockquote_depth.is_positive() {
717            if self
718                .blockquote_content_states
719                .last()
720                .is_some_and(|state| *state == BlockquoteContentState::InChildren)
721            {
722                self.json.open_object()?;
723                self.write_id(id)?;
724                self.json.key("type").value("paragraph")?;
725                self.write_paragraph_props(alignment)?;
726                self.json.key("content").open_array()?;
727                self.context.in_text_block = true;
728                return Ok(());
729            }
730            if self.context.blockquote_has_content {
731                self.handle_text("\n\n")?;
732            }
733            self.prepare_blockquote_inline_content()?;
734            return Ok(());
735        }
736        self.json.open_object()?;
737        self.write_id(id)?;
738        self.json.key("type").value("paragraph")?;
739        self.write_paragraph_props(alignment)?;
740        self.json.key("content").open_array()?;
741        self.context.in_text_block = true;
742        Ok(())
743    }
744
745    fn write_paragraph_props(&mut self, alignment: Option<&TextAlignment>) -> Result<()> {
746        if let Some(value) = non_default_alignment_value(alignment) {
747            self.json
748                .key("props")
749                .object(|j| j.key("textAlignment").value(value))?;
750        }
751        Ok(())
752    }
753
754    fn handle_preformatted(&mut self, id: Option<&str>, syntax: Option<&str>) -> Result<()> {
755        self.json.open_object()?;
756        self.json.key("type").value("codeBlock")?;
757        self.write_id(id)?;
758        if let Some(lang) = syntax {
759            self.json
760                .key("props")
761                .object(|j| j.key("language").value(lang))?;
762        }
763        self.json.key("content").open_array()?;
764        self.context.in_text_block = true;
765        Ok(())
766    }
767
768    /// Opens a `BlockNote` inline link object and its `content` array.
769    ///
770    /// Drops `title` and `id` — `BlockNote`'s inline link schema has no slot for these.
771    fn handle_start_link(&mut self, href: &str) -> Result<()> {
772        if self.list_stack.last().is_some_and(|entry| {
773            entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
774        }) {
775            self.initialize_current_list_item_content(None)?;
776        }
777        if !self.context.in_text_block
778            && !self.context.in_table_cell
779            && !self.in_list_item_content()
780        {
781            return Ok(());
782        }
783        if self.in_link {
784            return Ok(());
785        }
786        if self.blockquote_depth.is_positive() {
787            self.context.blockquote_has_content = true;
788        }
789        self.json.open_object()?;
790        self.json.key("type").value("link")?;
791        self.json.key("href").value(href)?;
792        self.json.key("content").open_array()?;
793        self.in_link = true;
794        self.link_emitted_styled_text = false;
795        Ok(())
796    }
797
798    fn handle_start_text_style(&mut self, kind: TextStyleKind) -> Result<()> {
799        self.open_styles.push(kind);
800        Ok(())
801    }
802
803    fn handle_end_text_style(&mut self) -> Result<()> {
804        if self.open_styles.pop().is_none() {
805            return Err(Error::InvalidSequence {
806                expected: "StartTextStyle".to_string(),
807                found: "EndTextStyle".to_string(),
808                message: "cannot close text style because no text style is open".to_string(),
809            });
810        }
811        Ok(())
812    }
813
814    fn current_blockquote_contains_list_item(&self) -> bool {
815        let Some(blockquote_index) = self
816            .open_block_ancestors
817            .iter()
818            .rposition(|kind| *kind == BlockKind::Blockquote)
819        else {
820            return false;
821        };
822        self.open_block_ancestors
823            .iter()
824            .skip(blockquote_index.saturating_add(1))
825            .any(|kind| {
826                matches!(
827                    kind,
828                    BlockKind::OrderedListItem | BlockKind::UnorderedListItem
829                )
830            })
831    }
832
833    fn handle_start_list_item(
834        &mut self,
835        kind: ListKind,
836        id: Option<&str>,
837        level: u32,
838        start: Option<u64>,
839    ) -> Result<()> {
840        if self.blockquote_depth.is_positive() {
841            self.prepare_blockquote_children()?;
842            if !self.current_blockquote_contains_list_item() {
843                self.open_list_item_object(kind, id, level, start)?;
844                return Ok(());
845            }
846        }
847        if self.list_stack.is_empty() {
848            self.close_for_block_sibling()?;
849            self.open_list_item_object(kind, id, level, start)?;
850            return Ok(());
851        }
852
853        let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
854
855        // Level-jump clamping: silently absorb invalid multi-level forward jumps from broken
856        // source documents by treating any skip-ahead as a single step beyond the current top.
857        let effective_level = if level > stack_top_level.saturating_add(1) {
858            stack_top_level.saturating_add(1)
859        } else {
860            level
861        };
862
863        if effective_level > stack_top_level {
864            self.open_current_list_item_children()?;
865            self.open_list_item_object(kind, id, effective_level, start)?;
866            return Ok(());
867        }
868
869        if effective_level == stack_top_level {
870            self.close_current_list_item_object()?;
871            if self.list_stack.is_empty() {
872                self.close_for_block_sibling()?;
873            }
874            self.open_list_item_object(kind, id, effective_level, start)?;
875            return Ok(());
876        }
877
878        // Level-down: pop stack entries until the top's level is strictly below effective_level
879        // (i.e., at effective_level - 1, the parent). Then open the new item as a sibling.
880        while let Some(top) = self.list_stack.last() {
881            if top.level < effective_level {
882                break;
883            }
884            self.close_current_list_item_object()?;
885        }
886        if self.list_stack.is_empty() {
887            self.close_for_block_sibling()?;
888        }
889        self.open_list_item_object(kind, id, effective_level, start)?;
890        Ok(())
891    }
892
893    fn handle_start_table(&mut self, id: Option<&str>) -> Result<()> {
894        self.prepare_for_child_block()?;
895        self.json.open_object()?;
896        self.json.key("type").value("table")?;
897        self.write_id(id)?;
898        self.json.key("content").open_object()?;
899        self.json.key("type").value("tableContent")?;
900        self.json.key("columnWidths").array(|_| Ok(()))?;
901        self.json.key("rows").open_array()?;
902        self.table_depth.inc();
903        self.open_block_ancestors.push(BlockKind::Table);
904        self.context.in_text_block = false;
905        Ok(())
906    }
907
908    fn handle_start_table_row(&mut self, id: Option<&str>) -> Result<()> {
909        self.json.open_object()?;
910        self.write_id(id)?;
911        self.json.key("cells").open_array()
912    }
913
914    fn handle_start_table_cell_event(&mut self, event: Event) -> Result<()> {
915        match event {
916            Event::StartTableCell {
917                colspan,
918                id,
919                rowspan,
920                ..
921            }
922            | Event::StartTableHeader {
923                colspan,
924                id,
925                rowspan,
926                ..
927            } => self.handle_table_cell(id.as_deref(), colspan, rowspan),
928            _ => Ok(()),
929        }
930    }
931
932    fn handle_table_cell(
933        &mut self,
934        id: Option<&str>,
935        colspan: Option<u32>,
936        rowspan: Option<u32>,
937    ) -> Result<()> {
938        self.json.open_object()?;
939        self.json.key("type").value("tableCell")?;
940        self.write_id(id)?;
941        if colspan.is_some() || rowspan.is_some() {
942            self.json.key("props").object(|j| {
943                if let Some(n) = colspan {
944                    j.key("colspan").value(n)?;
945                }
946                if let Some(n) = rowspan {
947                    j.key("rowspan").value(n)?;
948                }
949                Ok(())
950            })?;
951        }
952        self.json.key("content").open_array()?;
953        self.context.in_table_cell = true;
954        self.context.in_text_block = false;
955        Ok(())
956    }
957
958    fn handle_text(&mut self, content: &str) -> Result<()> {
959        if !self.context.in_text_block
960            && !self.context.in_table_cell
961            && !self.in_list_item_content()
962        {
963            return Ok(());
964        }
965        if self.blockquote_depth.is_positive() {
966            self.context.blockquote_has_content = true;
967        }
968        let mut bold = false;
969        let mut italic = false;
970        let mut code = false;
971        let mut strike = false;
972        let mut underline = false;
973        let mut text_color: Option<docspec_core::Color> = None;
974        let mut background_color: Option<docspec_core::Color> = None;
975
976        for kind in &self.open_styles {
977            match kind {
978                TextStyleKind::Bold => bold = true,
979                TextStyleKind::Italic => italic = true,
980                TextStyleKind::Code => code = true,
981                TextStyleKind::Strikethrough => strike = true,
982                TextStyleKind::Underline => underline = true,
983                TextStyleKind::Subscript => {
984                    // Intentionally not rendered: BlockNote's default schema has no subscript representation.
985                    Self::omit_unsupported_text_style("subscript");
986                }
987                TextStyleKind::Superscript => {
988                    // Intentionally not rendered: BlockNote's default schema has no superscript representation.
989                    Self::omit_unsupported_text_style("superscript");
990                }
991                TextStyleKind::TextColor(color) => text_color = Some(*color),
992                TextStyleKind::Mark(color) => background_color = Some(*color),
993                future_kind => {
994                    // Future text styles are accepted and omitted until BlockNote has a mapped representation.
995                    Self::omit_future_text_style(future_kind);
996                }
997            }
998        }
999
1000        self.json.object(|j| {
1001            j.key("type").value("text")?;
1002            j.key("text").value(content)?;
1003            j.key("styles").object(|s| {
1004                for (key, enabled) in [
1005                    ("bold", bold),
1006                    ("italic", italic),
1007                    ("code", code),
1008                    ("strike", strike),
1009                    ("underline", underline),
1010                ] {
1011                    if enabled {
1012                        s.key(key).value(true)?;
1013                    }
1014                }
1015                if let Some(c) = text_color {
1016                    if let Some(name) = palette::nearest_text_color(&c) {
1017                        s.key("textColor").value(name)?;
1018                    }
1019                }
1020                if let Some(c) = background_color {
1021                    if let Some(name) = palette::nearest_background_color(&c) {
1022                        s.key("backgroundColor").value(name)?;
1023                    }
1024                }
1025                Ok(())
1026            })
1027        })?;
1028        if self.in_link {
1029            self.link_emitted_styled_text = true;
1030        }
1031        Ok(())
1032    }
1033
1034    fn handle_text_event(&mut self, content: &str) -> Result<()> {
1035        // Auto-open paragraph for orphan text (e.g., text after image closed paragraph)
1036        if self.list_stack.last().is_some_and(|entry| {
1037            entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
1038        }) {
1039            self.initialize_current_list_item_content(None)?;
1040        }
1041        if self.blockquote_depth.is_positive() && !self.context.in_text_block {
1042            self.prepare_blockquote_inline_content()?;
1043        }
1044        if !self.context.in_text_block
1045            && self.blockquote_depth.is_zero()
1046            && !self.context.in_table_cell
1047            && !self.in_list_item_content()
1048        {
1049            self.handle_paragraph(None, None)?;
1050        }
1051        self.handle_text(content)
1052    }
1053
1054    fn in_list_item_content(&self) -> bool {
1055        self.list_stack
1056            .last()
1057            .is_some_and(|entry| entry.content_state == ListContentState::Open)
1058    }
1059
1060    /// Creates a new `BlockNoteWriter` that writes to the given writer.
1061    ///
1062    /// # Arguments
1063    ///
1064    /// * `writer` - The underlying writer to emit JSON to
1065    #[inline]
1066    #[must_use]
1067    pub fn new(writer: W) -> Self {
1068        Self {
1069            blockquote_depth: Depth::default(),
1070            blockquote_content_states: Vec::new(),
1071            context: BlockContext::default(),
1072            in_link: false,
1073            json: JsonEmitter::new(StrusonBackend::new(writer)),
1074            lifted_nested_events: Vec::new(),
1075            lifted_subtree_depth: Depth::default(),
1076            link_emitted_styled_text: false,
1077            list_stack: Vec::new(),
1078            open_block_ancestors: Vec::new(),
1079            open_styles: Vec::new(),
1080            table_depth: Depth::default(),
1081        }
1082    }
1083
1084    fn current_drain_destination(&self) -> DrainDestination {
1085        for (idx, kind) in self.open_block_ancestors.iter().enumerate().rev() {
1086            if blocknote_accepts_child(Some(*kind), ChildKind::Container(BlockKind::Heading)) {
1087                return DrainDestination::Ancestor {
1088                    ancestor_index: idx,
1089                    kind: *kind,
1090                };
1091            }
1092        }
1093        DrainDestination::DocumentRoot
1094    }
1095
1096    fn compute_rebase_offset(&self, buffered: &[Event]) -> u32 {
1097        let min_buffered_level = buffered
1098            .iter()
1099            .filter_map(|event| match event {
1100                Event::StartOrderedListItem { level, .. }
1101                | Event::StartUnorderedListItem { level, .. } => Some(*level),
1102                _ => None,
1103            })
1104            .min();
1105
1106        let Some(min_level) = min_buffered_level else {
1107            return 0;
1108        };
1109
1110        let required_min_level = self
1111            .list_stack
1112            .last()
1113            .map_or(0, |entry| entry.level.saturating_add(1));
1114        required_min_level.saturating_sub(min_level)
1115    }
1116
1117    fn pop_ancestor(&mut self, kind: BlockKind) {
1118        if let Some(pos) = self
1119            .open_block_ancestors
1120            .iter()
1121            .rposition(|entry| *entry == kind)
1122        {
1123            self.open_block_ancestors.remove(pos);
1124        }
1125    }
1126
1127    fn initialize_current_list_item_content(
1128        &mut self,
1129        alignment: Option<&TextAlignment>,
1130    ) -> Result<()> {
1131        let Some(current_entry) = self.list_stack.last() else {
1132            return Ok(());
1133        };
1134        if current_entry.content_state != ListContentState::Pending {
1135            return Ok(());
1136        }
1137        let kind = current_entry.kind;
1138        let start = current_entry.start;
1139        let alignment_value = non_default_alignment_value(alignment);
1140        if alignment_value.is_some() || (kind == ListKind::Ordered && start.is_some()) {
1141            self.json.key("props").object(|j| {
1142                if let Some(value) = alignment_value {
1143                    j.key("textAlignment").value(value)?;
1144                }
1145                if kind == ListKind::Ordered {
1146                    if let Some(start_prop) = start {
1147                        j.key("start").value(start_prop)?;
1148                    }
1149                }
1150                Ok(())
1151            })?;
1152        }
1153        self.json.key("content").open_array()?;
1154        if let Some(entry) = self.list_stack.last_mut() {
1155            entry.content_state = ListContentState::Open;
1156        }
1157        Ok(())
1158    }
1159
1160    fn open_current_list_item_children(&mut self) -> Result<()> {
1161        if self
1162            .list_stack
1163            .last()
1164            .is_some_and(|entry| entry.content_state == ListContentState::Pending)
1165        {
1166            self.initialize_current_list_item_content(None)?;
1167        }
1168        let content_array_open = self
1169            .list_stack
1170            .last()
1171            .is_some_and(|entry| entry.content_state == ListContentState::Open);
1172        if content_array_open {
1173            self.json.close_array()?;
1174            if let Some(entry) = self.list_stack.last_mut() {
1175                entry.content_state = ListContentState::Closed;
1176                entry.first_paragraph_consumed = true;
1177            }
1178        }
1179
1180        let children_array_open = self
1181            .list_stack
1182            .last()
1183            .is_some_and(|entry| entry.children_array_open);
1184        if !children_array_open {
1185            self.json.key("children").open_array()?;
1186            if let Some(entry) = self.list_stack.last_mut() {
1187                entry.children_array_open = true;
1188            }
1189        }
1190        Ok(())
1191    }
1192
1193    fn prepare_list_item_children(&mut self) -> Result<()> {
1194        self.open_current_list_item_children()
1195    }
1196
1197    fn prepare_for_child_block(&mut self) -> Result<()> {
1198        match self.current_drain_destination() {
1199            DrainDestination::Ancestor {
1200                kind: BlockKind::Blockquote,
1201                ..
1202            } => self.prepare_blockquote_children(),
1203            DrainDestination::Ancestor {
1204                kind: BlockKind::OrderedListItem | BlockKind::UnorderedListItem,
1205                ..
1206            } => self.prepare_list_item_children(),
1207            DrainDestination::Ancestor { .. } | DrainDestination::DocumentRoot => {
1208                self.close_for_block_sibling()
1209            }
1210        }
1211    }
1212
1213    fn open_list_item_object(
1214        &mut self,
1215        kind: ListKind,
1216        id: Option<&str>,
1217        level: u32,
1218        start: Option<u64>,
1219    ) -> Result<()> {
1220        self.json.open_object()?;
1221        self.write_id(id)?;
1222        let type_name = match kind {
1223            ListKind::Ordered => "numberedListItem",
1224            ListKind::Unordered => "bulletListItem",
1225        };
1226        self.json.key("type").value(type_name)?;
1227        let checked_start = start
1228            .map(|start_value| {
1229                u32::try_from(start_value).map_err(|err| Error::Other {
1230                    message: format!("ordered list start value out of range: {start_value}: {err}"),
1231                })
1232            })
1233            .transpose()?;
1234        self.list_stack.push(ListStackEntry {
1235            children_array_open: false,
1236            content_state: ListContentState::Pending,
1237            first_paragraph_consumed: false,
1238            kind,
1239            level,
1240            start: checked_start,
1241        });
1242        let ancestor_kind = match kind {
1243            ListKind::Ordered => BlockKind::OrderedListItem,
1244            ListKind::Unordered => BlockKind::UnorderedListItem,
1245        };
1246        self.open_block_ancestors.push(ancestor_kind);
1247        Ok(())
1248    }
1249
1250    fn handle_end_document(&mut self) -> Result<()> {
1251        while !self.list_stack.is_empty() {
1252            self.close_current_list_item_object()?;
1253        }
1254        self.json.close_array()
1255    }
1256
1257    fn write_id(&mut self, id: Option<&str>) -> Result<()> {
1258        if let Some(id_val) = id {
1259            self.json.key("id").value(id_val)?;
1260        }
1261        Ok(())
1262    }
1263
1264    fn omit_unsupported_text_style(_style_name: &str) {}
1265
1266    fn omit_future_text_style(_style: &TextStyleKind) {}
1267
1268    fn should_buffer_for_lift(&self, event: &Event) -> bool {
1269        if self.table_depth.get() >= 2 || self.lifted_subtree_depth.is_positive() {
1270            return true;
1271        }
1272        if self.context.in_table_cell && Self::is_block_level_start(event) {
1273            return true;
1274        }
1275        if matches!(event, Event::Image { .. }) && self.context.in_table_cell {
1276            return true;
1277        }
1278        if matches!(event, Event::StartTable { .. }) && self.table_depth.is_positive() {
1279            return true;
1280        }
1281        false
1282    }
1283
1284    fn update_lift_depth(&mut self, event: &Event) {
1285        match event {
1286            Event::StartTable { .. } => self.table_depth.inc(),
1287            Event::EndTable => self.table_depth.dec(),
1288            _ => {}
1289        }
1290        let non_leaf_block_start =
1291            Self::is_block_level_start(event) && !matches!(event, Event::ThematicBreak { .. });
1292        let non_leaf_block_end =
1293            Self::is_block_level_end(event) && !matches!(event, Event::ThematicBreak { .. });
1294        if non_leaf_block_start
1295            && ((self.context.in_table_cell && self.table_depth.get() == 1)
1296                || self.lifted_subtree_depth.is_positive())
1297        {
1298            self.lifted_subtree_depth.inc();
1299        }
1300        if non_leaf_block_end && self.lifted_subtree_depth.is_positive() {
1301            self.lifted_subtree_depth.dec();
1302        }
1303    }
1304
1305    fn is_outermost_table_close(&self, event: &Event) -> bool {
1306        matches!(event, Event::EndTable) && self.table_depth.get() == 1
1307    }
1308
1309    fn drain_lifted_nested_events(&mut self) -> Result<()> {
1310        let buffered = core::mem::take(&mut self.lifted_nested_events);
1311        if buffered.is_empty() {
1312            return Ok(());
1313        }
1314        match self.current_drain_destination() {
1315            DrainDestination::Ancestor {
1316                ancestor_index,
1317                kind: BlockKind::Blockquote,
1318            } => {
1319                let _ = ancestor_index;
1320                self.prepare_blockquote_children()?;
1321            }
1322            DrainDestination::Ancestor {
1323                ancestor_index,
1324                kind: BlockKind::OrderedListItem | BlockKind::UnorderedListItem,
1325            } => {
1326                let _ = ancestor_index;
1327                self.prepare_list_item_children()?;
1328            }
1329            DrainDestination::Ancestor { ancestor_index, .. } => {
1330                let _ = ancestor_index;
1331                self.close_for_block_sibling()?;
1332            }
1333            DrainDestination::DocumentRoot => {
1334                self.close_for_block_sibling()?;
1335            }
1336        }
1337        let rebase_offset = match self.current_drain_destination() {
1338            DrainDestination::Ancestor {
1339                kind: BlockKind::OrderedListItem | BlockKind::UnorderedListItem,
1340                ..
1341            } => self.compute_rebase_offset(&buffered),
1342            DrainDestination::Ancestor { .. } | DrainDestination::DocumentRoot => 0,
1343        };
1344        for ev in buffered {
1345            let rebased_event = if rebase_offset > 0 {
1346                match ev {
1347                    Event::StartOrderedListItem {
1348                        id,
1349                        level,
1350                        start,
1351                        style_type,
1352                    } => Event::StartOrderedListItem {
1353                        id,
1354                        level: level.saturating_add(rebase_offset),
1355                        start,
1356                        style_type,
1357                    },
1358                    Event::StartUnorderedListItem {
1359                        id,
1360                        level,
1361                        style_type,
1362                    } => Event::StartUnorderedListItem {
1363                        id,
1364                        level: level.saturating_add(rebase_offset),
1365                        style_type,
1366                    },
1367                    other => other,
1368                }
1369            } else {
1370                ev
1371            };
1372            self.handle_event(rebased_event)?;
1373        }
1374        Ok(())
1375    }
1376
1377    fn is_block_level_start(event: &Event) -> bool {
1378        matches!(
1379            event,
1380            Event::StartHeading { .. }
1381                | Event::StartBlockQuote { .. }
1382                | Event::StartPreformatted { .. }
1383                | Event::StartOrderedListItem { .. }
1384                | Event::StartUnorderedListItem { .. }
1385                | Event::ThematicBreak { .. }
1386        )
1387    }
1388
1389    fn is_block_level_end(event: &Event) -> bool {
1390        matches!(
1391            event,
1392            Event::EndHeading
1393                | Event::EndBlockQuote
1394                | Event::EndPreformatted
1395                | Event::EndOrderedListItem
1396                | Event::EndUnorderedListItem
1397                | Event::ThematicBreak { .. }
1398        )
1399    }
1400}
1401
1402impl<W: Write> EventSink for BlockNoteWriter<W> {
1403    #[inline]
1404    fn finish(self) -> Result<()> {
1405        self.json.finish().map(|_| ())
1406    }
1407
1408    #[inline]
1409    fn handle_event(&mut self, event: Event) -> Result<()> {
1410        if self.should_buffer_for_lift(&event) {
1411            self.update_lift_depth(&event);
1412            self.lifted_nested_events.push(event);
1413            return Ok(());
1414        }
1415        let is_outermost_table_close = self.is_outermost_table_close(&event);
1416        let result = match event {
1417            Event::StartDocument { .. } => self.json.open_array(),
1418            Event::EndDocument => self.handle_end_document(),
1419            Event::StartHeading { level, id, .. } => {
1420                self.prepare_for_child_block()?;
1421                self.handle_heading(level, id.as_deref())
1422            }
1423            Event::EndHeading | Event::EndPreformatted => {
1424                if !self.context.in_text_block {
1425                    return Ok(());
1426                }
1427                close_text_block!(self)
1428            }
1429            Event::StartParagraph { alignment, id } => {
1430                self.handle_paragraph(id.as_deref(), alignment.as_ref())
1431            }
1432            Event::EndParagraph => self.handle_end_paragraph(),
1433            Event::StartBlockQuote { id, .. } => {
1434                self.prepare_for_child_block()?;
1435                self.handle_blockquote(id.as_deref())
1436            }
1437            Event::EndBlockQuote => self.handle_end_blockquote(),
1438            Event::StartPreformatted { id, syntax, .. } => {
1439                self.prepare_for_child_block()?;
1440                self.handle_preformatted(id.as_deref(), syntax.as_deref())
1441            }
1442            Event::ThematicBreak { id, .. } => {
1443                self.prepare_for_child_block()?;
1444                self.handle_divider(id.as_deref())
1445            }
1446            Event::Text { content } => self.handle_text_event(&content),
1447            Event::StartTextStyle { kind, .. } => self.handle_start_text_style(kind),
1448            Event::EndTextStyle => self.handle_end_text_style(),
1449            Event::Image {
1450                source, alt, id, ..
1451            } => self.handle_image(source, alt, id.as_deref()),
1452            Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1453            Event::StartOrderedListItem {
1454                id, level, start, ..
1455            } => self.handle_start_list_item(ListKind::Ordered, id.as_deref(), level, start),
1456            Event::StartUnorderedListItem { id, level, .. } => {
1457                self.handle_start_list_item(ListKind::Unordered, id.as_deref(), level, None)
1458            }
1459            Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1460            Event::StartTable { id, .. } => self.handle_start_table(id.as_deref()),
1461            Event::EndTable => self.handle_end_table(),
1462            Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_deref()),
1463            Event::EndTableRow => self.handle_end_table_row(),
1464            event @ (Event::StartTableCell { .. } | Event::StartTableHeader { .. }) => {
1465                self.handle_start_table_cell_event(event)
1466            }
1467            Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1468            Event::StartLink { href, .. } => self.handle_start_link(&href),
1469            Event::EndLink => self.handle_end_link(),
1470            Event::EndCaption
1471            | Event::EndDefinitionDetail
1472            | Event::EndDefinitionList
1473            | Event::EndDefinitionTerm
1474            | Event::EndFootnote
1475            | Event::FootnoteRef { .. }
1476            | Event::StartCaption { .. }
1477            | Event::StartDefinitionDetail { .. }
1478            | Event::StartDefinitionList { .. }
1479            | Event::StartDefinitionTerm { .. }
1480            | Event::StartFootnote { .. }
1481            | _ => Ok(()),
1482        };
1483        if is_outermost_table_close {
1484            result?;
1485            return self.drain_lifted_nested_events();
1486        }
1487        result
1488    }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use super::*;
1494
1495    #[test]
1496    fn list_stack_empty_after_new() {
1497        let mut buf = Vec::new();
1498        let writer = BlockNoteWriter::new(&mut buf);
1499        assert!(writer.list_stack.is_empty());
1500    }
1501
1502    #[test]
1503    fn blockquote_state_stack_empty_after_new() {
1504        let mut buf = Vec::new();
1505        let writer = BlockNoteWriter::new(&mut buf);
1506        assert!(writer.blockquote_content_states.is_empty());
1507    }
1508
1509    #[test]
1510    fn current_drain_destination_unit_walks_stack_top_to_bottom() {
1511        let mut buf = Vec::new();
1512        let mut writer = BlockNoteWriter::new(&mut buf);
1513
1514        writer.open_block_ancestors.push(BlockKind::OrderedListItem);
1515        writer.open_block_ancestors.push(BlockKind::Table);
1516        writer.open_block_ancestors.push(BlockKind::Blockquote);
1517
1518        assert!(matches!(
1519            writer.current_drain_destination(),
1520            DrainDestination::Ancestor {
1521                ancestor_index: 2,
1522                kind: BlockKind::Blockquote,
1523            }
1524        ));
1525    }
1526
1527    #[test]
1528    fn current_drain_destination_skips_table_to_document_root() {
1529        let mut buf = Vec::new();
1530        let mut writer = BlockNoteWriter::new(&mut buf);
1531        writer.open_block_ancestors.push(BlockKind::Table);
1532
1533        assert!(matches!(
1534            writer.current_drain_destination(),
1535            DrainDestination::DocumentRoot
1536        ));
1537    }
1538
1539    #[test]
1540    fn compute_rebase_offset_without_list_items_is_zero() {
1541        let mut buf = Vec::new();
1542        let writer = BlockNoteWriter::new(&mut buf);
1543
1544        assert_eq!(
1545            writer.compute_rebase_offset(&[Event::StartParagraph {
1546                alignment: None,
1547                id: None,
1548            }]),
1549            0
1550        );
1551    }
1552
1553    #[test]
1554    fn compute_rebase_offset_without_destination_list_is_zero_for_level_zero() {
1555        let mut buf = Vec::new();
1556        let writer = BlockNoteWriter::new(&mut buf);
1557
1558        assert_eq!(
1559            writer.compute_rebase_offset(&[Event::StartUnorderedListItem {
1560                id: None,
1561                level: 0,
1562                style_type: docspec_core::ListStyleType::Disc,
1563            }]),
1564            0
1565        );
1566    }
1567
1568    #[test]
1569    fn compute_rebase_offset_uses_current_list_stack_level() {
1570        let mut buf = Vec::new();
1571        let mut writer = BlockNoteWriter::new(&mut buf);
1572        writer.list_stack.push(ListStackEntry {
1573            children_array_open: true,
1574            content_state: ListContentState::Closed,
1575            first_paragraph_consumed: true,
1576            kind: ListKind::Unordered,
1577            level: 2,
1578            start: None,
1579        });
1580
1581        assert_eq!(
1582            writer.compute_rebase_offset(&[
1583                Event::StartOrderedListItem {
1584                    id: None,
1585                    level: 4,
1586                    start: Some(1),
1587                    style_type: docspec_core::ListStyleType::Decimal,
1588                },
1589                Event::StartUnorderedListItem {
1590                    id: None,
1591                    level: 0,
1592                    style_type: docspec_core::ListStyleType::Disc,
1593                },
1594            ]),
1595            3
1596        );
1597    }
1598
1599    #[test]
1600    fn compute_rebase_offset_does_not_lower_already_nested_items() {
1601        let mut buf = Vec::new();
1602        let mut writer = BlockNoteWriter::new(&mut buf);
1603        writer.list_stack.push(ListStackEntry {
1604            children_array_open: true,
1605            content_state: ListContentState::Closed,
1606            first_paragraph_consumed: true,
1607            kind: ListKind::Unordered,
1608            level: 1,
1609            start: None,
1610        });
1611
1612        assert_eq!(
1613            writer.compute_rebase_offset(&[Event::StartUnorderedListItem {
1614                id: None,
1615                level: 4,
1616                style_type: docspec_core::ListStyleType::Disc,
1617            }]),
1618            0
1619        );
1620    }
1621
1622    #[test]
1623    fn close_for_block_sibling_with_open_blockquote_prepares_children() {
1624        let mut buf = Vec::new();
1625        let mut writer = BlockNoteWriter::new(&mut buf);
1626        assert!(writer
1627            .handle_event(Event::StartDocument {
1628                id: None,
1629                language: None,
1630                metadata: None,
1631            })
1632            .is_ok());
1633        assert!(writer
1634            .handle_event(Event::StartBlockQuote { id: None })
1635            .is_ok());
1636
1637        assert!(writer.close_for_block_sibling().is_ok());
1638        assert_eq!(
1639            writer.blockquote_content_states.last(),
1640            Some(&BlockquoteContentState::InChildren)
1641        );
1642    }
1643
1644    #[test]
1645    fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1646        // Drives close_open_list_items call inside close_for_block_sibling (line 264).
1647        // After opening a list item, list_stack is non-empty; calling the private
1648        // method directly exercises the !list_stack.is_empty() branch.
1649        let mut buf = Vec::new();
1650        let mut writer = BlockNoteWriter::new(&mut buf);
1651        assert!(writer
1652            .handle_event(Event::StartDocument {
1653                id: None,
1654                language: None,
1655                metadata: None,
1656            })
1657            .is_ok());
1658        assert!(writer
1659            .handle_event(Event::StartUnorderedListItem {
1660                id: None,
1661                level: 0,
1662                style_type: docspec_core::ListStyleType::Disc,
1663            })
1664            .is_ok());
1665        assert!(
1666            !writer.list_stack.is_empty(),
1667            "list_stack must be non-empty before calling close_for_block_sibling"
1668        );
1669        assert!(writer.close_for_block_sibling().is_ok());
1670        assert!(
1671            writer.list_stack.is_empty(),
1672            "close_for_block_sibling must drain list_stack via close_open_list_items"
1673        );
1674        assert!(writer.handle_event(Event::EndDocument).is_ok());
1675        assert!(writer.finish().is_ok());
1676    }
1677}
1678
1679#[cfg(test)]
1680mod accepts_child_tests {
1681    #![allow(clippy::bool_assert_comparison)]
1682    use super::*;
1683    use docspec_core::BlockKind;
1684
1685    #[test]
1686    fn document_root_accepts_all_block_kinds() {
1687        assert_eq!(
1688            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Blockquote)),
1689            true
1690        );
1691        assert_eq!(
1692            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Caption)),
1693            true
1694        );
1695        assert_eq!(
1696            blocknote_accepts_child(None, ChildKind::Container(BlockKind::DefinitionDetail)),
1697            true
1698        );
1699        assert_eq!(
1700            blocknote_accepts_child(None, ChildKind::Container(BlockKind::DefinitionList)),
1701            true
1702        );
1703        assert_eq!(
1704            blocknote_accepts_child(None, ChildKind::Container(BlockKind::DefinitionTerm)),
1705            true
1706        );
1707        assert_eq!(
1708            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Document)),
1709            true
1710        );
1711        assert_eq!(
1712            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Footnote)),
1713            true
1714        );
1715        assert_eq!(
1716            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Heading)),
1717            true
1718        );
1719        assert_eq!(
1720            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Link)),
1721            true
1722        );
1723        assert_eq!(
1724            blocknote_accepts_child(None, ChildKind::Container(BlockKind::OrderedListItem)),
1725            true
1726        );
1727        assert_eq!(
1728            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Paragraph)),
1729            true
1730        );
1731        assert_eq!(
1732            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Preformatted)),
1733            true
1734        );
1735        assert_eq!(
1736            blocknote_accepts_child(None, ChildKind::Container(BlockKind::Table)),
1737            true
1738        );
1739        assert_eq!(
1740            blocknote_accepts_child(None, ChildKind::Container(BlockKind::TableCell)),
1741            true
1742        );
1743        assert_eq!(
1744            blocknote_accepts_child(None, ChildKind::Container(BlockKind::TableHeader)),
1745            true
1746        );
1747        assert_eq!(
1748            blocknote_accepts_child(None, ChildKind::Container(BlockKind::TableRow)),
1749            true
1750        );
1751        assert_eq!(
1752            blocknote_accepts_child(None, ChildKind::Container(BlockKind::UnorderedListItem)),
1753            true
1754        );
1755        assert_eq!(
1756            blocknote_accepts_child(None, ChildKind::Leaf(BlockLeafKind::Image)),
1757            true
1758        );
1759        assert_eq!(
1760            blocknote_accepts_child(None, ChildKind::Leaf(BlockLeafKind::Divider)),
1761            true
1762        );
1763    }
1764
1765    #[test]
1766    fn heading_accepts_paragraph_child() {
1767        assert_eq!(
1768            blocknote_accepts_child(
1769                Some(BlockKind::Heading),
1770                ChildKind::Container(BlockKind::Paragraph),
1771            ),
1772            true
1773        );
1774    }
1775
1776    #[test]
1777    fn list_item_accepts_heading_child() {
1778        assert_eq!(
1779            blocknote_accepts_child(
1780                Some(BlockKind::OrderedListItem),
1781                ChildKind::Container(BlockKind::Heading),
1782            ),
1783            true
1784        );
1785    }
1786
1787    #[test]
1788    fn blockquote_accepts_list_item_child() {
1789        assert_eq!(
1790            blocknote_accepts_child(
1791                Some(BlockKind::Blockquote),
1792                ChildKind::Container(BlockKind::UnorderedListItem),
1793            ),
1794            true
1795        );
1796    }
1797
1798    #[test]
1799    fn table_cell_rejects_heading() {
1800        assert_eq!(
1801            blocknote_accepts_child(
1802                Some(BlockKind::TableCell),
1803                ChildKind::Container(BlockKind::Heading),
1804            ),
1805            false
1806        );
1807    }
1808
1809    #[test]
1810    fn table_cell_rejects_list_item() {
1811        assert_eq!(
1812            blocknote_accepts_child(
1813                Some(BlockKind::TableCell),
1814                ChildKind::Container(BlockKind::OrderedListItem),
1815            ),
1816            false
1817        );
1818    }
1819
1820    #[test]
1821    fn code_block_rejects_heading() {
1822        assert_eq!(
1823            blocknote_accepts_child(
1824                Some(BlockKind::Preformatted),
1825                ChildKind::Container(BlockKind::Heading),
1826            ),
1827            false
1828        );
1829    }
1830
1831    #[test]
1832    fn paragraph_rejects_blocks() {
1833        assert_eq!(
1834            blocknote_accepts_child(
1835                Some(BlockKind::Paragraph),
1836                ChildKind::Container(BlockKind::Blockquote),
1837            ),
1838            false
1839        );
1840    }
1841
1842    #[test]
1843    fn heading_rejects_table_row_child() {
1844        assert_eq!(
1845            blocknote_accepts_child(
1846                Some(BlockKind::Heading),
1847                ChildKind::Container(BlockKind::TableRow),
1848            ),
1849            false
1850        );
1851    }
1852
1853    #[test]
1854    fn heading_rejects_table_cell_child() {
1855        assert_eq!(
1856            blocknote_accepts_child(
1857                Some(BlockKind::Heading),
1858                ChildKind::Container(BlockKind::TableCell),
1859            ),
1860            false
1861        );
1862    }
1863
1864    #[test]
1865    fn heading_rejects_table_header_child() {
1866        assert_eq!(
1867            blocknote_accepts_child(
1868                Some(BlockKind::Heading),
1869                ChildKind::Container(BlockKind::TableHeader),
1870            ),
1871            false
1872        );
1873    }
1874
1875    #[test]
1876    fn heading_accepts_image_leaf() {
1877        assert_eq!(
1878            blocknote_accepts_child(
1879                Some(BlockKind::Heading),
1880                ChildKind::Leaf(BlockLeafKind::Image)
1881            ),
1882            true
1883        );
1884    }
1885
1886    #[test]
1887    fn heading_accepts_divider_leaf() {
1888        assert_eq!(
1889            blocknote_accepts_child(
1890                Some(BlockKind::Heading),
1891                ChildKind::Leaf(BlockLeafKind::Divider),
1892            ),
1893            true
1894        );
1895    }
1896
1897    #[test]
1898    fn blockquote_accepts_heading_child() {
1899        assert_eq!(
1900            blocknote_accepts_child(
1901                Some(BlockKind::Blockquote),
1902                ChildKind::Container(BlockKind::Heading),
1903            ),
1904            true
1905        );
1906    }
1907
1908    #[test]
1909    fn blockquote_accepts_table_child() {
1910        assert_eq!(
1911            blocknote_accepts_child(
1912                Some(BlockKind::Blockquote),
1913                ChildKind::Container(BlockKind::Table),
1914            ),
1915            true
1916        );
1917    }
1918
1919    #[test]
1920    fn blockquote_rejects_table_row_child() {
1921        assert_eq!(
1922            blocknote_accepts_child(
1923                Some(BlockKind::Blockquote),
1924                ChildKind::Container(BlockKind::TableRow),
1925            ),
1926            false
1927        );
1928    }
1929
1930    #[test]
1931    fn blockquote_rejects_table_cell_child() {
1932        assert_eq!(
1933            blocknote_accepts_child(
1934                Some(BlockKind::Blockquote),
1935                ChildKind::Container(BlockKind::TableCell),
1936            ),
1937            false
1938        );
1939    }
1940
1941    #[test]
1942    fn blockquote_rejects_table_header_child() {
1943        assert_eq!(
1944            blocknote_accepts_child(
1945                Some(BlockKind::Blockquote),
1946                ChildKind::Container(BlockKind::TableHeader),
1947            ),
1948            false
1949        );
1950    }
1951
1952    #[test]
1953    fn ordered_list_item_accepts_image_leaf() {
1954        assert_eq!(
1955            blocknote_accepts_child(
1956                Some(BlockKind::OrderedListItem),
1957                ChildKind::Leaf(BlockLeafKind::Image),
1958            ),
1959            true
1960        );
1961    }
1962
1963    #[test]
1964    fn ordered_list_item_rejects_table_row_child() {
1965        assert_eq!(
1966            blocknote_accepts_child(
1967                Some(BlockKind::OrderedListItem),
1968                ChildKind::Container(BlockKind::TableRow),
1969            ),
1970            false
1971        );
1972    }
1973
1974    #[test]
1975    fn ordered_list_item_rejects_table_cell_child() {
1976        assert_eq!(
1977            blocknote_accepts_child(
1978                Some(BlockKind::OrderedListItem),
1979                ChildKind::Container(BlockKind::TableCell),
1980            ),
1981            false
1982        );
1983    }
1984
1985    #[test]
1986    fn ordered_list_item_rejects_table_header_child() {
1987        assert_eq!(
1988            blocknote_accepts_child(
1989                Some(BlockKind::OrderedListItem),
1990                ChildKind::Container(BlockKind::TableHeader),
1991            ),
1992            false
1993        );
1994    }
1995
1996    #[test]
1997    fn unordered_list_item_accepts_preformatted_child() {
1998        assert_eq!(
1999            blocknote_accepts_child(
2000                Some(BlockKind::UnorderedListItem),
2001                ChildKind::Container(BlockKind::Preformatted),
2002            ),
2003            true
2004        );
2005    }
2006
2007    #[test]
2008    fn unordered_list_item_accepts_divider_leaf() {
2009        assert_eq!(
2010            blocknote_accepts_child(
2011                Some(BlockKind::UnorderedListItem),
2012                ChildKind::Leaf(BlockLeafKind::Divider),
2013            ),
2014            true
2015        );
2016    }
2017
2018    #[test]
2019    fn unordered_list_item_rejects_table_row_child() {
2020        assert_eq!(
2021            blocknote_accepts_child(
2022                Some(BlockKind::UnorderedListItem),
2023                ChildKind::Container(BlockKind::TableRow),
2024            ),
2025            false
2026        );
2027    }
2028
2029    #[test]
2030    fn unordered_list_item_rejects_table_cell_child() {
2031        assert_eq!(
2032            blocknote_accepts_child(
2033                Some(BlockKind::UnorderedListItem),
2034                ChildKind::Container(BlockKind::TableCell),
2035            ),
2036            false
2037        );
2038    }
2039
2040    #[test]
2041    fn unordered_list_item_rejects_table_header_child() {
2042        assert_eq!(
2043            blocknote_accepts_child(
2044                Some(BlockKind::UnorderedListItem),
2045                ChildKind::Container(BlockKind::TableHeader),
2046            ),
2047            false
2048        );
2049    }
2050
2051    #[test]
2052    fn caption_rejects_paragraph_child() {
2053        assert_eq!(
2054            blocknote_accepts_child(
2055                Some(BlockKind::Caption),
2056                ChildKind::Container(BlockKind::Paragraph),
2057            ),
2058            false
2059        );
2060    }
2061
2062    #[test]
2063    fn definition_list_rejects_definition_term_child() {
2064        assert_eq!(
2065            blocknote_accepts_child(
2066                Some(BlockKind::DefinitionList),
2067                ChildKind::Container(BlockKind::DefinitionTerm),
2068            ),
2069            false
2070        );
2071    }
2072
2073    #[test]
2074    fn definition_term_rejects_paragraph_child() {
2075        assert_eq!(
2076            blocknote_accepts_child(
2077                Some(BlockKind::DefinitionTerm),
2078                ChildKind::Container(BlockKind::Paragraph),
2079            ),
2080            false
2081        );
2082    }
2083
2084    #[test]
2085    fn definition_detail_rejects_paragraph_child() {
2086        assert_eq!(
2087            blocknote_accepts_child(
2088                Some(BlockKind::DefinitionDetail),
2089                ChildKind::Container(BlockKind::Paragraph),
2090            ),
2091            false
2092        );
2093    }
2094
2095    #[test]
2096    fn footnote_rejects_paragraph_child() {
2097        assert_eq!(
2098            blocknote_accepts_child(
2099                Some(BlockKind::Footnote),
2100                ChildKind::Container(BlockKind::Paragraph),
2101            ),
2102            false
2103        );
2104    }
2105
2106    #[test]
2107    fn link_rejects_paragraph_child() {
2108        assert_eq!(
2109            blocknote_accepts_child(
2110                Some(BlockKind::Link),
2111                ChildKind::Container(BlockKind::Paragraph),
2112            ),
2113            false
2114        );
2115    }
2116
2117    #[test]
2118    fn document_container_rejects_paragraph_child() {
2119        assert_eq!(
2120            blocknote_accepts_child(
2121                Some(BlockKind::Document),
2122                ChildKind::Container(BlockKind::Paragraph),
2123            ),
2124            false
2125        );
2126    }
2127
2128    #[test]
2129    fn preformatted_rejects_image_leaf() {
2130        assert_eq!(
2131            blocknote_accepts_child(
2132                Some(BlockKind::Preformatted),
2133                ChildKind::Leaf(BlockLeafKind::Image),
2134            ),
2135            false
2136        );
2137    }
2138
2139    #[test]
2140    fn paragraph_rejects_divider_leaf() {
2141        assert_eq!(
2142            blocknote_accepts_child(
2143                Some(BlockKind::Paragraph),
2144                ChildKind::Leaf(BlockLeafKind::Divider),
2145            ),
2146            false
2147        );
2148    }
2149
2150    #[test]
2151    fn table_rejects_table_row_child() {
2152        assert_eq!(
2153            blocknote_accepts_child(
2154                Some(BlockKind::Table),
2155                ChildKind::Container(BlockKind::TableRow),
2156            ),
2157            false
2158        );
2159    }
2160
2161    #[test]
2162    fn table_rejects_image_leaf() {
2163        assert_eq!(
2164            blocknote_accepts_child(
2165                Some(BlockKind::Table),
2166                ChildKind::Leaf(BlockLeafKind::Image)
2167            ),
2168            false
2169        );
2170    }
2171
2172    #[test]
2173    fn table_row_rejects_table_cell_child() {
2174        assert_eq!(
2175            blocknote_accepts_child(
2176                Some(BlockKind::TableRow),
2177                ChildKind::Container(BlockKind::TableCell),
2178            ),
2179            false
2180        );
2181    }
2182
2183    #[test]
2184    fn table_row_rejects_heading_child() {
2185        assert_eq!(
2186            blocknote_accepts_child(
2187                Some(BlockKind::TableRow),
2188                ChildKind::Container(BlockKind::Heading),
2189            ),
2190            false
2191        );
2192    }
2193
2194    #[test]
2195    fn table_header_rejects_heading_child() {
2196        assert_eq!(
2197            blocknote_accepts_child(
2198                Some(BlockKind::TableHeader),
2199                ChildKind::Container(BlockKind::Heading),
2200            ),
2201            false
2202        );
2203    }
2204
2205    #[test]
2206    fn table_header_rejects_divider_leaf() {
2207        assert_eq!(
2208            blocknote_accepts_child(
2209                Some(BlockKind::TableHeader),
2210                ChildKind::Leaf(BlockLeafKind::Divider),
2211            ),
2212            false
2213        );
2214    }
2215}