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