Expand description
DocSpec event stream to BlockNote JSON writer.
This crate provides a streaming BlockNoteWriter that implements EventSink to convert
DocSpec event streams into BlockNote JSON format. BlockNote is a block-based rich text
editor format.
§Design
The writer emits JSON tokens directly to the underlying Write as events arrive using
docspec-json for streaming JSON output. For text and URI-based images, memory usage is
constant regardless of document size. Asset-based images (ImageSource::Asset) are
base64-encoded into an in-memory data URI before writing, so memory scales with individual
asset size.
§Supported Events
StartDocument/EndDocument— array start/endStartHeading/EndHeading— heading blocksStartParagraph/EndParagraph— paragraph blocksStartBlockQuote/EndBlockQuote— quote blocksStartPreformatted/EndPreformatted— code blocksStartTable/EndTable— table blocksStartTableRow/EndTableRow— table rowsStartTableCell/EndTableCell— table cells (data)StartTableHeader/EndTableHeader— table cells (header, emitted identically to data cells)Text— inline text content with bold/italic/code/strikethrough/underline stylesImage— image blocksLineBreak/SoftBreak— line breaks within content blocksThematicBreak— divider blocksStartOrderedListItem/EndOrderedListItem—numberedListItemblocks with optionalstartpropStartUnorderedListItem/EndUnorderedListItem—bulletListItemblocks
§Table Cell Content Semantics
BlockNote’s tableCell.content is InlineContent[] — it cannot hold block-level types.
EVENTS.md declares that DocSpec cells may contain any block element, so this writer
flattens block-level events that appear inside a cell:
- Preserved:
Text(with all inline styles),LineBreak,SoftBreak - Absorbed silently:
StartParagraph/EndParagraph(paragraph boundaries are dropped — adjacent paragraphs concatenate without separator) - Dropped:
Image,StartBlockQuote,StartPreformatted,StartHeading,ThematicBreak, nestedStartTableand their children — silently discarded
Nested tables (a StartTable inside a cell) are entirely dropped: their rows, cells, text,
and closing events are all absorbed. Only the outer table is emitted. The current markdown
reader never produces multi-block cell content or nested tables — these guards exist for
future DOCX/ODT readers.
§List Support
Required: wrap BlockNoteWriter in StackTrackingSink
before feeding list events. Raw list events without that wrapper are undefined behavior.
StackTrackingSink auto-inserts StartParagraph inside list items, which is how the writer
knows where each item’s inline content begins.
DocSpec list events translate to BlockNote block types as follows:
StartUnorderedListItem→bulletListItemStartOrderedListItem→numberedListItem(thestartfield, when present on the first item, becomes thestartprop)
Nesting uses BlockNote’s native children: Block[] arrays. DocSpec’s level: u32 field
drives the nesting depth: a level increase opens a new children array; a level decrease
closes the appropriate number of open items and children arrays.
Multi-paragraph items: the first paragraph’s inline content populates the list item’s
content[] array. Each subsequent paragraph becomes a child paragraph block inside the
item’s children[] array.
Non-paragraph blocks inside list items (headings, images, code blocks, blockquotes,
tables, thematic breaks) are dropped silently along with all of their inline contents —
including any Text events nested within them. The drop applies anywhere inside a list
item: both in the inline content[] slot (around the first paragraph) and after the item
has transitioned to children[] (for multi-paragraph items or items containing nested
lists). This differs from the table cell policy: cells preserve Text while absorbing
block boundaries (so a heading inside a cell becomes plain text), whereas list items
suppress text inside dropped blocks entirely.
§Container Interactions
- Inside a table cell: list items are dropped entirely, consistent with other block-level content inside cells.
- Inside a blockquote: the blockquote is force-closed and the list item is emitted at the top level as a sibling. This matches the existing sibling-emit behavior for headings and images inside blockquotes.
§Out of Scope
checkListItem: requires upstreamDocSpecevent support not yet defined.toggleListItem: noDocSpecevent equivalent exists.- Custom
style_typemarkers:BlockNote’s default schema has no equivalent field; thestyle_typevalue fromStartOrderedListItem/StartUnorderedListItemis silently dropped.
§Example
use docspec_blocknote_writer::BlockNoteWriter;
use docspec_core::{Event, EventSink, ListStyleType, StackTrackingSink, TextStyle};
let mut buf = Vec::<u8>::new();
let mut writer = StackTrackingSink::new(BlockNoteWriter::new(&mut buf));
writer.handle_event(Event::StartDocument { id: None, language: None, metadata: None })?;
// Plain paragraph
writer.handle_event(Event::StartParagraph { alignment: None, id: None })?;
writer.handle_event(Event::Text {
content: "Hello".to_string(),
style: TextStyle::default(),
})?;
writer.handle_event(Event::EndParagraph)?;
// Unordered list item (StackTrackingSink auto-inserts the paragraph)
writer.handle_event(Event::StartUnorderedListItem {
id: None,
level: 0,
style_type: ListStyleType::Disc,
})?;
writer.handle_event(Event::Text {
content: "First bullet".to_string(),
style: TextStyle::default(),
})?;
writer.handle_event(Event::EndUnorderedListItem)?;
// Ordered list item with explicit start number
writer.handle_event(Event::StartOrderedListItem {
id: None,
level: 0,
start: Some(1),
style_type: ListStyleType::Decimal,
})?;
writer.handle_event(Event::Text {
content: "Step one".to_string(),
style: TextStyle::default(),
})?;
writer.handle_event(Event::EndOrderedListItem)?;
writer.handle_event(Event::EndDocument)?;
writer.finish()?;
let json = String::from_utf8(buf)?;
assert!(json.starts_with('['));Structs§
- Block
Note Writer - A streaming
BlockNoteJSON writer.