1use relative_path::RelativePathBuf;
6use serde::de::{self, Deserializer};
7use serde::ser::Serializer;
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::Path;
12
13pub type AttrMap = BTreeMap<String, String>;
14pub type ExtensionMap = BTreeMap<String, JsonValue>;
15
16pub fn empty_map() -> AttrMap {
17 AttrMap::new()
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21pub struct AnchorId(pub String);
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct FootnoteId(pub u64);
27
28impl Serialize for FootnoteId {
29 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
30 where
31 S: Serializer,
32 {
33 serializer.serialize_str(&self.0.to_string())
34 }
35}
36
37impl<'de> Deserialize<'de> for FootnoteId {
38 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39 where
40 D: Deserializer<'de>,
41 {
42 let s = String::deserialize(deserializer)?;
43 let num = s.parse::<u64>().map_err(de::Error::custom)?;
44 Ok(FootnoteId(num))
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49pub struct AssetId(pub String);
50
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52pub struct Url(pub String);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub struct HeadingLevel(u8);
56
57impl HeadingLevel {
58 pub fn new(level: u8) -> Result<Self, String> {
59 if (1..=6).contains(&level) {
60 Ok(Self(level))
61 } else {
62 Err("heading level must be in 1..=6".to_string())
63 }
64 }
65
66 pub fn get(self) -> u8 {
67 self.0
68 }
69}
70
71impl Serialize for HeadingLevel {
72 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
73 where
74 S: Serializer,
75 {
76 serializer.serialize_u8(self.0)
77 }
78}
79
80impl<'de> Deserialize<'de> for HeadingLevel {
81 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82 where
83 D: Deserializer<'de>,
84 {
85 let value = u8::deserialize(deserializer)?;
86 HeadingLevel::new(value).map_err(de::Error::custom)
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
91pub struct ExtensionKind(String);
92
93impl ExtensionKind {
94 pub fn new(kind: String) -> Result<Self, String> {
95 let k = kind.trim();
96 if k.is_empty() {
97 return Err("extension kind must not be empty".to_string());
98 }
99 if !(k.contains(':') || k.contains('/')) {
100 return Err("extension kind must be namespaced (use ':' or '/')".to_string());
101 }
102 Ok(Self(k.to_string()))
103 }
104
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108}
109
110impl Serialize for ExtensionKind {
111 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
112 where
113 S: Serializer,
114 {
115 serializer.serialize_str(self.as_str())
116 }
117}
118
119impl<'de> Deserialize<'de> for ExtensionKind {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: Deserializer<'de>,
123 {
124 let value = String::deserialize(deserializer)?;
125 ExtensionKind::new(value).map_err(de::Error::custom)
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
130pub struct AssetRef(pub AssetId);
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct Document {
134 pub blocks: Vec<Block>,
135 pub footnotes: BTreeMap<FootnoteId, FootnoteDef>,
136 pub assets: BTreeMap<AssetId, Asset>,
137 pub meta: DocMeta,
138}
139
140#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
141pub struct DocMeta {
142 pub title: Option<String>,
143 pub slug: Option<String>,
144 pub description: Option<String>,
145 pub tags: Vec<String>,
146 pub toc: Option<Vec<TocEntry>>,
147 pub extensions: ExtensionMap,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct TocEntry {
152 pub level: HeadingLevel,
153 pub id: AnchorId,
154 pub title: InlineSeq,
155}
156
157#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
158pub struct BlockAttrs {
159 pub classes: Vec<String>,
160 pub style: Option<String>,
161 pub passthrough: AttrMap,
162}
163
164#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
165pub struct InlineAttrs {
166 pub classes: Vec<String>,
167 pub style: Option<String>,
168 pub passthrough: AttrMap,
169}
170
171#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
172pub struct ImageAttrs {
173 pub width: Option<u32>,
174 pub height: Option<u32>,
175 pub align: Option<TextAlign>,
176 pub passthrough: AttrMap,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180pub enum TextAlign {
181 Left,
182 Center,
183 Right,
184}
185
186pub type InlineSeq = Vec<Inline>;
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub enum Block {
190 Heading {
191 level: HeadingLevel,
192 id: Option<AnchorId>,
193 content: InlineSeq,
194 attrs: BlockAttrs,
195 },
196 Paragraph {
197 content: InlineSeq,
198 attrs: BlockAttrs,
199 },
200 Quote {
201 blocks: Vec<Block>,
202 cite: Option<Url>,
203 attrs: BlockAttrs,
204 },
205 CodeBlock {
206 code: String,
207 language: Option<String>,
208 filename: Option<String>,
209 highlight_lines: Vec<u32>,
210 highlighted_html: Option<String>,
211 attrs: BlockAttrs,
212 },
213 Divider {
214 attrs: BlockAttrs,
215 },
216 List {
217 list: List,
218 attrs: BlockAttrs,
219 },
220 DefinitionList {
221 items: Vec<DefinitionItem>,
222 attrs: BlockAttrs,
223 },
224 Table {
225 caption: Option<Vec<Block>>,
226 sections: Vec<TableSection>,
227 attrs: BlockAttrs,
228 },
229 Figure {
230 content: Vec<Block>,
231 caption: Option<Vec<Block>>,
232 attrs: BlockAttrs,
233 },
234 Admonition {
235 kind: AdmonitionKind,
236 title: Option<InlineSeq>,
237 blocks: Vec<Block>,
238 attrs: BlockAttrs,
239 },
240 Details {
241 summary: Option<InlineSeq>,
242 blocks: Vec<Block>,
243 open: bool,
244 attrs: BlockAttrs,
245 },
246 MathBlock {
247 math: RenderPayload,
248 attrs: BlockAttrs,
249 },
250 SvgBlock {
251 svg: RenderPayload,
252 attrs: BlockAttrs,
253 },
254 UnknownBlock {
255 tag: String,
256 attrs: BlockAttrs,
257 children: Vec<UnknownChild>,
258 data: ExtensionMap,
259 note: Option<String>,
260 source: Option<String>,
261 },
262 RawBlock {
263 html: String,
264 origin: RawOrigin,
265 trust: RawTrust,
266 attrs: BlockAttrs,
267 },
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub enum Inline {
272 Text(String),
273 Code(String),
274 SoftBreak,
275 HardBreak,
276 Styled {
277 styles: StyleSet,
278 content: InlineSeq,
279 attrs: InlineAttrs,
280 },
281 Link {
282 content: InlineSeq,
283 href: Url,
284 title: Option<String>,
285 attrs: InlineAttrs,
286 },
287 Image {
288 asset: AssetRef,
289 alt: String,
290 title: Option<String>,
291 attrs: ImageAttrs,
292 },
293 FootnoteRef(FootnoteId),
294 MathInline {
295 math: RenderPayload,
296 attrs: InlineAttrs,
297 },
298 SvgInline {
299 svg: RenderPayload,
300 attrs: InlineAttrs,
301 },
302 UnknownInline {
303 tag: String,
304 attrs: InlineAttrs,
305 content: InlineSeq,
306 data: ExtensionMap,
307 note: Option<String>,
308 source: Option<String>,
309 },
310 RawInline {
311 html: String,
312 origin: RawOrigin,
313 trust: RawTrust,
314 attrs: InlineAttrs,
315 },
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
319pub struct StyleSet {
320 styles: Vec<TextStyle>,
321}
322
323impl StyleSet {
324 pub fn new(styles: Vec<TextStyle>) -> Result<Self, String> {
325 let mut set = Self { styles };
326 set.canonicalize();
327 if set.styles.is_empty() {
328 return Err("StyleSet must contain at least one style".to_string());
329 }
330 Ok(set)
331 }
332
333 pub fn single(style: TextStyle) -> Self {
334 Self {
335 styles: vec![style],
336 }
337 }
338
339 pub fn styles(&self) -> &[TextStyle] {
340 &self.styles
341 }
342
343 pub fn canonicalize(&mut self) {
344 self.styles.sort_by_key(|s| s.canonical_rank());
345 self.styles.dedup();
346 }
347}
348
349impl<'de> Deserialize<'de> for StyleSet {
350 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351 where
352 D: Deserializer<'de>,
353 {
354 #[derive(Deserialize)]
355 struct RawStyleSet {
356 styles: Vec<TextStyle>,
357 }
358
359 let raw = RawStyleSet::deserialize(deserializer)?;
360 StyleSet::new(raw.styles).map_err(de::Error::custom)
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
365pub enum TextStyle {
366 Bold,
367 Italic,
368 Strikethrough,
369 Underline,
370 Mark,
371 Superscript,
372 Subscript,
373 Kbd,
374}
375
376impl TextStyle {
377 fn canonical_rank(&self) -> u8 {
378 match self {
379 Self::Bold => 0,
380 Self::Italic => 1,
381 Self::Strikethrough => 2,
382 Self::Underline => 3,
383 Self::Mark => 4,
384 Self::Superscript => 5,
385 Self::Subscript => 6,
386 Self::Kbd => 7,
387 }
388 }
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct List {
393 pub kind: ListKind,
394}
395
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397pub enum ListKind {
398 Bullet {
399 items: Vec<FlowListItem>,
400 },
401 Numbered {
402 start: u32,
403 reversed: bool,
404 marker: Option<OrderedListMarker>,
405 items: Vec<FlowListItem>,
406 },
407 Task {
408 items: Vec<TaskListItem>,
409 },
410 Custom {
411 kind: ExtensionKind,
412 items: Vec<CustomListItem>,
413 data: ExtensionMap,
414 },
415}
416
417impl ListKind {
418 pub fn item_blocks_mut(&mut self) -> impl Iterator<Item = &mut Vec<Block>> {
419 enum ItemBlocksMut<'a> {
420 Flow(std::slice::IterMut<'a, FlowListItem>),
421 Task(std::slice::IterMut<'a, TaskListItem>),
422 Custom(std::slice::IterMut<'a, CustomListItem>),
423 }
424
425 impl<'a> Iterator for ItemBlocksMut<'a> {
426 type Item = &'a mut Vec<Block>;
427
428 fn next(&mut self) -> Option<Self::Item> {
429 match self {
430 Self::Flow(iter) => iter.next().map(|item| &mut item.blocks),
431 Self::Task(iter) => iter.next().map(|item| &mut item.blocks),
432 Self::Custom(iter) => iter.next().map(|item| &mut item.blocks),
433 }
434 }
435 }
436
437 match self {
438 Self::Bullet { items } | Self::Numbered { items, .. } => {
439 ItemBlocksMut::Flow(items.iter_mut())
440 }
441 Self::Task { items } => ItemBlocksMut::Task(items.iter_mut()),
442 Self::Custom { items, .. } => ItemBlocksMut::Custom(items.iter_mut()),
443 }
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct FlowListItem {
449 pub marker: Option<FlowListItemMarker>,
450 pub blocks: Vec<Block>,
451}
452
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct TaskListItem {
455 pub checked: bool,
456 pub blocks: Vec<Block>,
457}
458
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460pub struct CustomListItem {
461 pub blocks: Vec<Block>,
462 pub data: ExtensionMap,
463}
464
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub enum FlowListItemMarker {
467 Bullet,
468 Number(u32),
469}
470
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472pub enum UnknownChild {
473 Block(Block),
474 Inline(Inline),
475}
476
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
478pub struct DefinitionItem {
479 pub terms: Vec<Vec<Block>>,
480 pub definitions: Vec<Vec<Block>>,
481}
482
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484pub struct TableCell {
485 pub kind: TableCellKind,
486 pub blocks: Vec<Block>,
487 pub colspan: u32,
488 pub rowspan: u32,
489 pub scope: Option<TableHeaderScope>,
490 pub align: Option<TextAlign>,
491 pub attrs: BlockAttrs,
492}
493
494#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
495pub struct TableRow {
496 pub cells: Vec<TableCell>,
497 pub attrs: BlockAttrs,
498}
499
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
501pub struct TableSection {
502 pub kind: TableSectionKind,
503 pub rows: Vec<TableRow>,
504 pub attrs: BlockAttrs,
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508pub enum TableSectionKind {
509 Head,
510 Body,
511 Foot,
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
515pub enum TableCellKind {
516 Header,
517 Data,
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521pub enum TableHeaderScope {
522 Row,
523 Col,
524 RowGroup,
525 ColGroup,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
529pub enum OrderedListMarker {
530 Decimal,
531 LowerAlpha,
532 UpperAlpha,
533 LowerRoman,
534 UpperRoman,
535}
536
537impl TableCell {
538 pub fn simple(blocks: Vec<Block>) -> Self {
539 Self {
540 kind: TableCellKind::Data,
541 blocks,
542 colspan: 1,
543 rowspan: 1,
544 scope: None,
545 align: None,
546 attrs: BlockAttrs::default(),
547 }
548 }
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct FootnoteDef {
553 pub blocks: Vec<Block>,
554}
555
556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
557pub enum AdmonitionKind {
558 Note,
559 Tip,
560 Warning,
561 Danger,
562 Info,
563 Custom(ExtensionKind),
564}
565
566impl AdmonitionKind {
567 pub fn default_title(&self) -> &str {
568 match self {
569 Self::Note => "Note",
570 Self::Tip => "Tip",
571 Self::Warning => "Warning",
572 Self::Danger => "Caution",
573 Self::Info => "Important",
574 Self::Custom(s) => s.as_str(),
575 }
576 }
577}
578
579pub type MathPayload = RenderPayload;
580pub type SvgPayload = RenderPayload;
581
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
583pub enum MathSource {
584 Typst(String),
585 Latex(String),
586 Custom { kind: ExtensionKind, src: String },
587}
588
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
590pub struct RenderPayload {
591 pub src: Option<MathSource>,
592 pub rendered: Option<RenderedArtifact>,
593 pub id: Option<String>,
594}
595
596impl RenderPayload {
597 pub fn has_source_or_rendered(&self) -> bool {
598 self.src.is_some() || self.rendered.is_some()
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
603pub enum RenderedArtifact {
604 Svg(String),
605 MathMl(String),
606 Asset {
607 asset: AssetRef,
608 mime: Option<String>,
609 width: Option<u32>,
610 height: Option<u32>,
611 },
612 Custom {
613 kind: ExtensionKind,
614 data: ExtensionMap,
615 },
616}
617
618pub type RenderedMath = RenderedArtifact;
619
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621pub enum Asset {
622 Image(ImageAsset),
623 Video(MediaAsset),
624 Audio(MediaAsset),
625 File(FileAsset),
626 Custom(CustomAsset),
627}
628
629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
630pub struct ImageAsset {
631 pub source: AssetSource,
632 pub meta: Option<ImageMeta>,
633 pub variants: Vec<AssetVariant>,
634}
635
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637pub struct MediaAsset {
638 pub source: AssetSource,
639 pub mime: Option<String>,
640 pub duration_ms: Option<u64>,
641 pub width: Option<u32>,
642 pub height: Option<u32>,
643 pub sha256: Option<String>,
644 pub variants: Vec<AssetVariant>,
645}
646
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct FileAsset {
649 pub source: AssetSource,
650 pub mime: Option<String>,
651 pub size_bytes: Option<u64>,
652 pub sha256: Option<String>,
653 pub variants: Vec<AssetVariant>,
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657pub struct CustomAsset {
658 pub kind: ExtensionKind,
659 pub source: AssetSource,
660 pub meta: ExtensionMap,
661 pub variants: Vec<AssetVariant>,
662}
663
664#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
665pub enum AssetSource {
666 LocalPath { path: RelativePath },
667 RemoteUrl { url: Url },
668 DataUri { uri: String },
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
672pub struct RelativePath(RelativePathBuf);
673
674impl RelativePath {
675 pub fn new(path: String) -> Result<Self, String> {
676 if path.trim().is_empty() {
677 return Err("path must not be empty".to_string());
678 }
679
680 let p = Path::new(&path);
681 if p.is_absolute() {
682 return Err("absolute path is not allowed".to_string());
683 }
684
685 if path.starts_with('/') {
688 return Err("unix absolute path is not allowed".to_string());
689 }
690
691 let bytes = path.as_bytes();
694 if bytes.len() >= 3
695 && bytes[1] == b':'
696 && (bytes[2] == b'\\' || bytes[2] == b'/')
697 && bytes[0].is_ascii_alphabetic()
698 {
699 return Err("windows drive absolute path is not allowed".to_string());
700 }
701 if path.starts_with("\\\\") {
702 return Err("windows UNC absolute path is not allowed".to_string());
703 }
704 if path.starts_with('\\') {
705 return Err("windows rooted path is not allowed".to_string());
706 }
707
708 let mut parts: Vec<&str> = Vec::new();
713 for raw in path.split(['/', '\\']) {
714 if raw.is_empty() || raw == "." {
715 continue;
716 }
717 if raw == ".." {
718 if parts.pop().is_none() {
719 return Err("path escapes root".to_string());
720 }
721 continue;
722 }
723 parts.push(raw);
724 }
725
726 if parts.is_empty() {
727 return Err("path must resolve to a non-empty relative path".to_string());
728 }
729
730 let canonical = parts.join("/");
731 Ok(Self(RelativePathBuf::from(canonical)))
732 }
733
734 pub fn as_str(&self) -> &str {
735 self.0.as_str()
736 }
737}
738
739impl Serialize for RelativePath {
740 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
741 where
742 S: Serializer,
743 {
744 serializer.serialize_str(self.as_str())
745 }
746}
747
748impl<'de> Deserialize<'de> for RelativePath {
749 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
750 where
751 D: Deserializer<'de>,
752 {
753 let value = String::deserialize(deserializer)?;
754 RelativePath::new(value).map_err(de::Error::custom)
755 }
756}
757
758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
759pub struct ImageMeta {
760 pub width: Option<u32>,
761 pub height: Option<u32>,
762 pub format: Option<String>,
763 pub sha256: Option<String>,
764}
765
766#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
767pub struct AssetVariant {
768 pub name: String,
769 pub publish_url: Url,
772 pub width: Option<u32>,
773 pub height: Option<u32>,
774}
775
776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
777pub enum RawOrigin {
778 Typst,
779 Markdown,
780 User,
781 Extension(ExtensionKind),
782}
783
784#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
785pub enum RawTrust {
786 Trusted,
787 Untrusted,
788}
789
790#[cfg(test)]
791mod tests {
792 #![allow(clippy::expect_used)]
793
794 use super::*;
795
796 #[test]
797 fn heading_level_accepts_valid_range() {
798 for n in 1..=6 {
799 let lvl = HeadingLevel::new(n).expect("valid heading level");
800 assert_eq!(lvl.get(), n);
801 }
802 }
803
804 #[test]
805 fn heading_level_rejects_out_of_range() {
806 assert!(HeadingLevel::new(0).is_err());
807 assert!(HeadingLevel::new(7).is_err());
808 assert!(HeadingLevel::new(255).is_err());
809 }
810
811 #[test]
812 fn heading_level_deserialize_enforces_range() {
813 let ok: HeadingLevel = serde_json::from_str("3").expect("deserialize valid level");
814 assert_eq!(ok.get(), 3);
815
816 let bad = serde_json::from_str::<HeadingLevel>("0");
817 assert!(bad.is_err());
818 }
819
820 #[test]
821 fn extension_kind_requires_namespace() {
822 assert!(ExtensionKind::new("".to_string()).is_err());
823 assert!(ExtensionKind::new("custom".to_string()).is_err());
824 assert!(ExtensionKind::new("vendor:feature".to_string()).is_ok());
825 assert!(ExtensionKind::new("vendor/feature".to_string()).is_ok());
826 }
827
828 #[test]
829 fn extension_kind_deserialize_enforces_namespace() {
830 let ok: ExtensionKind =
831 serde_json::from_str("\"vendor:feature\"").expect("deserialize namespaced kind");
832 assert_eq!(ok.as_str(), "vendor:feature");
833
834 let bad = serde_json::from_str::<ExtensionKind>("\"feature\"");
835 assert!(bad.is_err());
836 }
837
838 #[test]
839 fn style_set_canonicalizes_and_deduplicates() {
840 let set = StyleSet::new(vec![
841 TextStyle::Italic,
842 TextStyle::Bold,
843 TextStyle::Italic,
844 TextStyle::Underline,
845 ])
846 .expect("non-empty style set");
847 assert_eq!(
848 set.styles(),
849 &[TextStyle::Bold, TextStyle::Italic, TextStyle::Underline]
850 );
851 }
852
853 #[test]
854 fn style_set_rejects_empty() {
855 assert!(StyleSet::new(vec![]).is_err());
856 }
857
858 #[test]
859 fn style_set_deserialize_rejects_empty() {
860 let bad = serde_json::from_str::<StyleSet>(r#"{"styles":[]}"#);
861 assert!(bad.is_err());
862 }
863
864 #[test]
865 fn style_set_deserialize_canonicalizes() {
866 let set: StyleSet =
867 serde_json::from_str(r#"{"styles":["Underline","Italic","Bold","Italic"]}"#)
868 .expect("deserialize style set");
869 assert_eq!(
870 set.styles(),
871 &[TextStyle::Bold, TextStyle::Italic, TextStyle::Underline]
872 );
873 }
874
875 #[test]
876 fn relative_path_canonicalizes_input() {
877 let p = RelativePath::new("./assets\\images/../hero.png".to_string())
878 .expect("canonicalizable relative path");
879 assert_eq!(p.as_str(), "assets/hero.png");
880 }
881
882 #[test]
883 fn relative_path_rejects_escape_or_absolute_forms() {
884 assert!(RelativePath::new("../outside.png".to_string()).is_err());
885 assert!(RelativePath::new("/abs/path.png".to_string()).is_err());
886 assert!(RelativePath::new("C:\\\\abs\\\\path.png".to_string()).is_err());
887 assert!(RelativePath::new("\\\\server\\\\share\\\\x.png".to_string()).is_err());
888 assert!(RelativePath::new("\\rooted\\\\x.png".to_string()).is_err());
889 }
890
891 #[test]
892 fn relative_path_deserialize_applies_canonicalization() {
893 let p: RelativePath =
894 serde_json::from_str(r#""a/b/../c.png""#).expect("deserialize and canonicalize");
895 assert_eq!(p.as_str(), "a/c.png");
896
897 let bad = serde_json::from_str::<RelativePath>(r#""../../escape.png""#);
898 assert!(bad.is_err());
899 }
900
901 #[test]
902 fn relative_path_rejects_root_escape_after_normalization() {
903 assert!(RelativePath::new("assets/../../escape.png".to_string()).is_err());
904 }
905
906 #[test]
907 fn list_kind_custom_deserialize_requires_namespaced_kind() {
908 let bad = serde_json::from_str::<ListKind>(
909 r#"{"Custom":{"kind":"custom","items":[],"data":{}}}"#,
910 );
911 assert!(bad.is_err());
912
913 let ok = serde_json::from_str::<ListKind>(
914 r#"{"Custom":{"kind":"vendor:custom","items":[],"data":{}}}"#,
915 );
916 assert!(ok.is_ok());
917 }
918
919 #[test]
920 fn render_payload_accepts_source_only() {
921 let payload = RenderPayload {
922 src: Some(MathSource::Latex("x+y".to_string())),
923 rendered: None,
924 id: None,
925 };
926 assert!(payload.has_source_or_rendered());
927 }
928
929 #[test]
930 fn render_payload_accepts_rendered_only() {
931 let payload = RenderPayload {
932 src: None,
933 rendered: Some(RenderedArtifact::Asset {
934 asset: AssetRef(AssetId("asset-1".to_string())),
935 mime: Some("image/png".to_string()),
936 width: Some(10),
937 height: Some(20),
938 }),
939 id: None,
940 };
941 assert!(payload.has_source_or_rendered());
942 }
943
944 #[test]
945 fn render_payload_detects_empty_payload() {
946 let payload = RenderPayload {
947 src: None,
948 rendered: None,
949 id: None,
950 };
951 assert!(!payload.has_source_or_rendered());
952 }
953}