1pub mod palette;
150
151use std::io::Write;
152
153use docspec_core::{
154 Depth, Error, Event, EventSink, ImageSource, Result, TextAlignment, TextStyleKind,
155};
156use docspec_json::{JsonEmitter, Null, StrusonBackend};
157
158macro_rules! close_text_block {
159 ($writer:expr) => {{
160 $writer.close_open_link_if_any()?;
161 $writer.close_content_block()?;
162 $writer.context.in_text_block = false;
163 Ok(())
164 }};
165}
166
167macro_rules! return_if_table_cell {
168 ($writer:expr) => {
169 if $writer.context.in_table_cell {
170 return Ok(());
171 }
172 };
173}
174
175macro_rules! drop_block_in_list_start {
176 ($writer:expr) => {
177 if $writer.in_any_list_item() || $writer.drop_inside_list_depth.is_positive() {
178 $writer.drop_inside_list_depth.inc();
179 return Ok(());
180 }
181 };
182}
183
184macro_rules! drop_block_in_list_end {
185 ($writer:expr) => {
186 if $writer.drop_inside_list_depth.is_positive() {
187 $writer.drop_inside_list_depth.dec();
188 return Ok(());
189 }
190 };
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195enum ListKind {
196 Ordered,
198 Unordered,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203enum ListContentState {
204 Pending,
205 Open,
206 Closed,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211struct ListStackEntry {
212 children_array_open: bool,
214 content_state: ListContentState,
216 first_paragraph_consumed: bool,
218 kind: ListKind,
220 level: u32,
222 start: Option<u32>,
224}
225
226#[derive(Default)]
227struct BlockContext {
228 blockquote_has_content: bool,
229 in_table_cell: bool,
230 in_text_block: bool,
231}
232
233fn non_default_alignment_value(alignment: Option<&TextAlignment>) -> Option<&'static str> {
234 match alignment {
235 Some(TextAlignment::Center) => Some("center"),
236 Some(TextAlignment::Right) => Some("right"),
237 Some(TextAlignment::Justify) => Some("justify"),
238 _ => None,
239 }
240}
241
242pub struct BlockNoteWriter<W: Write> {
251 blockquote_depth: Depth,
252 blockquote_force_closed_count: Depth,
253 context: BlockContext,
254 drop_inside_list_depth: Depth,
255 dropped_list_depth: Depth,
256 in_link: bool,
258 json: JsonEmitter<StrusonBackend<W>>,
259 link_emitted_styled_text: bool,
261 lifted_nested_events: Vec<Event>,
263 list_stack: Vec<ListStackEntry>,
264 open_styles: Vec<TextStyleKind>,
265 table_depth: Depth,
266}
267
268impl<W: Write> BlockNoteWriter<W> {
269 fn close_blockquote_for_sibling(&mut self) -> Result<()> {
270 self.close_open_link_if_any()?;
271 self.close_content_block()?;
272 self.blockquote_depth.dec();
273 self.blockquote_force_closed_count.inc();
274 self.context.in_text_block = self.blockquote_depth.is_positive();
275 Ok(())
276 }
277
278 fn close_content_block(&mut self) -> Result<()> {
279 self.json.close_array()?;
280 self.json.key("children").array(|_| Ok(()))?;
281 self.json.close_object()
282 }
283
284 fn close_current_list_item_object(&mut self) -> Result<()> {
285 if self
286 .list_stack
287 .last()
288 .is_some_and(|entry| entry.content_state == ListContentState::Pending)
289 {
290 self.initialize_current_list_item_content(None)?;
291 }
292 let popped_entry = self.list_stack.pop();
293 if let Some(list_entry) = popped_entry {
294 if list_entry.content_state == ListContentState::Open {
295 self.close_open_link_if_any()?;
296 self.json.close_array()?;
297 }
298 if list_entry.children_array_open {
299 self.json.close_array()?;
300 } else {
301 self.json.key("children").array(|_| Ok(()))?;
302 }
303 self.json.close_object()?;
304 }
305 Ok(())
306 }
307
308 fn close_for_block_sibling(&mut self) -> Result<()> {
309 if !self.list_stack.is_empty() {
310 self.close_open_list_items()?;
311 }
312 if self.blockquote_depth.is_positive() {
313 return self.close_blockquote_for_sibling();
314 }
315 if self.context.in_text_block {
316 self.close_open_link_if_any()?;
317 self.close_content_block()?;
318 self.context.in_text_block = false;
319 }
320 Ok(())
321 }
322
323 fn close_open_link_if_any(&mut self) -> Result<()> {
329 if self.in_link {
330 self.handle_end_link()?;
331 }
332 Ok(())
333 }
334
335 fn close_open_list_items(&mut self) -> Result<()> {
336 while !self.list_stack.is_empty() {
337 self.close_current_list_item_object()?;
338 }
339 Ok(())
340 }
341
342 fn handle_blockquote(&mut self, id: Option<&str>) -> Result<()> {
343 self.json.open_object()?;
344 self.json.key("type").value("quote")?;
345 self.write_id(id)?;
346 self.json.key("content").open_array()?;
347 self.blockquote_depth.inc();
348 self.context.blockquote_has_content = false;
349 self.context.in_text_block = true;
350 Ok(())
351 }
352
353 fn handle_divider(&mut self, id: Option<&str>) -> Result<()> {
354 self.json.object(|j| {
355 j.key("type").value("divider")?;
356 if let Some(id_val) = id {
357 j.key("id").value(id_val)?;
358 }
359 Ok(())
360 })
361 }
362
363 fn handle_end_link(&mut self) -> Result<()> {
369 if !self.in_link {
370 return Ok(());
371 }
372 if !self.link_emitted_styled_text {
373 self.json.open_object()?;
374 self.json.key("type").value("text")?;
375 self.json.key("text").value("")?;
376 self.json.key("styles").open_object()?;
377 self.json.close_object()?;
378 self.json.close_object()?;
379 }
380 self.json.close_array()?;
381 self.json.close_object()?;
382 self.in_link = false;
383 self.link_emitted_styled_text = false;
384 Ok(())
385 }
386
387 fn handle_end_list_item(&mut self) -> Result<()> {
388 if self.dropped_list_depth.is_positive() {
389 self.dropped_list_depth.dec();
390 return Ok(());
391 }
392 if self.list_stack.is_empty() {
393 return Ok(());
394 }
395 self.close_current_list_item_object()
396 }
397
398 fn handle_end_paragraph(&mut self) -> Result<()> {
399 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
400 return Ok(());
401 }
402 if !self.list_stack.is_empty()
403 && self
404 .list_stack
405 .last()
406 .is_some_and(|e| e.first_paragraph_consumed)
407 && self.context.in_text_block
408 {
409 self.close_open_link_if_any()?;
410 self.json.close_array()?;
411 self.json.key("children").array(|_| Ok(()))?;
412 self.json.close_object()?;
413 self.context.in_text_block = false;
414 return Ok(());
415 }
416 if self.in_list_item_content() {
417 if let Some(entry) = self.list_stack.last_mut() {
418 entry.first_paragraph_consumed = true;
419 }
420 return Ok(());
421 }
422 if self.blockquote_depth.is_positive()
423 || !self.context.in_text_block
424 || self.context.in_table_cell
425 {
426 return Ok(());
427 }
428 close_text_block!(self)
429 }
430
431 fn handle_end_table(&mut self) -> Result<()> {
432 drop_block_in_list_end!(self);
433 if self.table_depth.is_zero() {
434 return Ok(());
435 }
436 self.json.close_array()?;
437 self.json.close_object()?;
438 self.json.key("children").array(|_| Ok(()))?;
439 self.json.close_object()?;
440 self.table_depth.reset();
441 Ok(())
442 }
443
444 fn handle_end_table_cell(&mut self) -> Result<()> {
445 if self.drop_inside_list_depth.is_positive() {
446 return Ok(());
447 }
448 self.close_open_link_if_any()?;
449 self.json.close_array()?;
450 self.json.close_object()?;
451 self.context.in_table_cell = false;
452 Ok(())
453 }
454
455 fn handle_end_table_row(&mut self) -> Result<()> {
456 if self.drop_inside_list_depth.is_positive() {
457 return Ok(());
458 }
459 self.json.close_array()?;
460 self.json.close_object()
461 }
462
463 fn handle_end_blockquote(&mut self) -> Result<()> {
464 drop_block_in_list_end!(self);
465 return_if_table_cell!(self);
466 if self.blockquote_force_closed_count.is_positive() {
467 self.blockquote_force_closed_count.dec();
468 return Ok(());
469 }
470 if self.blockquote_depth.is_zero() || !self.context.in_text_block {
471 return Ok(());
472 }
473 self.close_open_link_if_any()?;
474 self.close_content_block()?;
475 self.blockquote_depth.dec();
476 self.context.in_text_block = self.blockquote_depth.is_positive();
477 Ok(())
478 }
479
480 fn handle_heading(&mut self, level: u8, id: Option<&str>) -> Result<()> {
481 self.json.open_object()?;
482 self.json.key("type").value("heading")?;
483 self.write_id(id)?;
484 self.json
485 .key("props")
486 .object(|j| j.key("level").value(level))?;
487 self.json.key("content").open_array()?;
488 self.context.in_text_block = true;
489 Ok(())
490 }
491
492 fn handle_image(
493 &mut self,
494 source: ImageSource,
495 alt: Option<String>,
496 id: Option<&str>,
497 ) -> Result<()> {
498 if self.context.in_table_cell || self.drop_inside_list_depth.is_positive() {
499 return Ok(());
500 }
501 if self.in_any_list_item() {
502 return Ok(());
503 }
504 self.close_for_block_sibling()?;
505 let caption = alt.unwrap_or_default();
506
507 match source {
508 ImageSource::Uri { uri } => self.json.object(|j| {
509 if let Some(id_val) = id {
510 j.key("id").value(id_val)?;
511 }
512 j.key("type").value("image")?;
513 j.key("props").object(|p| {
514 p.key("url").value(uri.as_str())?;
515 p.key("caption").value(caption.as_str())
516 })?;
517 j.key("content").value(Null)?;
518 j.key("children").array(|_| Ok(()))
519 }),
520 ImageSource::Asset(handle) => {
521 let content_type = handle
522 .content_type()
523 .ok_or_else(|| Error::Other {
524 message: format!("asset not found: {}", handle.asset_id()),
525 })?
526 .into_owned();
527
528 self.json.object(|j| {
529 if let Some(id_val) = id {
530 j.key("id").value(id_val)?;
531 }
532 j.key("type").value("image")?;
533 j.key("props").object(|p| {
534 p.key("url").string_value_streaming(|w| {
535 write!(w, "data:{content_type};base64,")?;
536 let mut enc = base64::write::EncoderWriter::new(
537 w,
538 &base64::engine::general_purpose::STANDARD,
539 );
540 handle.stream_to(&mut enc)?;
541 enc.finish()?;
542 Ok(())
543 })?;
544 p.key("caption").value(caption.as_str())
545 })?;
546 j.key("content").value(Null)?;
547 j.key("children").array(|_| Ok(()))
548 })
549 }
550 _ => Ok(()),
551 }
552 }
553
554 fn handle_line_break(&mut self) -> Result<()> {
555 if self.drop_inside_list_depth.is_positive() {
556 return Ok(());
557 }
558 if self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content() {
559 self.handle_text("\n")
560 } else {
561 Ok(())
562 }
563 }
564
565 fn handle_paragraph(
566 &mut self,
567 id: Option<&str>,
568 alignment: Option<&TextAlignment>,
569 ) -> Result<()> {
570 if self.context.in_table_cell {
572 return Ok(());
573 }
574 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
577 return Ok(());
578 }
579 if !self.list_stack.is_empty()
583 && self
584 .list_stack
585 .last()
586 .is_some_and(|e| e.first_paragraph_consumed)
587 {
588 if self
589 .list_stack
590 .last()
591 .is_some_and(|e| e.content_state == ListContentState::Open)
592 {
593 self.json.close_array()?;
594 if let Some(e) = self.list_stack.last_mut() {
595 e.content_state = ListContentState::Closed;
596 }
597 }
598 if !self
599 .list_stack
600 .last()
601 .is_some_and(|e| e.children_array_open)
602 {
603 self.json.key("children").open_array()?;
604 if let Some(e) = self.list_stack.last_mut() {
605 e.children_array_open = true;
606 }
607 }
608 self.json.open_object()?;
609 self.json.key("type").value("paragraph")?;
610 self.write_paragraph_props(alignment)?;
611 self.json.key("content").open_array()?;
612 self.context.in_text_block = true;
613 return Ok(());
614 }
615 if !self.list_stack.is_empty() {
616 self.initialize_current_list_item_content(alignment)?;
617 return Ok(());
618 }
619 if self.blockquote_depth.is_positive() {
620 if self.context.blockquote_has_content {
621 self.handle_text("\n\n")?;
622 }
623 return Ok(());
624 }
625 self.json.open_object()?;
626 self.write_id(id)?;
627 self.json.key("type").value("paragraph")?;
628 self.write_paragraph_props(alignment)?;
629 self.json.key("content").open_array()?;
630 self.context.in_text_block = true;
631 Ok(())
632 }
633
634 fn write_paragraph_props(&mut self, alignment: Option<&TextAlignment>) -> Result<()> {
635 if let Some(value) = non_default_alignment_value(alignment) {
636 self.json
637 .key("props")
638 .object(|j| j.key("textAlignment").value(value))?;
639 }
640 Ok(())
641 }
642
643 fn handle_preformatted(&mut self, id: Option<&str>, syntax: Option<&str>) -> Result<()> {
644 self.json.open_object()?;
645 self.json.key("type").value("codeBlock")?;
646 self.write_id(id)?;
647 if let Some(lang) = syntax {
648 self.json
649 .key("props")
650 .object(|j| j.key("language").value(lang))?;
651 }
652 self.json.key("content").open_array()?;
653 self.context.in_text_block = true;
654 Ok(())
655 }
656
657 fn handle_start_link(&mut self, href: &str) -> Result<()> {
661 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
662 return Ok(());
663 }
664 if self.list_stack.last().is_some_and(|entry| {
665 entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
666 }) {
667 self.initialize_current_list_item_content(None)?;
668 }
669 if !self.context.in_text_block
670 && !self.context.in_table_cell
671 && !self.in_list_item_content()
672 {
673 return Ok(());
674 }
675 if self.in_link {
676 return Ok(());
677 }
678 if self.blockquote_depth.is_positive() {
679 self.context.blockquote_has_content = true;
680 }
681 self.json.open_object()?;
682 self.json.key("type").value("link")?;
683 self.json.key("href").value(href)?;
684 self.json.key("content").open_array()?;
685 self.in_link = true;
686 self.link_emitted_styled_text = false;
687 Ok(())
688 }
689
690 fn handle_start_text_style(&mut self, kind: TextStyleKind) -> Result<()> {
691 self.open_styles.push(kind);
692 Ok(())
693 }
694
695 fn handle_end_text_style(&mut self) -> Result<()> {
696 if self.open_styles.pop().is_none() {
697 return Err(Error::InvalidSequence {
698 expected: "StartTextStyle".to_string(),
699 found: "EndTextStyle".to_string(),
700 message: "cannot close text style because no text style is open".to_string(),
701 });
702 }
703 Ok(())
704 }
705
706 fn handle_start_list_item(
707 &mut self,
708 kind: ListKind,
709 id: Option<&str>,
710 level: u32,
711 start: Option<u64>,
712 ) -> Result<()> {
713 if self.context.in_table_cell || self.drop_inside_list_depth.is_positive() {
714 self.dropped_list_depth.inc();
715 return Ok(());
716 }
717 if self.blockquote_depth.is_positive() {
718 self.close_blockquote_for_sibling()?;
719 }
720 if self.list_stack.is_empty() {
721 self.close_for_block_sibling()?;
722 self.open_list_item_object(kind, id, level, start)?;
723 return Ok(());
724 }
725
726 let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
727
728 let effective_level = if level > stack_top_level.saturating_add(1) {
731 stack_top_level.saturating_add(1)
732 } else {
733 level
734 };
735
736 if effective_level > stack_top_level {
737 self.open_current_list_item_children()?;
738 self.open_list_item_object(kind, id, effective_level, start)?;
739 return Ok(());
740 }
741
742 if effective_level == stack_top_level {
743 self.close_current_list_item_object()?;
744 if self.list_stack.is_empty() {
745 self.close_for_block_sibling()?;
746 }
747 self.open_list_item_object(kind, id, effective_level, start)?;
748 return Ok(());
749 }
750
751 while let Some(top) = self.list_stack.last() {
754 if top.level < effective_level {
755 break;
756 }
757 self.close_current_list_item_object()?;
758 }
759 if self.list_stack.is_empty() {
760 self.close_for_block_sibling()?;
761 }
762 self.open_list_item_object(kind, id, effective_level, start)?;
763 Ok(())
764 }
765
766 fn handle_start_table(&mut self, id: Option<&str>) -> Result<()> {
767 drop_block_in_list_start!(self);
768 self.close_for_block_sibling()?;
769 self.json.open_object()?;
770 self.json.key("type").value("table")?;
771 self.write_id(id)?;
772 self.json.key("content").open_object()?;
773 self.json.key("type").value("tableContent")?;
774 self.json.key("columnWidths").array(|_| Ok(()))?;
775 self.json.key("rows").open_array()?;
776 self.table_depth.inc();
777 self.context.in_text_block = false;
778 Ok(())
779 }
780
781 fn handle_start_table_row(&mut self, id: Option<&str>) -> Result<()> {
782 if self.drop_inside_list_depth.is_positive() {
783 return Ok(());
784 }
785 self.json.open_object()?;
786 self.write_id(id)?;
787 self.json.key("cells").open_array()
788 }
789
790 fn handle_table_cell(&mut self, id: Option<&str>) -> Result<()> {
791 if self.drop_inside_list_depth.is_positive() {
792 return Ok(());
793 }
794 self.json.open_object()?;
795 self.json.key("type").value("tableCell")?;
796 self.write_id(id)?;
797 self.json.key("content").open_array()?;
798 self.context.in_table_cell = true;
799 self.context.in_text_block = false;
800 Ok(())
801 }
802
803 fn handle_text(&mut self, content: &str) -> Result<()> {
804 if self.drop_inside_list_depth.is_positive()
805 || self.dropped_list_depth.is_positive()
806 || (!self.context.in_text_block
807 && !self.context.in_table_cell
808 && !self.in_list_item_content())
809 {
810 return Ok(());
811 }
812 if self.blockquote_depth.is_positive() {
813 self.context.blockquote_has_content = true;
814 }
815 let mut bold = false;
816 let mut italic = false;
817 let mut code = false;
818 let mut strike = false;
819 let mut underline = false;
820 let mut text_color: Option<docspec_core::Color> = None;
821 let mut background_color: Option<docspec_core::Color> = None;
822
823 for kind in &self.open_styles {
824 match kind {
825 TextStyleKind::Bold => bold = true,
826 TextStyleKind::Italic => italic = true,
827 TextStyleKind::Code => code = true,
828 TextStyleKind::Strikethrough => strike = true,
829 TextStyleKind::Underline => underline = true,
830 TextStyleKind::Subscript => {
831 Self::omit_unsupported_text_style("subscript");
833 }
834 TextStyleKind::Superscript => {
835 Self::omit_unsupported_text_style("superscript");
837 }
838 TextStyleKind::TextColor(color) => text_color = Some(*color),
839 TextStyleKind::Mark(color) => background_color = Some(*color),
840 future_kind => {
841 Self::omit_future_text_style(future_kind);
843 }
844 }
845 }
846
847 self.json.object(|j| {
848 j.key("type").value("text")?;
849 j.key("text").value(content)?;
850 j.key("styles").object(|s| {
851 for (key, enabled) in [
852 ("bold", bold),
853 ("italic", italic),
854 ("code", code),
855 ("strike", strike),
856 ("underline", underline),
857 ] {
858 if enabled {
859 s.key(key).value(true)?;
860 }
861 }
862 if let Some(c) = text_color {
863 if let Some(name) = palette::nearest_text_color(&c) {
864 s.key("textColor").value(name)?;
865 }
866 }
867 if let Some(c) = background_color {
868 if let Some(name) = palette::nearest_background_color(&c) {
869 s.key("backgroundColor").value(name)?;
870 }
871 }
872 Ok(())
873 })
874 })?;
875 if self.in_link {
876 self.link_emitted_styled_text = true;
877 }
878 Ok(())
879 }
880
881 fn handle_text_event(&mut self, content: &str) -> Result<()> {
882 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
883 return Ok(());
884 }
885 if self.list_stack.last().is_some_and(|entry| {
887 entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
888 }) {
889 self.initialize_current_list_item_content(None)?;
890 }
891 if !self.context.in_text_block
892 && self.blockquote_depth.is_zero()
893 && !self.context.in_table_cell
894 && !self.in_list_item_content()
895 {
896 self.handle_paragraph(None, None)?;
897 }
898 self.handle_text(content)
899 }
900
901 fn in_any_list_item(&self) -> bool {
909 !self.list_stack.is_empty()
910 }
911
912 fn in_list_item_content(&self) -> bool {
913 self.list_stack
914 .last()
915 .is_some_and(|entry| entry.content_state == ListContentState::Open)
916 }
917
918 #[inline]
924 #[must_use]
925 pub fn new(writer: W) -> Self {
926 Self {
927 blockquote_depth: Depth::default(),
928 blockquote_force_closed_count: Depth::default(),
929 context: BlockContext::default(),
930 drop_inside_list_depth: Depth::default(),
931 dropped_list_depth: Depth::default(),
932 in_link: false,
933 json: JsonEmitter::new(StrusonBackend::new(writer)),
934 lifted_nested_events: Vec::new(),
935 link_emitted_styled_text: false,
936 list_stack: Vec::new(),
937 open_styles: Vec::new(),
938 table_depth: Depth::default(),
939 }
940 }
941
942 fn initialize_current_list_item_content(
943 &mut self,
944 alignment: Option<&TextAlignment>,
945 ) -> Result<()> {
946 let Some(current_entry) = self.list_stack.last() else {
947 return Ok(());
948 };
949 if current_entry.content_state != ListContentState::Pending {
950 return Ok(());
951 }
952 let kind = current_entry.kind;
953 let start = current_entry.start;
954 let alignment_value = non_default_alignment_value(alignment);
955 if alignment_value.is_some() || (kind == ListKind::Ordered && start.is_some()) {
956 self.json.key("props").object(|j| {
957 if let Some(value) = alignment_value {
958 j.key("textAlignment").value(value)?;
959 }
960 if kind == ListKind::Ordered {
961 if let Some(start_prop) = start {
962 j.key("start").value(start_prop)?;
963 }
964 }
965 Ok(())
966 })?;
967 }
968 self.json.key("content").open_array()?;
969 if let Some(entry) = self.list_stack.last_mut() {
970 entry.content_state = ListContentState::Open;
971 }
972 Ok(())
973 }
974
975 fn open_current_list_item_children(&mut self) -> Result<()> {
976 if self
977 .list_stack
978 .last()
979 .is_some_and(|entry| entry.content_state == ListContentState::Pending)
980 {
981 self.initialize_current_list_item_content(None)?;
982 }
983 let content_array_open = self
984 .list_stack
985 .last()
986 .is_some_and(|entry| entry.content_state == ListContentState::Open);
987 if content_array_open {
988 self.json.close_array()?;
989 if let Some(entry) = self.list_stack.last_mut() {
990 entry.content_state = ListContentState::Closed;
991 entry.first_paragraph_consumed = true;
992 }
993 }
994
995 let children_array_open = self
996 .list_stack
997 .last()
998 .is_some_and(|entry| entry.children_array_open);
999 if !children_array_open {
1000 self.json.key("children").open_array()?;
1001 if let Some(entry) = self.list_stack.last_mut() {
1002 entry.children_array_open = true;
1003 }
1004 }
1005 Ok(())
1006 }
1007
1008 fn open_list_item_object(
1009 &mut self,
1010 kind: ListKind,
1011 id: Option<&str>,
1012 level: u32,
1013 start: Option<u64>,
1014 ) -> Result<()> {
1015 self.json.open_object()?;
1016 self.write_id(id)?;
1017 let type_name = match kind {
1018 ListKind::Ordered => "numberedListItem",
1019 ListKind::Unordered => "bulletListItem",
1020 };
1021 self.json.key("type").value(type_name)?;
1022 let checked_start = start
1023 .map(|start_value| {
1024 u32::try_from(start_value).map_err(|err| Error::Other {
1025 message: format!("ordered list start value out of range: {start_value}: {err}"),
1026 })
1027 })
1028 .transpose()?;
1029 self.list_stack.push(ListStackEntry {
1030 children_array_open: false,
1031 content_state: ListContentState::Pending,
1032 first_paragraph_consumed: false,
1033 kind,
1034 level,
1035 start: checked_start,
1036 });
1037 Ok(())
1038 }
1039
1040 fn handle_end_document(&mut self) -> Result<()> {
1041 while !self.list_stack.is_empty() {
1042 self.close_current_list_item_object()?;
1043 }
1044 self.json.close_array()
1045 }
1046
1047 fn write_id(&mut self, id: Option<&str>) -> Result<()> {
1048 if let Some(id_val) = id {
1049 self.json.key("id").value(id_val)?;
1050 }
1051 Ok(())
1052 }
1053
1054 fn omit_unsupported_text_style(_style_name: &str) {}
1055
1056 fn omit_future_text_style(_style: &TextStyleKind) {}
1057
1058 fn should_buffer_for_lift(&self, event: &Event) -> bool {
1059 match event {
1060 Event::StartTable { .. } => self.table_depth.is_positive(),
1061 _ => self.table_depth.get() >= 2,
1062 }
1063 }
1064
1065 fn update_lift_depth(&mut self, event: &Event) {
1066 match event {
1067 Event::StartTable { .. } => self.table_depth.inc(),
1068 Event::EndTable => self.table_depth.dec(),
1069 _ => {}
1070 }
1071 }
1072
1073 fn is_outermost_table_close(&self, event: &Event) -> bool {
1074 matches!(event, Event::EndTable) && self.table_depth.get() == 1
1075 }
1076
1077 fn drain_lifted_nested_events(&mut self) -> Result<()> {
1078 let buffered = core::mem::take(&mut self.lifted_nested_events);
1079 for ev in buffered {
1080 self.handle_event(ev)?;
1081 }
1082 Ok(())
1083 }
1084}
1085
1086impl<W: Write> EventSink for BlockNoteWriter<W> {
1087 #[inline]
1088 fn finish(self) -> Result<()> {
1089 self.json.finish().map(|_| ())
1090 }
1091
1092 #[inline]
1093 fn handle_event(&mut self, event: Event) -> Result<()> {
1094 if self.should_buffer_for_lift(&event) {
1095 self.update_lift_depth(&event);
1096 self.lifted_nested_events.push(event);
1097 return Ok(());
1098 }
1099 let is_outermost_table_close = self.is_outermost_table_close(&event);
1100 let result = match event {
1101 Event::StartDocument { .. } => self.json.open_array(),
1102 Event::EndDocument => self.handle_end_document(),
1103 Event::StartHeading { level, id, .. } => {
1104 return_if_table_cell!(self);
1105 drop_block_in_list_start!(self);
1106 self.close_for_block_sibling()?;
1107 self.handle_heading(level, id.as_deref())
1108 }
1109 Event::EndHeading => {
1110 drop_block_in_list_end!(self);
1111 if !self.context.in_text_block {
1112 return Ok(());
1113 }
1114 close_text_block!(self)
1115 }
1116 Event::EndPreformatted => {
1117 drop_block_in_list_end!(self);
1118 return_if_table_cell!(self);
1119 if !self.context.in_text_block {
1120 return Ok(());
1121 }
1122 close_text_block!(self)
1123 }
1124 Event::StartParagraph { alignment, id } => {
1125 self.handle_paragraph(id.as_deref(), alignment.as_ref())
1126 }
1127 Event::EndParagraph => self.handle_end_paragraph(),
1128 Event::StartBlockQuote { id, .. } => {
1129 return_if_table_cell!(self);
1130 drop_block_in_list_start!(self);
1131 self.close_for_block_sibling()?;
1132 self.handle_blockquote(id.as_deref())
1133 }
1134 Event::EndBlockQuote => self.handle_end_blockquote(),
1135 Event::StartPreformatted { id, syntax, .. } => {
1136 return_if_table_cell!(self);
1137 drop_block_in_list_start!(self);
1138 self.close_for_block_sibling()?;
1139 self.handle_preformatted(id.as_deref(), syntax.as_deref())
1140 }
1141 Event::ThematicBreak { id, .. } => {
1142 return_if_table_cell!(self);
1143 if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1144 return Ok(());
1145 }
1146 self.close_for_block_sibling()?;
1147 self.handle_divider(id.as_deref())
1148 }
1149 Event::Text { content } => self.handle_text_event(&content),
1150 Event::StartTextStyle { kind, .. } => self.handle_start_text_style(kind),
1151 Event::EndTextStyle => self.handle_end_text_style(),
1152 Event::Image {
1153 source, alt, id, ..
1154 } => self.handle_image(source, alt, id.as_deref()),
1155 Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1156 Event::StartOrderedListItem {
1157 id, level, start, ..
1158 } => self.handle_start_list_item(ListKind::Ordered, id.as_deref(), level, start),
1159 Event::StartUnorderedListItem { id, level, .. } => {
1160 self.handle_start_list_item(ListKind::Unordered, id.as_deref(), level, None)
1161 }
1162 Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1163 Event::StartTable { id, .. } => self.handle_start_table(id.as_deref()),
1164 Event::EndTable => self.handle_end_table(),
1165 Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_deref()),
1166 Event::EndTableRow => self.handle_end_table_row(),
1167 Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1168 self.handle_table_cell(id.as_deref())
1169 }
1170 Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1171 Event::StartLink { href, .. } => self.handle_start_link(&href),
1172 Event::EndLink => self.handle_end_link(),
1173 Event::EndCaption
1174 | Event::EndDefinitionDetail
1175 | Event::EndDefinitionList
1176 | Event::EndDefinitionTerm
1177 | Event::EndFootnote
1178 | Event::FootnoteRef { .. }
1179 | Event::StartCaption { .. }
1180 | Event::StartDefinitionDetail { .. }
1181 | Event::StartDefinitionList { .. }
1182 | Event::StartDefinitionTerm { .. }
1183 | Event::StartFootnote { .. }
1184 | _ => Ok(()),
1185 };
1186 if is_outermost_table_close {
1187 result?;
1188 return self.drain_lifted_nested_events();
1189 }
1190 result
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn list_stack_empty_after_new() {
1200 let mut buf = Vec::new();
1201 let writer = BlockNoteWriter::new(&mut buf);
1202 assert!(writer.list_stack.is_empty());
1203 }
1204
1205 #[test]
1206 fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1207 let mut buf = Vec::new();
1211 let mut writer = BlockNoteWriter::new(&mut buf);
1212 assert!(writer
1213 .handle_event(Event::StartDocument {
1214 id: None,
1215 language: None,
1216 metadata: None,
1217 })
1218 .is_ok());
1219 assert!(writer
1220 .handle_event(Event::StartUnorderedListItem {
1221 id: None,
1222 level: 0,
1223 style_type: docspec_core::ListStyleType::Disc,
1224 })
1225 .is_ok());
1226 assert!(
1227 !writer.list_stack.is_empty(),
1228 "list_stack must be non-empty before calling close_for_block_sibling"
1229 );
1230 assert!(writer.close_for_block_sibling().is_ok());
1231 assert!(
1232 writer.list_stack.is_empty(),
1233 "close_for_block_sibling must drain list_stack via close_open_list_items"
1234 );
1235 assert!(writer.handle_event(Event::EndDocument).is_ok());
1236 assert!(writer.finish().is_ok());
1237 }
1238}