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 };
502 let caption = alt.unwrap_or_default();
503
504 self.json.object(|j| {
505 if let Some(id_val) = id {
506 j.key("id").value(id_val.as_str())?;
507 }
508 j.key("type").value("image")?;
509 j.key("props").object(|p| {
510 p.key("url").value(url.as_str())?;
511 p.key("caption").value(caption.as_str())
512 })?;
513 j.key("content").value(Null)?;
514 j.key("children").array(|_| Ok(()))
515 })
516 }
517
518 fn handle_line_break(&mut self) -> Result<()> {
519 if self.drop_inside_list_depth.is_positive() {
520 return Ok(());
521 }
522 if (self.context.in_text_block || self.context.in_table_cell || self.in_list_item_content())
523 && self.table_depth.get() <= 1
524 {
525 self.handle_text("\n", &TextStyle::default())
526 } else {
527 Ok(())
528 }
529 }
530
531 fn handle_paragraph(&mut self, id: Option<&String>) -> Result<()> {
532 if self.context.in_table_cell {
534 return Ok(());
535 }
536 if self.drop_inside_list_depth.is_positive() || self.dropped_list_depth.is_positive() {
539 return Ok(());
540 }
541 if !self.list_stack.is_empty()
545 && self
546 .list_stack
547 .last()
548 .is_some_and(|e| e.first_paragraph_consumed)
549 {
550 if self.list_stack.last().is_some_and(|e| e.content_array_open) {
551 self.json.close_array()?;
552 if let Some(e) = self.list_stack.last_mut() {
553 e.content_array_open = false;
554 }
555 }
556 if !self
557 .list_stack
558 .last()
559 .is_some_and(|e| e.children_array_open)
560 {
561 self.json.key("children").open_array()?;
562 if let Some(e) = self.list_stack.last_mut() {
563 e.children_array_open = true;
564 }
565 }
566 self.json.open_object()?;
567 self.json.key("type").value("paragraph")?;
568 self.json
569 .key("props")
570 .object(|j| j.key("textAlignment").value("left"))?;
571 self.json.key("content").open_array()?;
572 self.context.in_text_block = true;
573 return Ok(());
574 }
575 if self.in_list_item_content() {
576 return Ok(());
577 }
578 if !self.list_stack.is_empty() {
579 self.close_open_list_items()?;
580 }
581 if self.blockquote_depth.is_positive() {
582 if self.context.blockquote_has_content {
583 self.handle_text("\n\n", &TextStyle::default())?;
584 }
585 return Ok(());
586 }
587 self.json.open_object()?;
588 self.write_id(id)?;
589 self.json.key("type").value("paragraph")?;
590 self.json
591 .key("props")
592 .object(|j| j.key("textAlignment").value("left"))?;
593 self.json.key("content").open_array()?;
594 self.context.in_text_block = true;
595 Ok(())
596 }
597
598 fn handle_preformatted(&mut self, id: Option<&String>, syntax: Option<&String>) -> Result<()> {
599 self.json.open_object()?;
600 self.json.key("type").value("codeBlock")?;
601 self.write_id(id)?;
602 if let Some(lang) = syntax {
603 self.json
604 .key("props")
605 .object(|j| j.key("language").value(lang.as_str()))?;
606 }
607 self.json.key("content").open_array()?;
608 self.context.in_text_block = true;
609 Ok(())
610 }
611
612 fn handle_start_link(&mut self, href: &str) -> Result<()> {
616 if self.drop_inside_list_depth.is_positive()
617 || self.dropped_list_depth.is_positive()
618 || (!self.context.in_text_block
619 && !self.context.in_table_cell
620 && !self.in_list_item_content())
621 || self.table_depth.get() > 1
622 {
623 return Ok(());
624 }
625 if self.in_link {
626 return Ok(());
627 }
628 if self.blockquote_depth.is_positive() {
629 self.context.blockquote_has_content = true;
630 }
631 self.json.open_object()?;
632 self.json.key("type").value("link")?;
633 self.json.key("href").value(href)?;
634 self.json.key("content").open_array()?;
635 self.in_link = true;
636 self.link_emitted_styled_text = false;
637 Ok(())
638 }
639
640 fn handle_start_list_item(
641 &mut self,
642 kind: ListKind,
643 id: Option<&String>,
644 level: u32,
645 start: Option<u64>,
646 ) -> Result<()> {
647 if self.context.in_table_cell
648 || self.table_depth.get() > 1
649 || self.drop_inside_list_depth.is_positive()
650 {
651 self.dropped_list_depth.inc();
652 return Ok(());
653 }
654 if self.blockquote_depth.is_positive() {
655 self.close_blockquote_for_sibling()?;
656 }
657 if self.list_stack.is_empty() {
658 self.close_for_block_sibling()?;
659 self.open_list_item_object(kind, id, level, start)?;
660 return Ok(());
661 }
662
663 let stack_top_level = self.list_stack.last().map_or(0, |entry| entry.level);
664
665 let effective_level = if level > stack_top_level.saturating_add(1) {
668 stack_top_level.saturating_add(1)
669 } else {
670 level
671 };
672
673 if effective_level > stack_top_level {
674 self.open_current_list_item_children()?;
675 self.open_list_item_object(kind, id, effective_level, start)?;
676 return Ok(());
677 }
678
679 if effective_level == stack_top_level {
680 self.close_current_list_item_object()?;
681 if self.list_stack.is_empty() {
682 self.close_for_block_sibling()?;
683 }
684 self.open_list_item_object(kind, id, effective_level, start)?;
685 return Ok(());
686 }
687
688 while let Some(top) = self.list_stack.last() {
691 if top.level < effective_level {
692 break;
693 }
694 self.close_current_list_item_object()?;
695 }
696 if self.list_stack.is_empty() {
697 self.close_for_block_sibling()?;
698 }
699 self.open_list_item_object(kind, id, effective_level, start)?;
700 Ok(())
701 }
702
703 fn handle_start_table(&mut self, id: Option<&String>) -> Result<()> {
704 drop_block_in_list_start!(self);
705 if self.table_depth.is_positive() {
706 self.table_depth.inc();
707 return Ok(());
708 }
709 self.close_for_block_sibling()?;
710 self.json.open_object()?;
711 self.json.key("type").value("table")?;
712 self.write_id(id)?;
713 self.json
714 .key("props")
715 .object(|p| p.key("textColor").value("default"))?;
716 self.json.key("content").open_object()?;
717 self.json.key("type").value("tableContent")?;
718 self.json.key("columnWidths").array(|_| Ok(()))?;
719 self.json.key("rows").open_array()?;
720 self.table_depth.inc();
721 self.context.in_text_block = false;
722 Ok(())
723 }
724
725 fn handle_start_table_row(&mut self, id: Option<&String>) -> Result<()> {
726 if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
727 return Ok(());
728 }
729 self.json.open_object()?;
730 self.write_id(id)?;
731 self.json.key("cells").open_array()
732 }
733
734 fn handle_table_cell(&mut self, id: Option<&String>) -> Result<()> {
735 if self.drop_inside_list_depth.is_positive() || self.table_depth.get() > 1 {
736 return Ok(());
737 }
738 self.json.open_object()?;
739 self.json.key("type").value("tableCell")?;
740 self.write_id(id)?;
741 self.json.key("props").object(|p| {
742 p.key("backgroundColor").value("default")?;
743 p.key("textColor").value("default")?;
744 p.key("textAlignment").value("left")
745 })?;
746 self.json.key("content").open_array()?;
747 self.context.in_table_cell = true;
748 self.context.in_text_block = false;
749 Ok(())
750 }
751
752 fn handle_text(&mut self, content: &str, style: &TextStyle) -> Result<()> {
753 if self.drop_inside_list_depth.is_positive()
754 || self.dropped_list_depth.is_positive()
755 || (!self.context.in_text_block
756 && !self.context.in_table_cell
757 && !self.in_list_item_content())
758 || self.table_depth.get() > 1
759 {
760 return Ok(());
761 }
762 if self.blockquote_depth.is_positive() {
763 self.context.blockquote_has_content = true;
764 }
765 self.json.object(|j| {
766 j.key("type").value("text")?;
767 j.key("text").value(content)?;
768 j.key("styles").object(|s| {
769 for (key, enabled) in [
770 ("bold", style.bold),
771 ("italic", style.italic),
772 ("code", style.code),
773 ("strike", style.strikethrough),
774 ("underline", style.underline),
775 ] {
776 if enabled {
777 s.key(key).value(true)?;
778 }
779 }
780 Ok(())
781 })
782 })?;
783 if self.in_link {
784 self.link_emitted_styled_text = true;
785 }
786 Ok(())
787 }
788
789 fn handle_text_event(&mut self, content: &str, style: &TextStyle) -> Result<()> {
790 if !self.context.in_text_block
792 && self.blockquote_depth.is_zero()
793 && !self.context.in_table_cell
794 && !self.in_list_item_content()
795 {
796 self.handle_paragraph(None)?;
797 }
798 self.handle_text(content, style)
799 }
800
801 fn in_any_list_item(&self) -> bool {
809 !self.list_stack.is_empty()
810 }
811
812 fn in_list_item_content(&self) -> bool {
813 self.list_stack
814 .last()
815 .is_some_and(|entry| entry.content_array_open)
816 }
817
818 #[inline]
824 #[must_use]
825 pub fn new(writer: W) -> Self {
826 Self {
827 assets: None,
828 blockquote_depth: Depth::default(),
829 blockquote_force_closed_count: Depth::default(),
830 context: BlockContext::default(),
831 drop_inside_list_depth: Depth::default(),
832 dropped_list_depth: Depth::default(),
833 in_link: false,
834 json: JsonEmitter::new(StrusonBackend::new(writer)),
835 link_emitted_styled_text: false,
836 list_stack: Vec::new(),
837 table_depth: Depth::default(),
838 }
839 }
840
841 fn open_current_list_item_children(&mut self) -> Result<()> {
842 let content_array_open = self
843 .list_stack
844 .last()
845 .is_some_and(|entry| entry.content_array_open);
846 if content_array_open {
847 self.json.close_array()?;
848 if let Some(entry) = self.list_stack.last_mut() {
849 entry.content_array_open = false;
850 }
851 }
852
853 let children_array_open = self
854 .list_stack
855 .last()
856 .is_some_and(|entry| entry.children_array_open);
857 if !children_array_open {
858 self.json.key("children").open_array()?;
859 if let Some(entry) = self.list_stack.last_mut() {
860 entry.children_array_open = true;
861 }
862 }
863 Ok(())
864 }
865
866 fn open_list_item_object(
867 &mut self,
868 kind: ListKind,
869 id: Option<&String>,
870 level: u32,
871 start: Option<u64>,
872 ) -> Result<()> {
873 self.json.open_object()?;
874 self.write_id(id)?;
875 let type_name = match kind {
876 ListKind::Ordered => "numberedListItem",
877 ListKind::Unordered => "bulletListItem",
878 };
879 self.json.key("type").value(type_name)?;
880 self.json.key("props").object(|j| {
881 j.key("backgroundColor").value("default")?;
882 j.key("textColor").value("default")?;
883 j.key("textAlignment").value("left")?;
884 if kind == ListKind::Ordered {
885 if let Some(start_value) = start {
886 let start_prop = u32::try_from(start_value).map_err(|err| Error::Other {
887 message: format!(
888 "ordered list start value out of range: {start_value}: {err}"
889 ),
890 })?;
891 j.key("start").value(start_prop)?;
892 }
893 }
894 Ok(())
895 })?;
896 self.json.key("content").open_array()?;
897 self.list_stack.push(ListStackEntry {
898 children_array_open: false,
899 content_array_open: true,
900 first_paragraph_consumed: false,
901 kind,
902 level,
903 });
904 Ok(())
905 }
906
907 #[inline]
918 #[must_use]
919 pub fn with_assets(writer: W, assets: &'a dyn AssetProvider) -> Self {
920 Self {
921 assets: Some(assets),
922 blockquote_depth: Depth::default(),
923 blockquote_force_closed_count: Depth::default(),
924 context: BlockContext::default(),
925 drop_inside_list_depth: Depth::default(),
926 dropped_list_depth: Depth::default(),
927 in_link: false,
928 json: JsonEmitter::new(StrusonBackend::new(writer)),
929 link_emitted_styled_text: false,
930 list_stack: Vec::new(),
931 table_depth: Depth::default(),
932 }
933 }
934
935 fn write_id(&mut self, id: Option<&String>) -> Result<()> {
936 if let Some(id_val) = id {
937 self.json.key("id").value(id_val.as_str())?;
938 }
939 Ok(())
940 }
941}
942
943impl<W: Write> EventSink for BlockNoteWriter<'_, W> {
944 #[inline]
945 fn finish(self) -> Result<()> {
946 self.json.finish().map(|_| ())
947 }
948
949 #[inline]
950 fn handle_event(&mut self, event: Event) -> Result<()> {
951 match event {
952 Event::StartDocument { .. } => self.json.open_array(),
953 Event::EndDocument => {
954 while !self.list_stack.is_empty() {
955 self.close_current_list_item_object()?;
956 }
957 self.json.close_array()
958 }
959 Event::StartHeading { level, id, .. } => {
960 return_if_table_cell!(self);
961 drop_block_in_list_start!(self);
962 self.close_for_block_sibling()?;
963 self.handle_heading(level, id.as_ref())
964 }
965 Event::EndHeading => {
966 drop_block_in_list_end!(self);
967 if !self.context.in_text_block {
968 return Ok(());
969 }
970 close_text_block!(self)
971 }
972 Event::EndPreformatted => {
973 drop_block_in_list_end!(self);
974 return_if_table_cell!(self);
975 if !self.context.in_text_block {
976 return Ok(());
977 }
978 close_text_block!(self)
979 }
980 Event::StartParagraph { id, .. } => self.handle_paragraph(id.as_ref()),
981 Event::EndParagraph => self.handle_end_paragraph(),
982 Event::StartBlockQuote { id, .. } => {
983 return_if_table_cell!(self);
984 drop_block_in_list_start!(self);
985 self.close_for_block_sibling()?;
986 self.handle_blockquote(id.as_ref())
987 }
988 Event::EndBlockQuote => {
989 drop_block_in_list_end!(self);
990 return_if_table_cell!(self);
991 if self.blockquote_force_closed_count.is_positive() {
992 self.blockquote_force_closed_count.dec();
993 return Ok(());
994 }
995 self.close_open_link_if_any()?;
996 self.close_content_block()?;
997 self.blockquote_depth.dec();
998 self.context.in_text_block = self.blockquote_depth.is_positive();
999 Ok(())
1000 }
1001 Event::StartPreformatted { id, syntax, .. } => {
1002 return_if_table_cell!(self);
1003 drop_block_in_list_start!(self);
1004 self.close_for_block_sibling()?;
1005 self.handle_preformatted(id.as_ref(), syntax.as_ref())
1006 }
1007 Event::ThematicBreak { id, .. } => {
1008 return_if_table_cell!(self);
1009 if self.in_any_list_item() || self.drop_inside_list_depth.is_positive() {
1010 return Ok(());
1011 }
1012 self.close_for_block_sibling()?;
1013 self.handle_divider(id.as_ref())
1014 }
1015 Event::Text { content, style, .. } => self.handle_text_event(&content, &style),
1016 Event::Image {
1017 source, alt, id, ..
1018 } => self.handle_image(source, alt, id.as_ref()),
1019 Event::LineBreak | Event::SoftBreak => self.handle_line_break(),
1020 Event::StartOrderedListItem {
1021 id, level, start, ..
1022 } => self.handle_start_list_item(ListKind::Ordered, id.as_ref(), level, start),
1023 Event::StartUnorderedListItem { id, level, .. } => {
1024 self.handle_start_list_item(ListKind::Unordered, id.as_ref(), level, None)
1025 }
1026 Event::EndOrderedListItem | Event::EndUnorderedListItem => self.handle_end_list_item(),
1027 Event::StartTable { id, .. } => self.handle_start_table(id.as_ref()),
1028 Event::EndTable => self.handle_end_table(),
1029 Event::StartTableRow { id, .. } => self.handle_start_table_row(id.as_ref()),
1030 Event::EndTableRow => self.handle_end_table_row(),
1031 Event::StartTableCell { id, .. } | Event::StartTableHeader { id, .. } => {
1032 self.handle_table_cell(id.as_ref())
1033 }
1034 Event::EndTableCell | Event::EndTableHeader => self.handle_end_table_cell(),
1035 Event::StartLink { href, .. } => self.handle_start_link(&href),
1036 Event::EndLink => self.handle_end_link(),
1037 Event::EndCaption
1038 | Event::EndDefinitionDetail
1039 | Event::EndDefinitionList
1040 | Event::EndDefinitionTerm
1041 | Event::EndFootnote
1042 | Event::FootnoteRef { .. }
1043 | Event::StartCaption { .. }
1044 | Event::StartDefinitionDetail { .. }
1045 | Event::StartDefinitionList { .. }
1046 | Event::StartDefinitionTerm { .. }
1047 | Event::StartFootnote { .. }
1048 | _ => Ok(()),
1049 }
1050 }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn list_stack_empty_after_new() {
1059 let mut buf = Vec::new();
1060 let writer = BlockNoteWriter::new(&mut buf);
1061 assert!(writer.list_stack.is_empty());
1062 }
1063
1064 #[test]
1065 fn close_for_block_sibling_with_nonempty_list_stack_closes_all_items() {
1066 let mut buf = Vec::new();
1070 let mut writer = BlockNoteWriter::new(&mut buf);
1071 assert!(writer
1072 .handle_event(Event::StartDocument {
1073 id: None,
1074 language: None,
1075 metadata: None,
1076 })
1077 .is_ok());
1078 assert!(writer
1079 .handle_event(Event::StartUnorderedListItem {
1080 id: None,
1081 level: 0,
1082 style_type: docspec_core::ListStyleType::Disc,
1083 })
1084 .is_ok());
1085 assert!(
1086 !writer.list_stack.is_empty(),
1087 "list_stack must be non-empty before calling close_for_block_sibling"
1088 );
1089 assert!(writer.close_for_block_sibling().is_ok());
1090 assert!(
1091 writer.list_stack.is_empty(),
1092 "close_for_block_sibling must drain list_stack via close_open_list_items"
1093 );
1094 assert!(writer.handle_event(Event::EndDocument).is_ok());
1095 assert!(writer.finish().is_ok());
1096 }
1097}