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