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