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_table_cell(&mut self, id: Option<&str>) -> Result<()> {
800 if self.drop_inside_list_depth.is_positive() {
801 return Ok(());
802 }
803 self.json.open_object()?;
804 self.json.key("type").value("tableCell")?;
805 self.write_id(id)?;
806 self.json.key("content").open_array()?;
807 self.context.in_table_cell = true;
808 self.context.in_text_block = false;
809 Ok(())
810 }
811
812 fn handle_text(&mut self, content: &str) -> Result<()> {
813 if self.drop_inside_list_depth.is_positive()
814 || self.dropped_list_depth.is_positive()
815 || (!self.context.in_text_block
816 && !self.context.in_table_cell
817 && !self.in_list_item_content())
818 {
819 return Ok(());
820 }
821 if self.blockquote_depth.is_positive() {
822 self.context.blockquote_has_content = true;
823 }
824 let mut bold = false;
825 let mut italic = false;
826 let mut code = false;
827 let mut strike = false;
828 let mut underline = false;
829 let mut text_color: Option<docspec_core::Color> = None;
830 let mut background_color: Option<docspec_core::Color> = None;
831
832 for kind in &self.open_styles {
833 match kind {
834 TextStyleKind::Bold => bold = true,
835 TextStyleKind::Italic => italic = true,
836 TextStyleKind::Code => code = true,
837 TextStyleKind::Strikethrough => strike = true,
838 TextStyleKind::Underline => underline = true,
839 TextStyleKind::Subscript => {
840 Self::omit_unsupported_text_style("subscript");
842 }
843 TextStyleKind::Superscript => {
844 Self::omit_unsupported_text_style("superscript");
846 }
847 TextStyleKind::TextColor(color) => text_color = Some(*color),
848 TextStyleKind::Mark(color) => background_color = Some(*color),
849 future_kind => {
850 Self::omit_future_text_style(future_kind);
852 }
853 }
854 }
855
856 self.json.object(|j| {
857 j.key("type").value("text")?;
858 j.key("text").value(content)?;
859 j.key("styles").object(|s| {
860 for (key, enabled) in [
861 ("bold", bold),
862 ("italic", italic),
863 ("code", code),
864 ("strike", strike),
865 ("underline", underline),
866 ] {
867 if enabled {
868 s.key(key).value(true)?;
869 }
870 }
871 if let Some(c) = text_color {
872 if let Some(name) = palette::nearest_text_color(&c) {
873 s.key("textColor").value(name)?;
874 }
875 }
876 if let Some(c) = background_color {
877 if let Some(name) = palette::nearest_background_color(&c) {
878 s.key("backgroundColor").value(name)?;
879 }
880 }
881 Ok(())
882 })
883 })?;
884 if self.in_link {
885 self.link_emitted_styled_text = true;
886 }
887 Ok(())
888 }
889
890 fn handle_text_event(&mut self, content: &str) -> Result<()> {
891 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
892 return Ok(());
893 }
894 if self.list_stack.last().is_some_and(|entry| {
896 entry.content_state == ListContentState::Pending && !entry.first_paragraph_consumed
897 }) {
898 self.initialize_current_list_item_content(None)?;
899 }
900 if !self.context.in_text_block
901 && self.blockquote_depth.is_zero()
902 && !self.context.in_table_cell
903 && !self.in_list_item_content()
904 {
905 self.handle_paragraph(None, None)?;
906 }
907 self.handle_text(content)
908 }
909
910 fn in_any_list_item(&self) -> bool {
918 !self.list_stack.is_empty()
919 }
920
921 fn in_list_item_content(&self) -> bool {
922 self.list_stack
923 .last()
924 .is_some_and(|entry| entry.content_state == ListContentState::Open)
925 }
926
927 #[inline]
933 #[must_use]
934 pub fn new(writer: W) -> Self {
935 Self {
936 blockquote_depth: Depth::default(),
937 blockquote_force_closed_count: Depth::default(),
938 context: BlockContext::default(),
939 drop_inside_list_depth: Depth::default(),
940 dropped_list_depth: Depth::default(),
941 in_link: false,
942 json: JsonEmitter::new(StrusonBackend::new(writer)),
943 lifted_nested_events: Vec::new(),
944 link_emitted_styled_text: false,
945 list_stack: Vec::new(),
946 open_styles: Vec::new(),
947 table_depth: Depth::default(),
948 }
949 }
950
951 fn initialize_current_list_item_content(
952 &mut self,
953 alignment: Option<&TextAlignment>,
954 ) -> Result<()> {
955 let Some(current_entry) = self.list_stack.last() else {
956 return Ok(());
957 };
958 if current_entry.content_state != ListContentState::Pending {
959 return Ok(());
960 }
961 let kind = current_entry.kind;
962 let start = current_entry.start;
963 let alignment_value = non_default_alignment_value(alignment);
964 if alignment_value.is_some() || (kind == ListKind::Ordered && start.is_some()) {
965 self.json.key("props").object(|j| {
966 if let Some(value) = alignment_value {
967 j.key("textAlignment").value(value)?;
968 }
969 if kind == ListKind::Ordered {
970 if let Some(start_prop) = start {
971 j.key("start").value(start_prop)?;
972 }
973 }
974 Ok(())
975 })?;
976 }
977 self.json.key("content").open_array()?;
978 if let Some(entry) = self.list_stack.last_mut() {
979 entry.content_state = ListContentState::Open;
980 }
981 Ok(())
982 }
983
984 fn open_current_list_item_children(&mut self) -> Result<()> {
985 if self
986 .list_stack
987 .last()
988 .is_some_and(|entry| entry.content_state == ListContentState::Pending)
989 {
990 self.initialize_current_list_item_content(None)?;
991 }
992 let content_array_open = self
993 .list_stack
994 .last()
995 .is_some_and(|entry| entry.content_state == ListContentState::Open);
996 if content_array_open {
997 self.json.close_array()?;
998 if let Some(entry) = self.list_stack.last_mut() {
999 entry.content_state = ListContentState::Closed;
1000 entry.first_paragraph_consumed = true;
1001 }
1002 }
1003
1004 let children_array_open = self
1005 .list_stack
1006 .last()
1007 .is_some_and(|entry| entry.children_array_open);
1008 if !children_array_open {
1009 self.json.key("children").open_array()?;
1010 if let Some(entry) = self.list_stack.last_mut() {
1011 entry.children_array_open = true;
1012 }
1013 }
1014 Ok(())
1015 }
1016
1017 fn open_list_item_object(
1018 &mut self,
1019 kind: ListKind,
1020 id: Option<&str>,
1021 level: u32,
1022 start: Option<u64>,
1023 ) -> Result<()> {
1024 self.json.open_object()?;
1025 self.write_id(id)?;
1026 let type_name = match kind {
1027 ListKind::Ordered => "numberedListItem",
1028 ListKind::Unordered => "bulletListItem",
1029 };
1030 self.json.key("type").value(type_name)?;
1031 let checked_start = start
1032 .map(|start_value| {
1033 u32::try_from(start_value).map_err(|err| Error::Other {
1034 message: format!("ordered list start value out of range: {start_value}: {err}"),
1035 })
1036 })
1037 .transpose()?;
1038 self.list_stack.push(ListStackEntry {
1039 children_array_open: false,
1040 content_state: ListContentState::Pending,
1041 first_paragraph_consumed: false,
1042 kind,
1043 level,
1044 start: checked_start,
1045 });
1046 Ok(())
1047 }
1048
1049 fn handle_end_document(&mut self) -> Result<()> {
1050 while !self.list_stack.is_empty() {
1051 self.close_current_list_item_object()?;
1052 }
1053 self.json.close_array()
1054 }
1055
1056 fn write_id(&mut self, id: Option<&str>) -> Result<()> {
1057 if let Some(id_val) = id {
1058 self.json.key("id").value(id_val)?;
1059 }
1060 Ok(())
1061 }
1062
1063 fn omit_unsupported_text_style(_style_name: &str) {}
1064
1065 fn omit_future_text_style(_style: &TextStyleKind) {}
1066
1067 fn should_buffer_for_lift(&self, event: &Event) -> bool {
1068 match event {
1069 Event::StartTable { .. } => self.table_depth.is_positive(),
1070 Event::Image { .. } if self.context.in_table_cell => true,
1071 _ => self.table_depth.get() >= 2,
1072 }
1073 }
1074
1075 fn update_lift_depth(&mut self, event: &Event) {
1076 match event {
1077 Event::StartTable { .. } => self.table_depth.inc(),
1078 Event::EndTable => self.table_depth.dec(),
1079 _ => {}
1080 }
1081 }
1082
1083 fn is_outermost_table_close(&self, event: &Event) -> bool {
1084 matches!(event, Event::EndTable) && self.table_depth.get() == 1
1085 }
1086
1087 fn drain_lifted_nested_events(&mut self) -> Result<()> {
1088 let buffered = core::mem::take(&mut self.lifted_nested_events);
1089 for ev in buffered {
1090 self.handle_event(ev)?;
1091 }
1092 Ok(())
1093 }
1094}
1095
1096impl<W: Write> EventSink for BlockNoteWriter<W> {
1097 #[inline]
1098 fn finish(self) -> Result<()> {
1099 self.json.finish().map(|_| ())
1100 }
1101
1102 #[inline]
1103 fn handle_event(&mut self, event: Event) -> Result<()> {
1104 if self.should_buffer_for_lift(&event) {
1105 self.update_lift_depth(&event);
1106 self.lifted_nested_events.push(event);
1107 return Ok(());
1108 }
1109 let is_outermost_table_close = self.is_outermost_table_close(&event);
1110 let result = match event {
1111 Event::StartDocument { .. } => self.json.open_array(),
1112 Event::EndDocument => self.handle_end_document(),
1113 Event::StartHeading { level, id, .. } => {
1114 return_if_table_cell!(self);
1115 drop_block_in_list_start!(self);
1116 self.close_for_block_sibling()?;
1117 self.handle_heading(level, id.as_deref())
1118 }
1119 Event::EndHeading => {
1120 drop_block_in_list_end!(self);
1121 if !self.context.in_text_block {
1122 return Ok(());
1123 }
1124 close_text_block!(self)
1125 }
1126 Event::EndPreformatted => {
1127 drop_block_in_list_end!(self);
1128 return_if_table_cell!(self);
1129 if !self.context.in_text_block {
1130 return Ok(());
1131 }
1132 close_text_block!(self)
1133 }
1134 Event::StartParagraph { alignment, id } => {
1135 self.handle_paragraph(id.as_deref(), alignment.as_ref())
1136 }
1137 Event::EndParagraph => self.handle_end_paragraph(),
1138 Event::StartBlockQuote { id, .. } => {
1139 return_if_table_cell!(self);
1140 drop_block_in_list_start!(self);
1141 self.close_for_block_sibling()?;
1142 self.handle_blockquote(id.as_deref())
1143 }
1144 Event::EndBlockQuote => self.handle_end_blockquote(),
1145 Event::StartPreformatted { id, syntax, .. } => {
1146 return_if_table_cell!(self);
1147 drop_block_in_list_start!(self);
1148 self.close_for_block_sibling()?;
1149 self.handle_preformatted(id.as_deref(), syntax.as_deref())
1150 }
1151 Event::ThematicBreak { id, .. } => {
1152 return_if_table_cell!(self);
1153 if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1154 return Ok(());
1155 }
1156 self.close_for_block_sibling()?;
1157 self.handle_divider(id.as_deref())
1158 }
1159 Event::Text { content } => self.handle_text_event(&content),
1160 Event::StartTextStyle { kind, .. } => self.handle_start_text_style(kind),
1161 Event::EndTextStyle => self.handle_end_text_style(),
1162 Event::Image {
1163 source, alt, id, ..
1164 } => self.handle_image(source, alt, id.as_deref()),
1165 Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1166 Event::StartOrderedListItem {
1167 id, level, start, ..
1168 } => self.handle_start_list_item(ListKind::Ordered, id.as_deref(), level, start),
1169 Event::StartUnorderedListItem { id, level, .. } => {
1170 self.handle_start_list_item(ListKind::Unordered, id.as_deref(), level, None)
1171 }
1172 Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1173 Event::StartTable { id, .. } => self.handle_start_table(id.as_deref()),
1174 Event::EndTable => self.handle_end_table(),
1175 Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_deref()),
1176 Event::EndTableRow => self.handle_end_table_row(),
1177 Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1178 self.handle_table_cell(id.as_deref())
1179 }
1180 Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1181 Event::StartLink { href, .. } => self.handle_start_link(&href),
1182 Event::EndLink => self.handle_end_link(),
1183 Event::EndCaption
1184 | Event::EndDefinitionDetail
1185 | Event::EndDefinitionList
1186 | Event::EndDefinitionTerm
1187 | Event::EndFootnote
1188 | Event::FootnoteRef { .. }
1189 | Event::StartCaption { .. }
1190 | Event::StartDefinitionDetail { .. }
1191 | Event::StartDefinitionList { .. }
1192 | Event::StartDefinitionTerm { .. }
1193 | Event::StartFootnote { .. }
1194 | _ => Ok(()),
1195 };
1196 if is_outermost_table_close {
1197 result?;
1198 return self.drain_lifted_nested_events();
1199 }
1200 result
1201 }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207
1208 #[test]
1209 fn list_stack_empty_after_new() {
1210 let mut buf = Vec::new();
1211 let writer = BlockNoteWriter::new(&mut buf);
1212 assert!(writer.list_stack.is_empty());
1213 }
1214
1215 #[test]
1216 fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1217 let mut buf = Vec::new();
1221 let mut writer = BlockNoteWriter::new(&mut buf);
1222 assert!(writer
1223 .handle_event(Event::StartDocument {
1224 id: None,
1225 language: None,
1226 metadata: None,
1227 })
1228 .is_ok());
1229 assert!(writer
1230 .handle_event(Event::StartUnorderedListItem {
1231 id: None,
1232 level: 0,
1233 style_type: docspec_core::ListStyleType::Disc,
1234 })
1235 .is_ok());
1236 assert!(
1237 !writer.list_stack.is_empty(),
1238 "list_stack must be non-empty before calling close_for_block_sibling"
1239 );
1240 assert!(writer.close_for_block_sibling().is_ok());
1241 assert!(
1242 writer.list_stack.is_empty(),
1243 "close_for_block_sibling must drain list_stack via close_open_list_items"
1244 );
1245 assert!(writer.handle_event(Event::EndDocument).is_ok());
1246 assert!(writer.finish().is_ok());
1247 }
1248}