1use serde::de::{self, MapAccess, Visitor};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Serialize};
6
7use crate::content::block::MathFormat;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Text {
30 pub value: String,
32
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub marks: Vec<Mark>,
36}
37
38impl Text {
39 #[must_use]
41 pub fn plain(value: impl Into<String>) -> Self {
42 Self {
43 value: value.into(),
44 marks: Vec::new(),
45 }
46 }
47
48 #[must_use]
50 pub fn with_marks(value: impl Into<String>, marks: Vec<Mark>) -> Self {
51 Self {
52 value: value.into(),
53 marks,
54 }
55 }
56
57 #[must_use]
59 pub fn bold(value: impl Into<String>) -> Self {
60 Self::with_marks(value, vec![Mark::Bold])
61 }
62
63 #[must_use]
65 pub fn italic(value: impl Into<String>) -> Self {
66 Self::with_marks(value, vec![Mark::Italic])
67 }
68
69 #[must_use]
71 pub fn code(value: impl Into<String>) -> Self {
72 Self::with_marks(value, vec![Mark::Code])
73 }
74
75 #[must_use]
77 pub fn link(value: impl Into<String>, href: impl Into<String>) -> Self {
78 Self::with_marks(
79 value,
80 vec![Mark::Link {
81 href: href.into(),
82 title: None,
83 }],
84 )
85 }
86
87 #[must_use]
89 pub fn footnote(value: impl Into<String>, number: u32) -> Self {
90 Self::with_marks(value, vec![Mark::Footnote { number, id: None }])
91 }
92
93 #[must_use]
95 pub fn has_marks(&self) -> bool {
96 !self.marks.is_empty()
97 }
98
99 #[must_use]
101 pub fn has_mark(&self, mark_type: MarkType) -> bool {
102 self.marks.iter().any(|m| m.mark_type() == mark_type)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Mark {
118 Bold,
120
121 Italic,
123
124 Underline,
126
127 Strikethrough,
129
130 Code,
132
133 Superscript,
135
136 Subscript,
138
139 Link {
141 href: String,
143
144 title: Option<String>,
146 },
147
148 Anchor {
150 id: String,
152 },
153
154 Footnote {
158 number: u32,
160
161 id: Option<String>,
163 },
164
165 Math {
167 format: MathFormat,
169
170 source: String,
172 },
173
174 Extension(ExtensionMark),
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct ExtensionMark {
191 pub namespace: String,
193
194 pub mark_type: String,
196
197 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
199 pub attributes: serde_json::Value,
200}
201
202impl ExtensionMark {
203 #[must_use]
205 pub fn new(namespace: impl Into<String>, mark_type: impl Into<String>) -> Self {
206 Self {
207 namespace: namespace.into(),
208 mark_type: mark_type.into(),
209 attributes: serde_json::Value::Null,
210 }
211 }
212
213 #[must_use]
217 pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
218 type_str.split_once(':')
219 }
220
221 #[must_use]
223 pub fn full_type(&self) -> String {
224 format!("{}:{}", self.namespace, self.mark_type)
225 }
226
227 #[must_use]
229 pub fn is_namespace(&self, namespace: &str) -> bool {
230 self.namespace == namespace
231 }
232
233 #[must_use]
235 pub fn is_type(&self, namespace: &str, mark_type: &str) -> bool {
236 self.namespace == namespace && self.mark_type == mark_type
237 }
238
239 #[must_use]
241 pub fn with_attributes(mut self, attributes: serde_json::Value) -> Self {
242 self.attributes = attributes;
243 self
244 }
245
246 #[must_use]
248 pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
249 self.attributes.get(key)
250 }
251
252 #[must_use]
254 pub fn get_string_attribute(&self, key: &str) -> Option<&str> {
255 self.attributes.get(key).and_then(serde_json::Value::as_str)
256 }
257
258 #[must_use]
260 pub fn get_string_array_attribute(&self, key: &str) -> Option<Vec<&str>> {
261 self.attributes.get(key).and_then(|v| {
262 v.as_array()
263 .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
264 })
265 }
266
267 #[must_use]
271 pub fn get_citation_refs(&self) -> Option<Vec<&str>> {
272 if let Some(refs) = self.get_string_array_attribute("refs") {
274 return Some(refs);
275 }
276 self.get_string_attribute("ref").map(|r| vec![r])
278 }
279
280 pub fn normalize_citation_attrs(&mut self) {
284 if let Some(obj) = self.attributes.as_object_mut() {
285 if obj.contains_key("refs") {
286 return;
287 }
288 if let Some(single) = obj.remove("ref") {
289 if let Some(s) = single.as_str() {
290 obj.insert(
291 "refs".to_string(),
292 serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]),
293 );
294 } else {
295 obj.insert("ref".to_string(), single);
297 }
298 }
299 }
300 }
301
302 #[must_use]
306 pub fn get_glossary_ref(&self) -> Option<&str> {
307 self.get_string_attribute("ref")
308 .or_else(|| self.get_string_attribute("termId"))
309 }
310
311 pub fn normalize_glossary_attrs(&mut self) {
315 if let Some(obj) = self.attributes.as_object_mut() {
316 if obj.contains_key("ref") {
317 return;
318 }
319 if let Some(val) = obj.remove("termId") {
320 obj.insert("ref".to_string(), val);
321 }
322 }
323 }
324
325 #[must_use]
329 pub fn citation(reference: impl Into<String>) -> Self {
330 Self::new("semantic", "citation").with_attributes(serde_json::json!({
331 "refs": [reference.into()]
332 }))
333 }
334
335 #[must_use]
337 pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
338 Self::new("semantic", "citation").with_attributes(serde_json::json!({
339 "refs": [reference.into()],
340 "locator": page.into(),
341 "locatorType": "page"
342 }))
343 }
344
345 #[must_use]
347 pub fn multi_citation(refs: &[String]) -> Self {
348 Self::new("semantic", "citation").with_attributes(serde_json::json!({
349 "refs": refs
350 }))
351 }
352
353 #[must_use]
355 pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
356 Self::new("semantic", "entity").with_attributes(serde_json::json!({
357 "uri": uri.into(),
358 "entityType": entity_type.into()
359 }))
360 }
361
362 #[must_use]
364 pub fn glossary(term_id: impl Into<String>) -> Self {
365 Self::new("semantic", "glossary").with_attributes(serde_json::json!({
366 "ref": term_id.into()
367 }))
368 }
369
370 #[must_use]
372 pub fn index(term: impl Into<String>) -> Self {
373 Self::new("presentation", "index").with_attributes(serde_json::json!({
374 "term": term.into()
375 }))
376 }
377
378 #[must_use]
380 pub fn index_with_subterm(term: impl Into<String>, subterm: impl Into<String>) -> Self {
381 Self::new("presentation", "index").with_attributes(serde_json::json!({
382 "term": term.into(),
383 "subterm": subterm.into()
384 }))
385 }
386
387 #[must_use]
393 pub fn equation_ref(target: impl Into<String>) -> Self {
394 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
395 "target": target.into()
396 }))
397 }
398
399 #[must_use]
403 pub fn equation_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
404 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
405 "target": target.into(),
406 "format": format.into()
407 }))
408 }
409
410 #[must_use]
414 pub fn algorithm_ref(target: impl Into<String>) -> Self {
415 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
416 "target": target.into()
417 }))
418 }
419
420 #[must_use]
424 pub fn algorithm_ref_line(target: impl Into<String>, line: impl Into<String>) -> Self {
425 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
426 "target": target.into(),
427 "line": line.into()
428 }))
429 }
430
431 #[must_use]
435 pub fn algorithm_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
436 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
437 "target": target.into(),
438 "format": format.into()
439 }))
440 }
441
442 #[must_use]
444 pub fn algorithm_ref_line_formatted(
445 target: impl Into<String>,
446 line: impl Into<String>,
447 format: impl Into<String>,
448 ) -> Self {
449 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
450 "target": target.into(),
451 "line": line.into(),
452 "format": format.into()
453 }))
454 }
455
456 #[must_use]
460 pub fn theorem_ref(target: impl Into<String>) -> Self {
461 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
462 "target": target.into()
463 }))
464 }
465
466 #[must_use]
470 pub fn theorem_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
471 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
472 "target": target.into(),
473 "format": format.into()
474 }))
475 }
476
477 #[must_use]
484 pub fn highlight(color: impl Into<String>) -> Self {
485 Self::new("collaboration", "highlight").with_attributes(serde_json::json!({
486 "color": color.into()
487 }))
488 }
489
490 #[must_use]
492 pub fn highlight_yellow() -> Self {
493 Self::highlight("yellow")
494 }
495
496 #[must_use]
500 pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
501 Self::highlight(color.to_string())
502 }
503}
504
505fn infer_mark_namespace(mark_type: &str) -> &'static str {
510 match mark_type {
511 "citation" | "entity" | "glossary" => "semantic",
512 "theorem-ref" | "equation-ref" | "algorithm-ref" => "academic",
513 "cite" => "legal",
514 "highlight" => "collaboration",
515 "index" => "presentation",
516 _ => "",
517 }
518}
519
520impl Serialize for Mark {
521 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
522 match self {
523 Self::Bold => serializer.serialize_str("bold"),
525 Self::Italic => serializer.serialize_str("italic"),
526 Self::Underline => serializer.serialize_str("underline"),
527 Self::Strikethrough => serializer.serialize_str("strikethrough"),
528 Self::Code => serializer.serialize_str("code"),
529 Self::Superscript => serializer.serialize_str("superscript"),
530 Self::Subscript => serializer.serialize_str("subscript"),
531
532 Self::Link { href, title } => {
534 let len = 2 + usize::from(title.is_some());
535 let mut map = serializer.serialize_map(Some(len))?;
536 map.serialize_entry("type", "link")?;
537 map.serialize_entry("href", href)?;
538 if let Some(t) = title {
539 map.serialize_entry("title", t)?;
540 }
541 map.end()
542 }
543 Self::Anchor { id } => {
544 let mut map = serializer.serialize_map(Some(2))?;
545 map.serialize_entry("type", "anchor")?;
546 map.serialize_entry("id", id)?;
547 map.end()
548 }
549 Self::Footnote { number, id } => {
550 let len = 2 + usize::from(id.is_some());
551 let mut map = serializer.serialize_map(Some(len))?;
552 map.serialize_entry("type", "footnote")?;
553 map.serialize_entry("number", number)?;
554 if let Some(i) = id {
555 map.serialize_entry("id", i)?;
556 }
557 map.end()
558 }
559 Self::Math { format, source } => {
560 let mut map = serializer.serialize_map(Some(3))?;
561 map.serialize_entry("type", "math")?;
562 map.serialize_entry("format", format)?;
563 map.serialize_entry("source", source)?;
564 map.end()
565 }
566
567 Self::Extension(ext) => {
569 let type_str = ext.full_type();
570 let attr_count = ext.attributes.as_object().map_or(0, serde_json::Map::len);
571 let mut map = serializer.serialize_map(Some(1 + attr_count))?;
572 map.serialize_entry("type", &type_str)?;
573 if let Some(obj) = ext.attributes.as_object() {
574 for (k, v) in obj {
575 map.serialize_entry(k, v)?;
576 }
577 }
578 map.end()
579 }
580 }
581 }
582}
583
584impl<'de> Deserialize<'de> for Mark {
585 #[allow(clippy::too_many_lines)] fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
587 struct MarkVisitor;
588
589 impl<'de> Visitor<'de> for MarkVisitor {
590 type Value = Mark;
591
592 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593 formatter.write_str("a string (simple mark) or an object (complex mark)")
594 }
595
596 fn visit_str<E: de::Error>(self, v: &str) -> Result<Mark, E> {
598 match v {
599 "bold" => Ok(Mark::Bold),
600 "italic" => Ok(Mark::Italic),
601 "underline" => Ok(Mark::Underline),
602 "strikethrough" => Ok(Mark::Strikethrough),
603 "code" => Ok(Mark::Code),
604 "superscript" => Ok(Mark::Superscript),
605 "subscript" => Ok(Mark::Subscript),
606 other => {
607 let (ns, mt) = if let Some((ns, mt)) = other.split_once(':') {
609 (ns.to_string(), mt.to_string())
610 } else {
611 (infer_mark_namespace(other).to_string(), other.to_string())
612 };
613 Ok(Mark::Extension(ExtensionMark::new(ns, mt)))
614 }
615 }
616 }
617
618 #[allow(clippy::too_many_lines)] fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Mark, A::Error> {
621 let mut type_str: Option<String> = None;
622 let mut fields = serde_json::Map::new();
623
624 while let Some(key) = map.next_key::<String>()? {
625 if key == "type" {
626 type_str = Some(map.next_value()?);
627 } else {
628 let value: serde_json::Value = map.next_value()?;
629 fields.insert(key, value);
630 }
631 }
632
633 let type_str = type_str.ok_or_else(|| de::Error::missing_field("type"))?;
634
635 match type_str.as_str() {
636 "bold" => Ok(Mark::Bold),
638 "italic" => Ok(Mark::Italic),
639 "underline" => Ok(Mark::Underline),
640 "strikethrough" => Ok(Mark::Strikethrough),
641 "code" => Ok(Mark::Code),
642 "superscript" => Ok(Mark::Superscript),
643 "subscript" => Ok(Mark::Subscript),
644
645 "link" => {
647 let href = fields
648 .get("href")
649 .and_then(serde_json::Value::as_str)
650 .ok_or_else(|| de::Error::missing_field("href"))?
651 .to_string();
652 let title = fields
653 .get("title")
654 .and_then(serde_json::Value::as_str)
655 .map(ToString::to_string);
656 Ok(Mark::Link { href, title })
657 }
658 "anchor" => {
659 let id = fields
660 .get("id")
661 .and_then(serde_json::Value::as_str)
662 .ok_or_else(|| de::Error::missing_field("id"))?
663 .to_string();
664 Ok(Mark::Anchor { id })
665 }
666 "footnote" => {
667 let number = fields
668 .get("number")
669 .and_then(serde_json::Value::as_u64)
670 .ok_or_else(|| de::Error::missing_field("number"))?;
671 let id = fields
672 .get("id")
673 .and_then(serde_json::Value::as_str)
674 .map(ToString::to_string);
675 Ok(Mark::Footnote {
676 number: u32::try_from(number)
677 .map_err(|_| de::Error::custom("footnote number too large"))?,
678 id,
679 })
680 }
681 "math" => {
682 let format_val = fields
683 .get("format")
684 .ok_or_else(|| de::Error::missing_field("format"))?;
685 let format: MathFormat = serde_json::from_value(format_val.clone())
686 .map_err(de::Error::custom)?;
687 let source = fields
689 .get("source")
690 .or_else(|| fields.get("value"))
691 .and_then(serde_json::Value::as_str)
692 .ok_or_else(|| de::Error::missing_field("source"))?
693 .to_string();
694 Ok(Mark::Math { format, source })
695 }
696
697 "extension" => {
699 let namespace = fields
700 .get("namespace")
701 .and_then(serde_json::Value::as_str)
702 .unwrap_or("")
703 .to_string();
704 let mark_type = fields
705 .get("markType")
706 .and_then(serde_json::Value::as_str)
707 .unwrap_or("")
708 .to_string();
709 let attributes = fields
710 .get("attributes")
711 .cloned()
712 .unwrap_or(serde_json::Value::Null);
713 Ok(Mark::Extension(ExtensionMark {
714 namespace,
715 mark_type,
716 attributes,
717 }))
718 }
719
720 other => {
722 let (namespace, mark_type) = if let Some((ns, mt)) = other.split_once(':') {
723 (ns.to_string(), mt.to_string())
724 } else {
725 (infer_mark_namespace(other).to_string(), other.to_string())
726 };
727 let attributes = if fields.is_empty() {
728 serde_json::Value::Null
729 } else {
730 serde_json::Value::Object(fields)
731 };
732 Ok(Mark::Extension(ExtensionMark {
733 namespace,
734 mark_type,
735 attributes,
736 }))
737 }
738 }
739 }
740 }
741
742 deserializer.deserialize_any(MarkVisitor)
743 }
744}
745
746impl Mark {
747 #[must_use]
749 pub fn mark_type(&self) -> MarkType {
750 match self {
751 Self::Bold => MarkType::Bold,
752 Self::Italic => MarkType::Italic,
753 Self::Underline => MarkType::Underline,
754 Self::Strikethrough => MarkType::Strikethrough,
755 Self::Code => MarkType::Code,
756 Self::Superscript => MarkType::Superscript,
757 Self::Subscript => MarkType::Subscript,
758 Self::Link { .. } => MarkType::Link,
759 Self::Anchor { .. } => MarkType::Anchor,
760 Self::Footnote { .. } => MarkType::Footnote,
761 Self::Math { .. } => MarkType::Math,
762 Self::Extension(_) => MarkType::Extension,
763 }
764 }
765
766 #[must_use]
768 pub fn is_extension(&self) -> bool {
769 matches!(self, Self::Extension(_))
770 }
771
772 #[must_use]
774 pub fn as_extension(&self) -> Option<&ExtensionMark> {
775 match self {
776 Self::Extension(ext) => Some(ext),
777 _ => None,
778 }
779 }
780}
781
782#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
784pub enum MarkType {
785 Bold,
787 Italic,
789 Underline,
791 Strikethrough,
793 Code,
795 Superscript,
797 Subscript,
799 Link,
801 Anchor,
803 Footnote,
805 Math,
807 Extension,
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
816 fn test_text_plain() {
817 let text = Text::plain("Hello");
818 assert_eq!(text.value, "Hello");
819 assert!(text.marks.is_empty());
820 assert!(!text.has_marks());
821 }
822
823 #[test]
824 fn test_text_bold() {
825 let text = Text::bold("Important");
826 assert_eq!(text.marks, vec![Mark::Bold]);
827 assert!(text.has_marks());
828 assert!(text.has_mark(MarkType::Bold));
829 assert!(!text.has_mark(MarkType::Italic));
830 }
831
832 #[test]
833 fn test_text_link() {
834 let text = Text::link("Click", "https://example.com");
835 assert!(text.has_mark(MarkType::Link));
836 if let Mark::Link { href, title } = &text.marks[0] {
837 assert_eq!(href, "https://example.com");
838 assert!(title.is_none());
839 } else {
840 panic!("Expected Link mark");
841 }
842 }
843
844 #[test]
845 fn test_text_serialization() {
846 let text = Text::bold("Test");
847 let json = serde_json::to_string(&text).unwrap();
848 assert!(json.contains("\"value\":\"Test\""));
849 assert!(json.contains("\"bold\""));
851 }
852
853 #[test]
854 fn test_text_deserialization() {
855 let json = r#"{"value":"Test","marks":["bold","italic"]}"#;
857 let text: Text = serde_json::from_str(json).unwrap();
858 assert_eq!(text.value, "Test");
859 assert_eq!(text.marks.len(), 2);
860 assert_eq!(text.marks[0], Mark::Bold);
861 assert_eq!(text.marks[1], Mark::Italic);
862 }
863
864 #[test]
865 fn test_text_deserialization_object_format() {
866 let json = r#"{"value":"Test","marks":[{"type":"bold"},{"type":"italic"}]}"#;
868 let text: Text = serde_json::from_str(json).unwrap();
869 assert_eq!(text.value, "Test");
870 assert_eq!(text.marks.len(), 2);
871 assert_eq!(text.marks[0], Mark::Bold);
872 assert_eq!(text.marks[1], Mark::Italic);
873 }
874
875 #[test]
876 fn test_link_with_title() {
877 let json = r#"{"type":"link","href":"https://example.com","title":"Example"}"#;
878 let mark: Mark = serde_json::from_str(json).unwrap();
879 if let Mark::Link { href, title } = mark {
880 assert_eq!(href, "https://example.com");
881 assert_eq!(title, Some("Example".to_string()));
882 } else {
883 panic!("Expected Link mark");
884 }
885 }
886
887 #[test]
888 fn test_text_footnote() {
889 let text = Text::footnote("important claim", 1);
890 assert!(text.has_mark(MarkType::Footnote));
891 if let Mark::Footnote { number, id } = &text.marks[0] {
892 assert_eq!(*number, 1);
893 assert!(id.is_none());
894 } else {
895 panic!("Expected Footnote mark");
896 }
897 }
898
899 #[test]
900 fn test_footnote_mark_serialization() {
901 let mark = Mark::Footnote {
902 number: 1,
903 id: Some("fn1".to_string()),
904 };
905 let json = serde_json::to_string(&mark).unwrap();
906 assert!(json.contains("\"type\":\"footnote\""));
907 assert!(json.contains("\"number\":1"));
908 assert!(json.contains("\"id\":\"fn1\""));
909 }
910
911 #[test]
912 fn test_footnote_mark_deserialization() {
913 let json = r#"{"type":"footnote","number":2,"id":"fn-2"}"#;
914 let mark: Mark = serde_json::from_str(json).unwrap();
915 if let Mark::Footnote { number, id } = mark {
916 assert_eq!(number, 2);
917 assert_eq!(id, Some("fn-2".to_string()));
918 } else {
919 panic!("Expected Footnote mark");
920 }
921 }
922
923 #[test]
924 fn test_footnote_mark_without_id() {
925 let json = r#"{"type":"footnote","number":3}"#;
926 let mark: Mark = serde_json::from_str(json).unwrap();
927 if let Mark::Footnote { number, id } = mark {
928 assert_eq!(number, 3);
929 assert!(id.is_none());
930 } else {
931 panic!("Expected Footnote mark");
932 }
933 }
934
935 #[test]
936 fn test_math_mark() {
937 use crate::content::block::MathFormat;
938
939 let mark = Mark::Math {
940 format: MathFormat::Latex,
941 source: "E = mc^2".to_string(),
942 };
943 assert_eq!(mark.mark_type(), MarkType::Math);
944 }
945
946 #[test]
947 fn test_math_mark_serialization() {
948 use crate::content::block::MathFormat;
949
950 let mark = Mark::Math {
951 format: MathFormat::Latex,
952 source: "\\frac{1}{2}".to_string(),
953 };
954 let json = serde_json::to_string(&mark).unwrap();
955 assert!(json.contains("\"type\":\"math\""));
956 assert!(json.contains("\"format\":\"latex\""));
957 assert!(json.contains("\"source\":\"\\\\frac{1}{2}\""));
958 }
959
960 #[test]
961 fn test_math_mark_deserialization() {
962 use crate::content::block::MathFormat;
963
964 let json = r#"{"type":"math","format":"mathml","source":"<math>...</math>"}"#;
965 let mark: Mark = serde_json::from_str(json).unwrap();
966 if let Mark::Math { format, source } = mark {
967 assert_eq!(format, MathFormat::Mathml);
968 assert_eq!(source, "<math>...</math>");
969 } else {
970 panic!("Expected Math mark");
971 }
972 }
973
974 #[test]
975 fn test_text_with_math_mark() {
976 use crate::content::block::MathFormat;
977
978 let text = Text::with_marks(
979 "x²",
980 vec![Mark::Math {
981 format: MathFormat::Latex,
982 source: "x^2".to_string(),
983 }],
984 );
985 assert!(text.has_mark(MarkType::Math));
986 }
987
988 #[test]
991 fn test_extension_mark_new() {
992 let ext = ExtensionMark::new("semantic", "citation");
993 assert_eq!(ext.namespace, "semantic");
994 assert_eq!(ext.mark_type, "citation");
995 assert_eq!(ext.full_type(), "semantic:citation");
996 }
997
998 #[test]
999 fn test_extension_mark_parse_type() {
1000 assert_eq!(
1001 ExtensionMark::parse_type("semantic:citation"),
1002 Some(("semantic", "citation"))
1003 );
1004 assert_eq!(
1005 ExtensionMark::parse_type("legal:cite"),
1006 Some(("legal", "cite"))
1007 );
1008 assert_eq!(ExtensionMark::parse_type("bold"), None);
1009 }
1010
1011 #[test]
1012 fn test_extension_mark_with_attributes() {
1013 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
1014 "refs": ["smith2023"],
1015 "page": "42"
1016 }));
1017
1018 assert_eq!(
1019 ext.get_string_array_attribute("refs"),
1020 Some(vec!["smith2023"])
1021 );
1022 assert_eq!(ext.get_string_attribute("page"), Some("42"));
1023 }
1024
1025 #[test]
1026 fn test_extension_mark_namespace_check() {
1027 let ext = ExtensionMark::new("semantic", "citation");
1028 assert!(ext.is_namespace("semantic"));
1029 assert!(!ext.is_namespace("legal"));
1030 assert!(ext.is_type("semantic", "citation"));
1031 assert!(!ext.is_type("semantic", "entity"));
1032 }
1033
1034 #[test]
1035 fn test_mark_extension_variant() {
1036 let ext = ExtensionMark::new("semantic", "citation");
1037 let mark = Mark::Extension(ext.clone());
1038
1039 assert!(mark.is_extension());
1040 assert_eq!(mark.mark_type(), MarkType::Extension);
1041 assert_eq!(
1042 mark.as_extension().unwrap().full_type(),
1043 "semantic:citation"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_extension_mark_serialization() {
1049 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
1050 "refs": ["smith2023"]
1051 }));
1052 let mark = Mark::Extension(ext);
1053
1054 let json = serde_json::to_string(&mark).unwrap();
1055 assert!(json.contains("\"type\":\"semantic:citation\""));
1057 assert!(json.contains("\"refs\":[\"smith2023\"]"));
1058 assert!(!json.contains("\"namespace\""));
1060 assert!(!json.contains("\"markType\""));
1061 }
1062
1063 #[test]
1064 fn test_extension_mark_deserialization_new_format() {
1065 let json = r#"{
1067 "type": "legal:cite",
1068 "citation": "Brown v. Board of Education"
1069 }"#;
1070 let mark: Mark = serde_json::from_str(json).unwrap();
1071
1072 if let Mark::Extension(ext) = mark {
1073 assert_eq!(ext.namespace, "legal");
1074 assert_eq!(ext.mark_type, "cite");
1075 assert_eq!(
1076 ext.get_string_attribute("citation"),
1077 Some("Brown v. Board of Education")
1078 );
1079 } else {
1080 panic!("Expected Extension mark");
1081 }
1082 }
1083
1084 #[test]
1085 fn test_extension_mark_deserialization_old_format() {
1086 let json = r#"{
1088 "type": "extension",
1089 "namespace": "legal",
1090 "markType": "cite",
1091 "attributes": {
1092 "citation": "Brown v. Board of Education"
1093 }
1094 }"#;
1095 let mark: Mark = serde_json::from_str(json).unwrap();
1096
1097 if let Mark::Extension(ext) = mark {
1098 assert_eq!(ext.namespace, "legal");
1099 assert_eq!(ext.mark_type, "cite");
1100 assert_eq!(
1101 ext.get_string_attribute("citation"),
1102 Some("Brown v. Board of Education")
1103 );
1104 } else {
1105 panic!("Expected Extension mark");
1106 }
1107 }
1108
1109 #[test]
1110 fn test_text_with_extension_mark() {
1111 let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
1112 let text = Text::with_marks("important claim", vec![mark]);
1113
1114 assert!(text.has_mark(MarkType::Extension));
1115 if let Mark::Extension(ext) = &text.marks[0] {
1116 assert_eq!(ext.namespace, "semantic");
1117 assert_eq!(ext.mark_type, "citation");
1118 } else {
1119 panic!("Expected Extension mark");
1120 }
1121 }
1122
1123 #[test]
1124 fn test_citation_convenience() {
1125 let ext = ExtensionMark::citation("smith2023");
1126 assert!(ext.is_type("semantic", "citation"));
1127 assert_eq!(
1128 ext.get_string_array_attribute("refs"),
1129 Some(vec!["smith2023"])
1130 );
1131 assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
1132 }
1133
1134 #[test]
1135 fn test_citation_with_page_convenience() {
1136 let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
1137 assert!(ext.is_type("semantic", "citation"));
1138 assert_eq!(
1139 ext.get_string_array_attribute("refs"),
1140 Some(vec!["smith2023"])
1141 );
1142 assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
1143 assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
1144 }
1145
1146 #[test]
1147 fn test_multi_citation_convenience() {
1148 let refs = vec!["smith2023".into(), "jones2024".into()];
1149 let ext = ExtensionMark::multi_citation(&refs);
1150 assert!(ext.is_type("semantic", "citation"));
1151 assert_eq!(
1152 ext.get_string_array_attribute("refs"),
1153 Some(vec!["smith2023", "jones2024"])
1154 );
1155 }
1156
1157 #[test]
1158 fn test_get_citation_refs_legacy() {
1159 let ext = ExtensionMark::new("semantic", "citation")
1161 .with_attributes(serde_json::json!({"ref": "smith2023"}));
1162 assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
1163 }
1164
1165 #[test]
1166 fn test_normalize_citation_attrs() {
1167 let mut ext = ExtensionMark::new("semantic", "citation")
1168 .with_attributes(serde_json::json!({"ref": "smith2023"}));
1169 ext.normalize_citation_attrs();
1170 assert_eq!(
1171 ext.get_string_array_attribute("refs"),
1172 Some(vec!["smith2023"])
1173 );
1174 assert!(ext.get_string_attribute("ref").is_none());
1175 }
1176
1177 #[test]
1178 fn test_normalize_citation_attrs_noop_when_refs_exists() {
1179 let mut ext = ExtensionMark::citation("smith2023");
1180 ext.normalize_citation_attrs();
1181 assert_eq!(
1182 ext.get_string_array_attribute("refs"),
1183 Some(vec!["smith2023"])
1184 );
1185 }
1186
1187 #[test]
1188 fn test_entity_convenience() {
1189 let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
1190 assert!(ext.is_type("semantic", "entity"));
1191 assert_eq!(
1192 ext.get_string_attribute("uri"),
1193 Some("https://www.wikidata.org/wiki/Q937")
1194 );
1195 assert_eq!(ext.get_string_attribute("entityType"), Some("person"));
1196 }
1197
1198 #[test]
1199 fn test_glossary_convenience() {
1200 let ext = ExtensionMark::glossary("api-term");
1201 assert!(ext.is_type("semantic", "glossary"));
1202 assert_eq!(ext.get_string_attribute("ref"), Some("api-term"));
1203 assert_eq!(ext.get_glossary_ref(), Some("api-term"));
1204 }
1205
1206 #[test]
1207 fn test_get_glossary_ref_legacy() {
1208 let ext = ExtensionMark::new("semantic", "glossary")
1209 .with_attributes(serde_json::json!({"termId": "api-term"}));
1210 assert_eq!(ext.get_glossary_ref(), Some("api-term"));
1211 }
1212
1213 #[test]
1214 fn test_normalize_glossary_attrs() {
1215 let mut ext = ExtensionMark::new("semantic", "glossary")
1216 .with_attributes(serde_json::json!({"termId": "api-term"}));
1217 ext.normalize_glossary_attrs();
1218 assert_eq!(ext.get_string_attribute("ref"), Some("api-term"));
1219 assert!(ext.get_string_attribute("termId").is_none());
1220 }
1221
1222 #[test]
1223 fn test_normalize_glossary_attrs_noop_when_ref_exists() {
1224 let mut ext = ExtensionMark::glossary("api-term");
1225 ext.normalize_glossary_attrs();
1226 assert_eq!(ext.get_string_attribute("ref"), Some("api-term"));
1227 }
1228
1229 #[test]
1230 fn test_index_convenience() {
1231 let ext = ExtensionMark::index("algorithm");
1232 assert!(ext.is_type("presentation", "index"));
1233 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1234 }
1235
1236 #[test]
1237 fn test_index_with_subterm_convenience() {
1238 let ext = ExtensionMark::index_with_subterm("algorithm", "sorting");
1239 assert!(ext.is_type("presentation", "index"));
1240 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1241 assert_eq!(ext.get_string_attribute("subterm"), Some("sorting"));
1242 }
1243
1244 #[test]
1245 fn test_non_extension_mark_as_extension() {
1246 let mark = Mark::Bold;
1247 assert!(!mark.is_extension());
1248 assert!(mark.as_extension().is_none());
1249 }
1250
1251 #[test]
1252 fn test_equation_ref_convenience() {
1253 let ext = ExtensionMark::equation_ref("#eq-pythagoras");
1254 assert!(ext.is_type("academic", "equation-ref"));
1255 assert_eq!(ext.get_string_attribute("target"), Some("#eq-pythagoras"));
1256 assert!(ext.get_string_attribute("format").is_none());
1257 }
1258
1259 #[test]
1260 fn test_equation_ref_formatted_convenience() {
1261 let ext = ExtensionMark::equation_ref_formatted("#eq-1", "Equation ({number})");
1262 assert!(ext.is_type("academic", "equation-ref"));
1263 assert_eq!(ext.get_string_attribute("target"), Some("#eq-1"));
1264 assert_eq!(
1265 ext.get_string_attribute("format"),
1266 Some("Equation ({number})")
1267 );
1268 }
1269
1270 #[test]
1271 fn test_algorithm_ref_convenience() {
1272 let ext = ExtensionMark::algorithm_ref("#alg-quicksort");
1273 assert!(ext.is_type("academic", "algorithm-ref"));
1274 assert_eq!(ext.get_string_attribute("target"), Some("#alg-quicksort"));
1275 assert!(ext.get_string_attribute("line").is_none());
1276 }
1277
1278 #[test]
1279 fn test_algorithm_ref_line_convenience() {
1280 let ext = ExtensionMark::algorithm_ref_line("#alg-bisection", "loop");
1281 assert!(ext.is_type("academic", "algorithm-ref"));
1282 assert_eq!(ext.get_string_attribute("target"), Some("#alg-bisection"));
1283 assert_eq!(ext.get_string_attribute("line"), Some("loop"));
1284 }
1285
1286 #[test]
1287 fn test_algorithm_ref_formatted_convenience() {
1288 let ext = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}");
1289 assert!(ext.is_type("academic", "algorithm-ref"));
1290 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1291 assert_eq!(
1292 ext.get_string_attribute("format"),
1293 Some("Algorithm {number}")
1294 );
1295 }
1296
1297 #[test]
1298 fn test_algorithm_ref_line_formatted_convenience() {
1299 let ext = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "pivot", "line {line}");
1300 assert!(ext.is_type("academic", "algorithm-ref"));
1301 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1302 assert_eq!(ext.get_string_attribute("line"), Some("pivot"));
1303 assert_eq!(ext.get_string_attribute("format"), Some("line {line}"));
1304 }
1305
1306 #[test]
1307 fn test_theorem_ref_convenience() {
1308 let ext = ExtensionMark::theorem_ref("#thm-pythagoras");
1309 assert!(ext.is_type("academic", "theorem-ref"));
1310 assert_eq!(ext.get_string_attribute("target"), Some("#thm-pythagoras"));
1311 }
1312
1313 #[test]
1314 fn test_theorem_ref_formatted_convenience() {
1315 let ext = ExtensionMark::theorem_ref_formatted("#thm-1", "{variant} {number}");
1316 assert!(ext.is_type("academic", "theorem-ref"));
1317 assert_eq!(ext.get_string_attribute("target"), Some("#thm-1"));
1318 assert_eq!(
1319 ext.get_string_attribute("format"),
1320 Some("{variant} {number}")
1321 );
1322 }
1323
1324 #[test]
1325 fn test_highlight_mark_convenience() {
1326 let ext = ExtensionMark::highlight("yellow");
1327 assert!(ext.is_type("collaboration", "highlight"));
1328 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1329 }
1330
1331 #[test]
1332 fn test_highlight_yellow_convenience() {
1333 let ext = ExtensionMark::highlight_yellow();
1334 assert!(ext.is_type("collaboration", "highlight"));
1335 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1336 }
1337
1338 #[test]
1339 fn test_highlight_colored_convenience() {
1340 let ext = ExtensionMark::highlight_colored("green");
1342 assert!(ext.is_type("collaboration", "highlight"));
1343 assert_eq!(ext.get_string_attribute("color"), Some("green"));
1344 }
1345}
1346
1347#[cfg(test)]
1348mod proptests {
1349 use super::*;
1350 use proptest::prelude::*;
1351
1352 fn arb_text_value() -> impl Strategy<Value = String> {
1354 "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
1355 }
1356
1357 fn arb_url() -> impl Strategy<Value = String> {
1359 "(https?://[a-z]{3,10}\\.[a-z]{2,4}(/[a-z0-9]{0,10})?)".prop_map(|s| s)
1360 }
1361
1362 fn arb_simple_mark() -> impl Strategy<Value = Mark> {
1364 prop_oneof![
1365 Just(Mark::Bold),
1366 Just(Mark::Italic),
1367 Just(Mark::Underline),
1368 Just(Mark::Strikethrough),
1369 Just(Mark::Code),
1370 Just(Mark::Superscript),
1371 Just(Mark::Subscript),
1372 ]
1373 }
1374
1375 fn arb_link_mark() -> impl Strategy<Value = Mark> {
1377 (arb_url(), prop::option::of("[a-zA-Z ]{0,20}"))
1378 .prop_map(|(href, title)| Mark::Link { href, title })
1379 }
1380
1381 fn arb_footnote_mark() -> impl Strategy<Value = Mark> {
1383 (1u32..1000u32, prop::option::of("[a-z]{2,10}"))
1384 .prop_map(|(number, id)| Mark::Footnote { number, id })
1385 }
1386
1387 fn arb_mark() -> impl Strategy<Value = Mark> {
1389 prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
1390 }
1391
1392 fn arb_text() -> impl Strategy<Value = Text> {
1394 (arb_text_value(), prop::collection::vec(arb_mark(), 0..3))
1395 .prop_map(|(value, marks)| Text { value, marks })
1396 }
1397
1398 proptest! {
1399 #[test]
1401 fn plain_text_no_marks(value in arb_text_value()) {
1402 let text = Text::plain(&value);
1403 prop_assert_eq!(&text.value, &value);
1404 prop_assert!(text.marks.is_empty());
1405 prop_assert!(!text.has_marks());
1406 }
1407
1408 #[test]
1410 fn bold_text_has_bold_mark(value in arb_text_value()) {
1411 let text = Text::bold(&value);
1412 prop_assert_eq!(&text.value, &value);
1413 prop_assert_eq!(text.marks.len(), 1);
1414 prop_assert!(text.has_mark(MarkType::Bold));
1415 }
1416
1417 #[test]
1419 fn italic_text_has_italic_mark(value in arb_text_value()) {
1420 let text = Text::italic(&value);
1421 prop_assert_eq!(&text.value, &value);
1422 prop_assert_eq!(text.marks.len(), 1);
1423 prop_assert!(text.has_mark(MarkType::Italic));
1424 }
1425
1426 #[test]
1428 fn code_text_has_code_mark(value in arb_text_value()) {
1429 let text = Text::code(&value);
1430 prop_assert_eq!(&text.value, &value);
1431 prop_assert_eq!(text.marks.len(), 1);
1432 prop_assert!(text.has_mark(MarkType::Code));
1433 }
1434
1435 #[test]
1437 fn link_text_has_link_mark(value in arb_text_value(), href in arb_url()) {
1438 let text = Text::link(&value, &href);
1439 prop_assert_eq!(&text.value, &value);
1440 prop_assert_eq!(text.marks.len(), 1);
1441 prop_assert!(text.has_mark(MarkType::Link));
1442 if let Mark::Link { href: actual_href, .. } = &text.marks[0] {
1443 prop_assert_eq!(actual_href, &href);
1444 }
1445 }
1446
1447 #[test]
1449 fn text_json_roundtrip(text in arb_text()) {
1450 let json = serde_json::to_string(&text).unwrap();
1451 let parsed: Text = serde_json::from_str(&json).unwrap();
1452 prop_assert_eq!(text, parsed);
1453 }
1454
1455 #[test]
1457 fn mark_json_roundtrip(mark in arb_mark()) {
1458 let json = serde_json::to_string(&mark).unwrap();
1459 let parsed: Mark = serde_json::from_str(&json).unwrap();
1460 prop_assert_eq!(mark, parsed);
1461 }
1462
1463 #[test]
1465 fn simple_mark_types(mark in arb_simple_mark()) {
1466 let expected = match mark {
1467 Mark::Bold => MarkType::Bold,
1468 Mark::Italic => MarkType::Italic,
1469 Mark::Underline => MarkType::Underline,
1470 Mark::Strikethrough => MarkType::Strikethrough,
1471 Mark::Code => MarkType::Code,
1472 Mark::Superscript => MarkType::Superscript,
1473 Mark::Subscript => MarkType::Subscript,
1474 Mark::Link { .. }
1475 | Mark::Anchor { .. }
1476 | Mark::Footnote { .. }
1477 | Mark::Math { .. }
1478 | Mark::Extension(_) => {
1479 prop_assert!(false, "arb_simple_mark() generated non-simple mark: {mark:?}");
1481 return Ok(());
1482 }
1483 };
1484 prop_assert_eq!(mark.mark_type(), expected);
1485 }
1486
1487 #[test]
1489 fn link_mark_type(mark in arb_link_mark()) {
1490 prop_assert_eq!(mark.mark_type(), MarkType::Link);
1491 }
1492
1493 #[test]
1495 fn footnote_mark_type(mark in arb_footnote_mark()) {
1496 prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1497 }
1498
1499 #[test]
1501 fn has_marks_consistent(text in arb_text()) {
1502 prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1503 }
1504 }
1505}