Skip to main content

typub_ir/
lib.rs

1//! Typub IR v2 (semantic document IR).
2//!
3//! This is a semantic-first IR surface aligned with RFC-0009.
4
5use 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/// Footnote identifier using numeric type for correct sorting.
24/// Serializes as string for JSON compatibility (e.g., "1" instead of 1).
25#[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        // On Windows, "/path" is not absolute (it's relative to current drive root),
686        // but for portability we reject Unix-style absolute paths everywhere.
687        if path.starts_with('/') {
688            return Err("unix absolute path is not allowed".to_string());
689        }
690
691        // Guard against absolute-like Windows forms on non-Windows hosts.
692        // Examples: C:\foo, C:/foo, \\server\share\foo
693        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        // Canonicalize lexically into a unique project-relative form:
709        // - normalize separators to '/'
710        // - remove empty and '.' segments
711        // - resolve '..' and reject root escape
712        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    /// Publish-conformance URL only.
770    /// Preview-only resolution data MUST remain outside conformance IR.
771    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}