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