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