1use std::io::Write;
148
149use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
150use base64::write::EncoderWriter as Base64Encoder;
151use docspec_core::{AssetProvider, Depth, Error, Event, EventSink, ImageSource, Result, TextStyle};
152use docspec_json::{JsonEmitter, Null, StrusonBackend};
153
154macro_rules! close_text_block {
155 ($writer:expr) => {{
156 $writer.close_open_link_if_any()?;
157 $writer.close_content_block()?;
158 $writer.context.in_text_block = false;
159 Ok(())
160 }};
161}
162
163macro_rules! return_if_table_cell {
164 ($writer:expr) => {
165 if $writer.context.in_table_cell {
166 return Ok(());
167 }
168 };
169}
170
171macro_rules! drop_block_in_list_start {
172 ($writer:expr) => {
173 if $writer.in_any_list_item() || $writer.drop_inside_list_depth.is_positive() {
174 $writer.drop_inside_list_depth.inc();
175 return Ok(());
176 }
177 };
178}
179
180macro_rules! drop_block_in_list_end {
181 ($writer:expr) => {
182 if $writer.drop_inside_list_depth.is_positive() {
183 $writer.drop_inside_list_depth.dec();
184 return Ok(());
185 }
186 };
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum ListKind {
192 Ordered,
194 Unordered,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200struct ListStackEntry {
201 children_array_open: bool,
203 content_array_open: bool,
205 first_paragraph_consumed: bool,
207 kind: ListKind,
209 level: u32,
211}
212
213#[derive(Default)]
214struct BlockContext {
215 blockquote_has_content: bool,
216 in_table_cell: bool,
217 in_text_block: bool,
218}
219
220pub struct BlockNoteWriter<'a, W: Write> {
232 assets: Option<&'a dyn AssetProvider>,
233 blockquote_depth: Depth,
234 blockquote_force_closed_count: Depth,
235 context: BlockContext,
236 drop_inside_list_depth: Depth,
237 dropped_list_depth: Depth,
238 in_link: bool,
240 json: JsonEmitter<StrusonBackend<W>>,
241 link_emitted_styled_text: bool,
243 list_stack: Vec<ListStackEntry>,
244 table_depth: Depth,
245}
246
247impl<'a, W: Write> BlockNoteWriter<'a, W> {
248 fn close_blockquote_for_sibling(&mut self) -> Result<()> {
249 self.close_open_link_if_any()?;
250 self.close_content_block()?;
251 self.blockquote_depth.dec();
252 self.blockquote_force_closed_count.inc();
253 self.context.in_text_block = self.blockquote_depth.is_positive();
254 Ok(())
255 }
256
257 fn close_content_block(&mut self) -> Result<()> {
258 self.json.close_array()?;
259 self.json.key("children").array(|_| Ok(()))?;
260 self.json.close_object()
261 }
262
263 fn close_current_list_item_object(&mut self) -> Result<()> {
264 let popped_entry = self.list_stack.pop();
265 if let Some(list_entry) = popped_entry {
266 if list_entry.content_array_open {
267 self.close_open_link_if_any()?;
268 self.json.close_array()?;
269 }
270 if list_entry.children_array_open {
271 self.json.close_array()?;
272 } else {
273 self.json.key("children").array(|_| Ok(()))?;
274 }
275 self.json.close_object()?;
276 }
277 Ok(())
278 }
279
280 fn close_for_block_sibling(&mut self) -> Result<()> {
281 if !self.list_stack.is_empty() {
282 self.close_open_list_items()?;
283 }
284 if self.blockquote_depth.is_positive() {
285 return self.close_blockquote_for_sibling();
286 }
287 if self.context.in_text_block {
288 self.close_open_link_if_any()?;
289 self.close_content_block()?;
290 self.context.in_text_block = false;
291 }
292 Ok(())
293 }
294
295 fn close_open_link_if_any(&mut self) -> Result<()> {
301 if self.in_link {
302 self.handle_end_link()?;
303 }
304 Ok(())
305 }
306
307 fn close_open_list_items(&mut self) -> Result<()> {
308 while !self.list_stack.is_empty() {
309 self.close_current_list_item_object()?;
310 }
311 Ok(())
312 }
313
314 fn encode_asset_as_data_uri(&self, asset_id: &str) -> Result<String> {
321 let provider = self.assets.ok_or_else(|| Error::Other {
322 message: "no AssetProvider configured".to_string(),
323 })?;
324 let content_type = provider
325 .content_type(asset_id)
326 .ok_or_else(|| Error::Other {
327 message: format!("asset not found: {asset_id}"),
328 })?;
329 let prefix = format!("data:{content_type};base64,");
330 let mut data_uri = Vec::with_capacity(prefix.len());
331 data_uri.extend_from_slice(prefix.as_bytes());
332 {
333 let mut enc = Base64Encoder::new(&mut data_uri, &BASE64_STANDARD);
334 provider
335 .stream_to(asset_id, &mut enc)
336 .ok_or_else(|| Error::Other {
337 message: format!("asset not found: {asset_id}"),
338 })?
339 .map_err(Error::from)?;
340 enc.finish().map_err(Error::from)?
341 };
342 String::from_utf8(data_uri).map_err(|e| Error::Other {
343 message: format!("base64 encoding produced invalid UTF-8: {e}"),
344 })
345 }
346
347 fn handle_blockquote(&mut self, id: Option<&String>) -> Result<()> {
348 self.json.open_object()?;
349 self.json.key("type").value("quote")?;
350 self.write_id(id)?;
351 self.json.key("content").open_array()?;
352 self.blockquote_depth.inc();
353 self.context.blockquote_has_content = false;
354 self.context.in_text_block = true;
355 Ok(())
356 }
357
358 fn handle_divider(&mut self, id: Option<&String>) -> Result<()> {
359 self.json.object(|j| {
360 j.key("type").value("divider")?;
361 if let Some(id_val) = id {
362 j.key("id").value(id_val.as_str())?;
363 }
364 Ok(())
365 })
366 }
367
368 fn handle_end_link(&mut self) -> Result<()> {
374 if !self.in_link {
375 return Ok(());
376 }
377 if !self.link_emitted_styled_text {
378 self.json.open_object()?;
379 self.json.key("type").value("text")?;
380 self.json.key("text").value("")?;
381 self.json.key("styles").open_object()?;
382 self.json.close_object()?;
383 self.json.close_object()?;
384 }
385 self.json.close_array()?;
386 self.json.close_object()?;
387 self.in_link = false;
388 self.link_emitted_styled_text = false;
389 Ok(())
390 }
391
392 fn handle_end_list_item(&mut self) -> Result<()> {
393 if self.dropped_list_depth.is_positive() {
394 self.dropped_list_depth.dec();
395 return Ok(());
396 }
397 if self.list_stack.is_empty() {
398 return Ok(());
399 }
400 self.close_current_list_item_object()
401 }
402
403 fn handle_end_paragraph(&mut self) -> Result<()> {
404 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
405 return Ok(());
406 }
407 if !self.list_stack.is_empty()
408 && self
409 .list_stack
410 .last()
411 .is_some_and(|e| e.first_paragraph_consumed)
412 && self.context.in_text_block
413 {
414 self.close_open_link_if_any()?;
415 self.json.close_array()?;
416 self.json.key("children").array(|_| Ok(()))?;
417 self.json.close_object()?;
418 self.context.in_text_block = false;
419 return Ok(());
420 }
421 if self.in_list_item_content() {
422 if let Some(entry) = self.list_stack.last_mut() {
423 entry.first_paragraph_consumed = true;
424 }
425 return Ok(());
426 }
427 if self.blockquote_depth.is_positive()
428 || !self.context.in_text_block
429 || self.context.in_table_cell
430 {
431 return Ok(());
432 }
433 close_text_block!(self)
434 }
435
436 fn handle_end_table(&mut self) -> Result<()> {
437 drop_block_in_list_end!(self);
438 if self.table_depth.is_zero() {
439 return Ok(());
440 }
441 if self.table_depth.get() > 1 {
442 self.table_depth.dec();
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() || self.table_depth.get() > 1 {
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() || self.table_depth.get() > 1 {
466 return Ok(());
467 }
468 self.json.close_array()?;
469 self.json.close_object()
470 }
471
472 fn handle_heading(&mut self, level: u8, id: Option<&String>) -> Result<()> {
473 self.json.open_object()?;
474 self.json.key("type").value("heading")?;
475 self.write_id(id)?;
476 self.json.key("props").object(|j| {
477 j.key("level").value(level)?;
478 j.key("textAlignment").value("left")
479 })?;
480 self.json.key("content").open_array()?;
481 self.context.in_text_block = true;
482 Ok(())
483 }
484
485 fn handle_image(
486 &mut self,
487 source: ImageSource,
488 alt: Option<String>,
489 id: Option<&String>,
490 ) -> Result<()> {
491 if self.context.in_table_cell || self.drop_inside_list_depth.is_positive() {
492 return Ok(());
493 }
494 if self.in_any_list_item() {
495 return Ok(());
496 }
497 self.close_for_block_sibling()?;
498 let url = match source {
499 ImageSource::Uri { uri } => uri,
500 ImageSource::Asset { asset_id } => self.encode_asset_as_data_uri(&asset_id)?,
501 _ => return Ok(()),
502 };
503 let caption = alt.unwrap_or_default();
504
505 self.json.object(|j| {
506 if let Some(id_val) = id {
507 j.key("id").value(id_val.as_str())?;
508 }
509 j.key("type").value("image")?;
510 j.key("props").object(|p| {
511 p.key("url").value(url.as_str())?;
512 p.key("caption").value(caption.as_str())
513 })?;
514 j.key("content").value(Null)?;
515 j.key("children").array(|_| Ok(()))
516 })
517 }
518
519 fn handle_line_break(&mut self) -> Result<()> {
520 if self.drop_inside_list_depth.is_positive() {
521 return Ok(());
522 }
523 if (self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content())
524 && self.table_depth.get() <= 1
525 {
526 self.handle_text("\n", &TextStyle::default())
527 } else {
528 Ok(())
529 }
530 }
531
532 fn handle_paragraph(&mut self, id: Option<&String>) -> Result<()> {
533 if self.context.in_table_cell {
535 return Ok(());
536 }
537 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
540 return Ok(());
541 }
542 if !self.list_stack.is_empty()
546 && self
547 .list_stack
548 .last()
549 .is_some_and(|e| e.first_paragraph_consumed)
550 {
551 if self.list_stack.last().is_some_and(|e| e.content_array_open) {
552 self.json.close_array()?;
553 if let Some(e) = self.list_stack.last_mut() {
554 e.content_array_open = false;
555 }
556 }
557 if !self
558 .list_stack
559 .last()
560 .is_some_and(|e| e.children_array_open)
561 {
562 self.json.key("children").open_array()?;
563 if let Some(e) = self.list_stack.last_mut() {
564 e.children_array_open = true;
565 }
566 }
567 self.json.open_object()?;
568 self.json.key("type").value("paragraph")?;
569 self.json
570 .key("props")
571 .object(|j| j.key("textAlignment").value("left"))?;
572 self.json.key("content").open_array()?;
573 self.context.in_text_block = true;
574 return Ok(());
575 }
576 if self.in_list_item_content() {
577 return Ok(());
578 }
579 if !self.list_stack.is_empty() {
580 self.close_open_list_items()?;
581 }
582 if self.blockquote_depth.is_positive() {
583 if self.context.blockquote_has_content {
584 self.handle_text("\n\n", &TextStyle::default())?;
585 }
586 return Ok(());
587 }
588 self.json.open_object()?;
589 self.write_id(id)?;
590 self.json.key("type").value("paragraph")?;
591 self.json
592 .key("props")
593 .object(|j| j.key("textAlignment").value("left"))?;
594 self.json.key("content").open_array()?;
595 self.context.in_text_block = true;
596 Ok(())
597 }
598
599 fn handle_preformatted(&mut self, id: Option<&String>, syntax: Option<&String>) -> Result<()> {
600 self.json.open_object()?;
601 self.json.key("type").value("codeBlock")?;
602 self.write_id(id)?;
603 if let Some(lang) = syntax {
604 self.json
605 .key("props")
606 .object(|j| j.key("language").value(lang.as_str()))?;
607 }
608 self.json.key("content").open_array()?;
609 self.context.in_text_block = true;
610 Ok(())
611 }
612
613 fn handle_start_link(&mut self, href: &str) -> Result<()> {
617 if self.drop_inside_list_depth.is_positive()
618 || self.dropped_list_depth.is_positive()
619 || (!self.context.in_text_block
620 && !self.context.in_table_cell
621 && !self.in_list_item_content())
622 || self.table_depth.get() > 1
623 {
624 return Ok(());
625 }
626 if self.in_link {
627 return Ok(());
628 }
629 if self.blockquote_depth.is_positive() {
630 self.context.blockquote_has_content = true;
631 }
632 self.json.open_object()?;
633 self.json.key("type").value("link")?;
634 self.json.key("href").value(href)?;
635 self.json.key("content").open_array()?;
636 self.in_link = true;
637 self.link_emitted_styled_text = false;
638 Ok(())
639 }
640
641 fn handle_start_list_item(
642 &mut self,
643 kind: ListKind,
644 id: Option<&String>,
645 level: u32,
646 start: Option<u64>,
647 ) -> Result<()> {
648 if self.context.in_table_cell
649 || self.table_depth.get() > 1
650 || self.drop_inside_list_depth.is_positive()
651 {
652 self.dropped_list_depth.inc();
653 return Ok(());
654 }
655 if self.blockquote_depth.is_positive() {
656 self.close_blockquote_for_sibling()?;
657 }
658 if self.list_stack.is_empty() {
659 self.close_for_block_sibling()?;
660 self.open_list_item_object(kind, id, level, start)?;
661 return Ok(());
662 }
663
664 let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
665
666 let effective_level = if level > stack_top_level.saturating_add(1) {
669 stack_top_level.saturating_add(1)
670 } else {
671 level
672 };
673
674 if effective_level > stack_top_level {
675 self.open_current_list_item_children()?;
676 self.open_list_item_object(kind, id, effective_level, start)?;
677 return Ok(());
678 }
679
680 if effective_level == stack_top_level {
681 self.close_current_list_item_object()?;
682 if self.list_stack.is_empty() {
683 self.close_for_block_sibling()?;
684 }
685 self.open_list_item_object(kind, id, effective_level, start)?;
686 return Ok(());
687 }
688
689 while let Some(top) = self.list_stack.last() {
692 if top.level < effective_level {
693 break;
694 }
695 self.close_current_list_item_object()?;
696 }
697 if self.list_stack.is_empty() {
698 self.close_for_block_sibling()?;
699 }
700 self.open_list_item_object(kind, id, effective_level, start)?;
701 Ok(())
702 }
703
704 fn handle_start_table(&mut self, id: Option<&String>) -> Result<()> {
705 drop_block_in_list_start!(self);
706 if self.table_depth.is_positive() {
707 self.table_depth.inc();
708 return Ok(());
709 }
710 self.close_for_block_sibling()?;
711 self.json.open_object()?;
712 self.json.key("type").value("table")?;
713 self.write_id(id)?;
714 self.json
715 .key("props")
716 .object(|p| p.key("textColor").value("default"))?;
717 self.json.key("content").open_object()?;
718 self.json.key("type").value("tableContent")?;
719 self.json.key("columnWidths").array(|_| Ok(()))?;
720 self.json.key("rows").open_array()?;
721 self.table_depth.inc();
722 self.context.in_text_block = false;
723 Ok(())
724 }
725
726 fn handle_start_table_row(&mut self, id: Option<&String>) -> Result<()> {
727 if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
728 return Ok(());
729 }
730 self.json.open_object()?;
731 self.write_id(id)?;
732 self.json.key("cells").open_array()
733 }
734
735 fn handle_table_cell(&mut self, id: Option<&String>) -> Result<()> {
736 if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
737 return Ok(());
738 }
739 self.json.open_object()?;
740 self.json.key("type").value("tableCell")?;
741 self.write_id(id)?;
742 self.json.key("props").object(|p| {
743 p.key("backgroundColor").value("default")?;
744 p.key("textColor").value("default")?;
745 p.key("textAlignment").value("left")
746 })?;
747 self.json.key("content").open_array()?;
748 self.context.in_table_cell = true;
749 self.context.in_text_block = false;
750 Ok(())
751 }
752
753 fn handle_text(&mut self, content: &str, style: &TextStyle) -> Result<()> {
754 if self.drop_inside_list_depth.is_positive()
755 || self.dropped_list_depth.is_positive()
756 || (!self.context.in_text_block
757 && !self.context.in_table_cell
758 && !self.in_list_item_content())
759 || self.table_depth.get() > 1
760 {
761 return Ok(());
762 }
763 if self.blockquote_depth.is_positive() {
764 self.context.blockquote_has_content = true;
765 }
766 self.json.object(|j| {
767 j.key("type").value("text")?;
768 j.key("text").value(content)?;
769 j.key("styles").object(|s| {
770 for (key, enabled) in [
771 ("bold", style.bold),
772 ("italic", style.italic),
773 ("code", style.code),
774 ("strike", style.strikethrough),
775 ("underline", style.underline),
776 ] {
777 if enabled {
778 s.key(key).value(true)?;
779 }
780 }
781 Ok(())
782 })
783 })?;
784 if self.in_link {
785 self.link_emitted_styled_text = true;
786 }
787 Ok(())
788 }
789
790 fn handle_text_event(&mut self, content: &str, style: &TextStyle) -> Result<()> {
791 if !self.context.in_text_block
793 && self.blockquote_depth.is_zero()
794 && !self.context.in_table_cell
795 && !self.in_list_item_content()
796 {
797 self.handle_paragraph(None)?;
798 }
799 self.handle_text(content, style)
800 }
801
802 fn in_any_list_item(&self) -> bool {
810 !self.list_stack.is_empty()
811 }
812
813 fn in_list_item_content(&self) -> bool {
814 self.list_stack
815 .last()
816 .is_some_and(|entry| entry.content_array_open)
817 }
818
819 #[inline]
825 #[must_use]
826 pub fn new(writer: W) -> Self {
827 Self {
828 assets: None,
829 blockquote_depth: Depth::default(),
830 blockquote_force_closed_count: Depth::default(),
831 context: BlockContext::default(),
832 drop_inside_list_depth: Depth::default(),
833 dropped_list_depth: Depth::default(),
834 in_link: false,
835 json: JsonEmitter::new(StrusonBackend::new(writer)),
836 link_emitted_styled_text: false,
837 list_stack: Vec::new(),
838 table_depth: Depth::default(),
839 }
840 }
841
842 fn open_current_list_item_children(&mut self) -> Result<()> {
843 let content_array_open = self
844 .list_stack
845 .last()
846 .is_some_and(|entry| entry.content_array_open);
847 if content_array_open {
848 self.json.close_array()?;
849 if let Some(entry) = self.list_stack.last_mut() {
850 entry.content_array_open = false;
851 }
852 }
853
854 let children_array_open = self
855 .list_stack
856 .last()
857 .is_some_and(|entry| entry.children_array_open);
858 if !children_array_open {
859 self.json.key("children").open_array()?;
860 if let Some(entry) = self.list_stack.last_mut() {
861 entry.children_array_open = true;
862 }
863 }
864 Ok(())
865 }
866
867 fn open_list_item_object(
868 &mut self,
869 kind: ListKind,
870 id: Option<&String>,
871 level: u32,
872 start: Option<u64>,
873 ) -> Result<()> {
874 self.json.open_object()?;
875 self.write_id(id)?;
876 let type_name = match kind {
877 ListKind::Ordered => "numberedListItem",
878 ListKind::Unordered => "bulletListItem",
879 };
880 self.json.key("type").value(type_name)?;
881 self.json.key("props").object(|j| {
882 j.key("backgroundColor").value("default")?;
883 j.key("textColor").value("default")?;
884 j.key("textAlignment").value("left")?;
885 if kind == ListKind::Ordered {
886 if let Some(start_value) = start {
887 let start_prop = u32::try_from(start_value).map_err(|err| Error::Other {
888 message: format!(
889 "ordered list start value out of range: {start_value}: {err}"
890 ),
891 })?;
892 j.key("start").value(start_prop)?;
893 }
894 }
895 Ok(())
896 })?;
897 self.json.key("content").open_array()?;
898 self.list_stack.push(ListStackEntry {
899 children_array_open: false,
900 content_array_open: true,
901 first_paragraph_consumed: false,
902 kind,
903 level,
904 });
905 Ok(())
906 }
907
908 #[inline]
919 #[must_use]
920 pub fn with_assets(writer: W, assets: &'a dyn AssetProvider) -> Self {
921 Self {
922 assets: Some(assets),
923 blockquote_depth: Depth::default(),
924 blockquote_force_closed_count: Depth::default(),
925 context: BlockContext::default(),
926 drop_inside_list_depth: Depth::default(),
927 dropped_list_depth: Depth::default(),
928 in_link: false,
929 json: JsonEmitter::new(StrusonBackend::new(writer)),
930 link_emitted_styled_text: false,
931 list_stack: Vec::new(),
932 table_depth: Depth::default(),
933 }
934 }
935
936 fn write_id(&mut self, id: Option<&String>) -> Result<()> {
937 if let Some(id_val) = id {
938 self.json.key("id").value(id_val.as_str())?;
939 }
940 Ok(())
941 }
942}
943
944impl<W: Write> EventSink for BlockNoteWriter<'_, W> {
945 #[inline]
946 fn finish(self) -> Result<()> {
947 self.json.finish().map(|_| ())
948 }
949
950 #[inline]
951 fn handle_event(&mut self, event: Event) -> Result<()> {
952 match event {
953 Event::StartDocument { .. } => self.json.open_array(),
954 Event::EndDocument => {
955 while !self.list_stack.is_empty() {
956 self.close_current_list_item_object()?;
957 }
958 self.json.close_array()
959 }
960 Event::StartHeading { level, id, .. } => {
961 return_if_table_cell!(self);
962 drop_block_in_list_start!(self);
963 self.close_for_block_sibling()?;
964 self.handle_heading(level, id.as_ref())
965 }
966 Event::EndHeading => {
967 drop_block_in_list_end!(self);
968 if !self.context.in_text_block {
969 return Ok(());
970 }
971 close_text_block!(self)
972 }
973 Event::EndPreformatted => {
974 drop_block_in_list_end!(self);
975 return_if_table_cell!(self);
976 if !self.context.in_text_block {
977 return Ok(());
978 }
979 close_text_block!(self)
980 }
981 Event::StartParagraph { id, .. } => self.handle_paragraph(id.as_ref()),
982 Event::EndParagraph => self.handle_end_paragraph(),
983 Event::StartBlockQuote { id, .. } => {
984 return_if_table_cell!(self);
985 drop_block_in_list_start!(self);
986 self.close_for_block_sibling()?;
987 self.handle_blockquote(id.as_ref())
988 }
989 Event::EndBlockQuote => {
990 drop_block_in_list_end!(self);
991 return_if_table_cell!(self);
992 if self.blockquote_force_closed_count.is_positive() {
993 self.blockquote_force_closed_count.dec();
994 return Ok(());
995 }
996 self.close_open_link_if_any()?;
997 self.close_content_block()?;
998 self.blockquote_depth.dec();
999 self.context.in_text_block = self.blockquote_depth.is_positive();
1000 Ok(())
1001 }
1002 Event::StartPreformatted { id, syntax, .. } => {
1003 return_if_table_cell!(self);
1004 drop_block_in_list_start!(self);
1005 self.close_for_block_sibling()?;
1006 self.handle_preformatted(id.as_ref(), syntax.as_ref())
1007 }
1008 Event::ThematicBreak { id, .. } => {
1009 return_if_table_cell!(self);
1010 if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1011 return Ok(());
1012 }
1013 self.close_for_block_sibling()?;
1014 self.handle_divider(id.as_ref())
1015 }
1016 Event::Text { content, style, .. } => self.handle_text_event(&content, &style),
1017 Event::Image {
1018 source, alt, id, ..
1019 } => self.handle_image(source, alt, id.as_ref()),
1020 Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1021 Event::StartOrderedListItem {
1022 id, level, start, ..
1023 } => self.handle_start_list_item(ListKind::Ordered, id.as_ref(), level, start),
1024 Event::StartUnorderedListItem { id, level, .. } => {
1025 self.handle_start_list_item(ListKind::Unordered, id.as_ref(), level, None)
1026 }
1027 Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1028 Event::StartTable { id, .. } => self.handle_start_table(id.as_ref()),
1029 Event::EndTable => self.handle_end_table(),
1030 Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_ref()),
1031 Event::EndTableRow => self.handle_end_table_row(),
1032 Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1033 self.handle_table_cell(id.as_ref())
1034 }
1035 Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1036 Event::StartLink { href, .. } => self.handle_start_link(&href),
1037 Event::EndLink => self.handle_end_link(),
1038 Event::EndCaption
1039 | Event::EndDefinitionDetail
1040 | Event::EndDefinitionList
1041 | Event::EndDefinitionTerm
1042 | Event::EndFootnote
1043 | Event::FootnoteRef { .. }
1044 | Event::StartCaption { .. }
1045 | Event::StartDefinitionDetail { .. }
1046 | Event::StartDefinitionList { .. }
1047 | Event::StartDefinitionTerm { .. }
1048 | Event::StartFootnote { .. }
1049 | _ => Ok(()),
1050 }
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 #[test]
1059 fn list_stack_empty_after_new() {
1060 let mut buf = Vec::new();
1061 let writer = BlockNoteWriter::new(&mut buf);
1062 assert!(writer.list_stack.is_empty());
1063 }
1064
1065 #[test]
1066 fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1067 let mut buf = Vec::new();
1071 let mut writer = BlockNoteWriter::new(&mut buf);
1072 assert!(writer
1073 .handle_event(Event::StartDocument {
1074 id: None,
1075 language: None,
1076 metadata: None,
1077 })
1078 .is_ok());
1079 assert!(writer
1080 .handle_event(Event::StartUnorderedListItem {
1081 id: None,
1082 level: 0,
1083 style_type: docspec_core::ListStyleType::Disc,
1084 })
1085 .is_ok());
1086 assert!(
1087 !writer.list_stack.is_empty(),
1088 "list_stack must be non-empty before calling close_for_block_sibling"
1089 );
1090 assert!(writer.close_for_block_sibling().is_ok());
1091 assert!(
1092 writer.list_stack.is_empty(),
1093 "close_for_block_sibling must drain list_stack via close_open_list_items"
1094 );
1095 assert!(writer.handle_event(Event::EndDocument).is_ok());
1096 assert!(writer.finish().is_ok());
1097 }
1098}