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