Skip to main content

mdi_core/
lib.rs

1//! The language-neutral MDI 2.0 syntax core.
2//!
3//! Rust is the sole executable syntax authority for an MDI document.  It
4//! parses CommonMark, GFM, YAML front matter, and MDI into one portable wire
5//! tree; language bindings only adapt that tree to their host APIs.
6
7use serde::{Serialize, Serializer, ser::SerializeStruct};
8use std::fs;
9use std::io::{Cursor, Seek, Write};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::{SystemTime, UNIX_EPOCH};
13use unicode_segmentation::UnicodeSegmentation;
14use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
15
16/// MDI syntax version implemented by this crate.
17pub const MDI_SPEC_VERSION: &str = "2.0";
18
19/// Version of the language-neutral wire format returned by the bindings.
20///
21/// This version changes only for incompatible wire-schema changes.
22pub const MDI_IR_VERSION: &str = "1.0";
23
24/// A binding-friendly parse envelope.
25///
26/// Capabilities let a binding verify the features represented in this parse
27/// result before it adapts the portable tree to a host-language API.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ParseOutput {
31    pub ir_version: &'static str,
32    pub syntax_version: &'static str,
33    pub capabilities: ParserCapabilities,
34    pub document: Document,
35    pub diagnostics: Vec<Diagnostic>,
36}
37
38/// Parser features represented in a [`ParseOutput`].
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ParserCapabilities {
42    pub mdi: bool,
43    pub common_mark: bool,
44    pub gfm: bool,
45    pub front_matter: bool,
46    pub source_spans: bool,
47}
48
49/// A recoverable parser message returned as part of the stable wire contract.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Diagnostic {
53    pub severity: DiagnosticSeverity,
54    pub code: String,
55    pub message: String,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub span: Option<SourceSpan>,
58}
59
60/// Severity of a parser diagnostic.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub enum DiagnosticSeverity {
64    Warning,
65    Error,
66}
67
68/// Half-open UTF-8 byte range in the original source.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct SourceSpan {
72    pub start_byte: u32,
73    pub end_byte: u32,
74}
75
76/// The complete language-neutral document tree.  `children` uses mdast's
77/// stable tagged JSON shape (with MDI nodes injected by this crate); every
78/// source-backed node is annotated with a UTF-8 byte `span`.
79#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct Document {
82    pub span: SourceSpan,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub frontmatter: Option<Frontmatter>,
85    pub children: Vec<serde_json::Value>,
86}
87
88/// Ordered YAML front matter, retaining both recognized and unknown fields.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct Frontmatter {
92    pub span: SourceSpan,
93    pub raw: String,
94    pub entries: Vec<FrontmatterEntry>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct FrontmatterEntry {
100    pub key: String,
101    pub value: serde_json::Value,
102}
103
104/// Transitional MDI-only document used by the compatibility helper
105/// [`parse_mdi_syntax`].  New callers must use [`parse_document`].
106#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct MdiSyntaxDocument {
109    pub blocks: Vec<MdiBlock>,
110}
111
112/// A block-level MDI construct or ordinary source line in the compatibility
113/// helper.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(tag = "type", rename_all = "camelCase")]
116pub enum MdiBlock {
117    Paragraph {
118        inlines: Vec<Inline>,
119        indent: Option<u32>,
120        bottom: Option<u32>,
121    },
122    Blank,
123    Pagebreak {
124        variant: Option<PagebreakVariant>,
125    },
126}
127
128/// A page-break side.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub enum PagebreakVariant {
132    Left,
133    Right,
134}
135
136/// A language-neutral MDI inline AST.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Inline {
139    Text(String),
140    Ruby {
141        base: String,
142        ruby: RubyReading,
143    },
144    Tcy(String),
145    Break,
146    Em {
147        mark: String,
148        children: Vec<Inline>,
149    },
150    NoBreak(Vec<Inline>),
151    Warichu(Vec<Inline>),
152    Kern {
153        amount: String,
154        children: Vec<Inline>,
155    },
156}
157
158/// Keep the public wire nodes flat (`{ type, value }`, `{ type, children }`,
159/// and so on) without forcing the Rust implementation to use struct variants
160/// internally. This shape maps naturally to discriminated unions in every
161/// host language.
162impl Serialize for Inline {
163    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164    where
165        S: Serializer,
166    {
167        match self {
168            Self::Text(value) => {
169                let mut node = serializer.serialize_struct("Inline", 2)?;
170                node.serialize_field("type", "text")?;
171                node.serialize_field("value", value)?;
172                node.end()
173            }
174            Self::Ruby { base, ruby } => {
175                let mut node = serializer.serialize_struct("Inline", 3)?;
176                node.serialize_field("type", "ruby")?;
177                node.serialize_field("base", base)?;
178                node.serialize_field("ruby", ruby)?;
179                node.end()
180            }
181            Self::Tcy(value) => {
182                let mut node = serializer.serialize_struct("Inline", 2)?;
183                node.serialize_field("type", "tcy")?;
184                node.serialize_field("value", value)?;
185                node.end()
186            }
187            Self::Break => {
188                let mut node = serializer.serialize_struct("Inline", 1)?;
189                node.serialize_field("type", "break")?;
190                node.end()
191            }
192            Self::Em { mark, children } => {
193                let mut node = serializer.serialize_struct("Inline", 3)?;
194                node.serialize_field("type", "em")?;
195                node.serialize_field("mark", mark)?;
196                node.serialize_field("children", children)?;
197                node.end()
198            }
199            Self::NoBreak(children) => {
200                let mut node = serializer.serialize_struct("Inline", 2)?;
201                node.serialize_field("type", "noBreak")?;
202                node.serialize_field("children", children)?;
203                node.end()
204            }
205            Self::Warichu(children) => {
206                let mut node = serializer.serialize_struct("Inline", 2)?;
207                node.serialize_field("type", "warichu")?;
208                node.serialize_field("children", children)?;
209                node.end()
210            }
211            Self::Kern { amount, children } => {
212                let mut node = serializer.serialize_struct("Inline", 3)?;
213                node.serialize_field("type", "kern")?;
214                node.serialize_field("amount", amount)?;
215                node.serialize_field("children", children)?;
216                node.end()
217            }
218        }
219    }
220}
221
222/// The reading attached to a ruby base.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224#[serde(tag = "type", content = "value", rename_all = "camelCase")]
225pub enum RubyReading {
226    Group(String),
227    Split(Vec<String>),
228}
229
230/// Parse only MDI syntax for legacy callers.  This is intentionally not the
231/// public whole-document entry point; use [`parse_document`] instead.
232pub fn parse_mdi_syntax(source: &str) -> MdiSyntaxDocument {
233    let mut blocks = Vec::new();
234    let mut pending: Option<PendingBlock> = None;
235
236    for line in source.lines() {
237        if is_blank_marker(line) {
238            flush_pending(&mut blocks, &mut pending);
239            blocks.push(MdiBlock::Blank);
240            continue;
241        }
242        if let Some(pagebreak) = pagebreak(line) {
243            flush_pending(&mut blocks, &mut pending);
244            blocks.push(MdiBlock::Pagebreak { variant: pagebreak });
245            continue;
246        }
247        if let Some(marker) = pending_block(line) {
248            if pending.is_some() {
249                flush_pending(&mut blocks, &mut pending);
250                blocks.push(paragraph(line, None));
251            } else {
252                pending = Some(marker);
253            }
254            continue;
255        }
256
257        blocks.push(paragraph(line, pending.take()));
258    }
259    flush_pending(&mut blocks, &mut pending);
260    MdiSyntaxDocument { blocks }
261}
262
263/// Parse a complete `.mdi` document.  CommonMark and all GFM constructs are
264/// parsed by `markdown-rs`; MDI is then lowered into the same tagged tree in
265/// Rust.  The host never tokenizes Markdown or MDI.
266pub fn parse_document(source: &str) -> Document {
267    let prepared = prepare_block_markers(source);
268    let mut constructs = markdown::Constructs::gfm();
269    constructs.frontmatter = true;
270    let options = markdown::ParseOptions {
271        constructs,
272        ..markdown::ParseOptions::default()
273    };
274    let tree = markdown::to_mdast(&prepared.markdown, &options)
275        .expect("MDI does not enable MDX, so Markdown parsing cannot fail");
276    let mut root = serde_json::to_value(tree).expect("markdown AST is serializable");
277    let frontmatter = extract_frontmatter(&root, source);
278    annotate_and_lower(&mut root, source, false);
279    lower_markdown_inside_mdi(&mut root, source);
280    inject_block_markers(&mut root, &prepared.markers);
281    let children = root
282        .get_mut("children")
283        .and_then(serde_json::Value::as_array_mut)
284        .map(|children| {
285            children
286                .drain(..)
287                .filter(|child| {
288                    child.get("type").and_then(serde_json::Value::as_str) != Some("yaml")
289                })
290                .collect()
291        })
292        .unwrap_or_default();
293    Document {
294        span: SourceSpan {
295            start_byte: 0,
296            end_byte: source.len() as u32,
297        },
298        frontmatter,
299        children,
300    }
301}
302
303/// Restore a bracket macro which Markdown would otherwise split at emphasis,
304/// links, or other inline delimiters.  The macro owns its balanced boundary;
305/// its payload is then parsed as Markdown and recursively lowered by Rust.
306fn lower_markdown_inside_mdi(node: &mut serde_json::Value, source: &str) {
307    let Some(object) = node.as_object_mut() else {
308        return;
309    };
310    if object.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
311        let span = object.get("span").cloned();
312        if let Some(raw) = span
313            .as_ref()
314            .and_then(|span| source_from_span(span, source))
315        {
316            let raw = raw.trim_end_matches(['\r', '\n']);
317            if let Some(children) =
318                markdown_paragraph_children(raw, span.as_ref().expect("span exists"))
319            {
320                object.insert("children".to_owned(), serde_json::Value::Array(children));
321                return;
322            }
323        }
324    }
325    if let Some(children) = object
326        .get_mut("children")
327        .and_then(serde_json::Value::as_array_mut)
328    {
329        for child in children {
330            lower_markdown_inside_mdi(child, source);
331        }
332    }
333}
334
335fn markdown_paragraph_children(
336    raw: &str,
337    span: &serde_json::Value,
338) -> Option<Vec<serde_json::Value>> {
339    let paragraph_start = span.get("startByte")?.as_u64()? as usize;
340    let mut output = Vec::new();
341    let mut index = 0;
342    let mut plain_start = 0;
343    let mut found = false;
344    while index < raw.len() {
345        let rest = &raw[index..];
346        if rest.starts_with("[[")
347            && let Some(end) = close_macro(rest)
348            && let Some(mut macro_node) =
349                markdown_macro_children(&rest[..end + 2], paragraph_start + index)
350        {
351            output.append(&mut markdown_fragment_children(
352                &raw[plain_start..index],
353                paragraph_start + plain_start,
354            ));
355            output.append(&mut macro_node);
356            index += end + 2;
357            plain_start = index;
358            found = true;
359            continue;
360        }
361        index += rest.chars().next()?.len_utf8();
362    }
363    if !found {
364        return None;
365    }
366    output.append(&mut markdown_fragment_children(
367        &raw[plain_start..],
368        paragraph_start + plain_start,
369    ));
370    Some(output)
371}
372
373fn markdown_fragment_children(source: &str, start_byte: usize) -> Vec<serde_json::Value> {
374    if source.is_empty() {
375        return Vec::new();
376    }
377    if let Some((text, identifier)) = trailing_footnote_reference(source) {
378        let mut children = markdown_fragment_children(text, start_byte);
379        children.push(serde_json::json!({
380            "type": "footnoteReference",
381            "identifier": identifier,
382            "label": identifier,
383            "span": SourceSpan { start_byte: (start_byte + text.len()) as u32, end_byte: (start_byte + source.len()) as u32 },
384        }));
385        return children;
386    }
387    let leading = source
388        .chars()
389        .take_while(|character| character.is_whitespace())
390        .collect::<String>();
391    let trailing = source
392        .chars()
393        .rev()
394        .take_while(|character| character.is_whitespace())
395        .collect::<String>()
396        .chars()
397        .rev()
398        .collect::<String>();
399    let tree = markdown::to_mdast(source, &markdown_options())
400        .expect("MDI fragment parsing cannot fail when MDX is disabled");
401    let mut tree = serde_json::to_value(tree).expect("Markdown fragment AST is serializable");
402    annotate_and_lower(&mut tree, source, false);
403    shift_spans(&mut tree, start_byte);
404    let mut children = tree
405        .get_mut("children")
406        .and_then(serde_json::Value::as_array_mut)
407        .and_then(|children| children.first_mut())
408        .and_then(|paragraph| paragraph.get("children"))
409        .and_then(serde_json::Value::as_array)
410        .cloned()
411        .unwrap_or_default();
412    if !leading.is_empty()
413        && !children
414            .first()
415            .and_then(|child| child.get("value"))
416            .and_then(serde_json::Value::as_str)
417            .is_some_and(|value| value.starts_with(&leading))
418    {
419        children.insert(
420            0,
421            serde_json::json!({ "type": "text", "value": leading, "span": SourceSpan { start_byte: start_byte as u32, end_byte: (start_byte + leading.len()) as u32 } }),
422        );
423    }
424    if !trailing.is_empty()
425        && !children
426            .last()
427            .and_then(|child| child.get("value"))
428            .and_then(serde_json::Value::as_str)
429            .is_some_and(|value| value.ends_with(&trailing))
430    {
431        children.push(serde_json::json!({ "type": "text", "value": trailing, "span": SourceSpan { start_byte: (start_byte + source.len() - trailing.len()) as u32, end_byte: (start_byte + source.len()) as u32 } }));
432    }
433    children
434}
435
436fn trailing_footnote_reference(source: &str) -> Option<(&str, &str)> {
437    let (text, suffix) = source.rsplit_once("[^")?;
438    let identifier = suffix.strip_suffix(']')?;
439    (!identifier.is_empty() && !identifier.chars().any(char::is_whitespace))
440        .then_some((text, identifier))
441}
442
443fn markdown_options() -> markdown::ParseOptions {
444    let mut constructs = markdown::Constructs::gfm();
445    constructs.frontmatter = false;
446    markdown::ParseOptions {
447        constructs,
448        ..markdown::ParseOptions::default()
449    }
450}
451
452fn source_from_span<'a>(span: &serde_json::Value, source: &'a str) -> Option<&'a str> {
453    source.get(span.get("startByte")?.as_u64()? as usize..span.get("endByte")?.as_u64()? as usize)
454}
455
456/// Map byte boundaries in Markdown's decoded text value back to the raw text
457/// range. Markdown commonly removes a backslash before punctuation; accepting
458/// only that loss keeps this mapping deliberately conservative.
459fn decoded_byte_offsets(decoded: &str, raw: &str) -> Option<Vec<(usize, usize)>> {
460    let mut offsets = vec![(0, 0)];
461    let mut raw_index = 0;
462    for (decoded_index, character) in decoded.char_indices() {
463        let expected_end = decoded_index + character.len_utf8();
464        if raw[raw_index..].starts_with('\\') {
465            let after_slash = raw_index + '\\'.len_utf8();
466            if raw[after_slash..].starts_with(character) {
467                raw_index = after_slash;
468            }
469        }
470        if !raw[raw_index..].starts_with(character) {
471            return None;
472        }
473        raw_index += character.len_utf8();
474        offsets.push((expected_end, raw_index));
475    }
476    (raw_index == raw.len()).then_some(offsets)
477}
478
479fn source_offset(offsets: &[(usize, usize)], decoded_offset: usize) -> Option<usize> {
480    offsets
481        .binary_search_by_key(&decoded_offset, |(decoded, _)| *decoded)
482        .ok()
483        .map(|index| offsets[index].1)
484}
485
486/// Shift a tree parsed from a source fragment back into the parent document.
487/// `markdown-rs` reports fragment-local UTF-8 byte offsets.
488fn shift_spans(node: &mut serde_json::Value, amount: usize) {
489    let Some(object) = node.as_object_mut() else {
490        return;
491    };
492    if let Some(span) = object
493        .get_mut("span")
494        .and_then(serde_json::Value::as_object_mut)
495    {
496        for key in ["startByte", "endByte"] {
497            if let Some(offset) = span.get(key).and_then(serde_json::Value::as_u64) {
498                span.insert(key.to_owned(), serde_json::json!(offset + amount as u64));
499            }
500        }
501    }
502    if let Some(children) = object
503        .get_mut("children")
504        .and_then(serde_json::Value::as_array_mut)
505    {
506        for child in children {
507            shift_spans(child, amount);
508        }
509    }
510}
511
512fn markdown_macro_children(raw: &str, start_byte: usize) -> Option<Vec<serde_json::Value>> {
513    if !raw.starts_with("[[") {
514        return None;
515    }
516    let end = close_macro(raw)?;
517    if end + 2 != raw.len() {
518        return None;
519    }
520    let body = &raw[2..end];
521    let (name, payload) = body.split_once(':')?;
522    let (type_name, extra, content, content_offset) = match name {
523        "no-break" if !payload.is_empty() => (
524            "noBreak",
525            serde_json::Map::new(),
526            payload,
527            2 + name.len() + 1,
528        ),
529        "warichu" => (
530            "warichu",
531            serde_json::Map::new(),
532            payload,
533            2 + name.len() + 1,
534        ),
535        "kern" => {
536            let (amount, content) = payload.split_once(':')?;
537            if !valid_kern(amount) {
538                return None;
539            }
540            let mut extra = serde_json::Map::new();
541            extra.insert("amount".to_owned(), serde_json::json!(unescape_mdi(amount)));
542            (
543                "kern",
544                extra,
545                content,
546                2 + name.len() + 1 + amount.len() + 1,
547            )
548        }
549        "em" => {
550            let (mark, content) = bare_index(payload, ':')
551                .and_then(|index| {
552                    let mark = unescape_mdi(&payload[..index]);
553                    (mark.graphemes(true).count() == 1
554                        && !mark.chars().any(|c| c.is_whitespace() || c.is_control()))
555                    .then_some((mark, &payload[index + 1..]))
556                })
557                .unwrap_or_else(|| ("﹅".to_owned(), payload));
558            let mut extra = serde_json::Map::new();
559            extra.insert("mark".to_owned(), serde_json::json!(mark));
560            let content_offset = raw.len() - 2 - content.len();
561            ("em", extra, content, content_offset)
562        }
563        _ => return None,
564    };
565    let mut constructs = markdown::Constructs::gfm();
566    let options = markdown::ParseOptions {
567        constructs: {
568            constructs.frontmatter = false;
569            constructs
570        },
571        ..markdown::ParseOptions::default()
572    };
573    let tree = markdown::to_mdast(content, &options).ok()?;
574    let mut value = serde_json::to_value(tree).ok()?;
575    annotate_and_lower(&mut value, content, false);
576    shift_spans(&mut value, start_byte + content_offset);
577    let children = value
578        .get_mut("children")?
579        .as_array_mut()?
580        .first_mut()?
581        .get_mut("children")?
582        .as_array()?
583        .clone();
584    let mut node = extra;
585    node.insert("type".to_owned(), serde_json::json!(type_name));
586    node.insert("children".to_owned(), serde_json::Value::Array(children));
587    node.insert(
588        "span".to_owned(),
589        serde_json::json!(SourceSpan {
590            start_byte: start_byte as u32,
591            end_byte: (start_byte + raw.len()) as u32,
592        }),
593    );
594    Some(vec![serde_json::Value::Object(node)])
595}
596
597#[derive(Clone)]
598enum PreparedBlockMarker {
599    Blank(SourceSpan),
600    Pagebreak(SourceSpan, Option<PagebreakVariant>),
601    Indent(SourceSpan, bool, u32),
602}
603
604struct PreparedSource {
605    markdown: String,
606    markers: Vec<PreparedBlockMarker>,
607}
608
609/// Hide standalone MDI flow markers from the Markdown tokenizer without
610/// moving a single byte.  This lets Markdown form the surrounding paragraphs
611/// correctly and preserves every AST offset.  Fenced code and blockquotes are
612/// deliberately excluded by the normative literal-context rule.
613fn prepare_block_markers(source: &str) -> PreparedSource {
614    let mut markdown = String::with_capacity(source.len());
615    let mut markers = Vec::new();
616    let mut offset = 0;
617    let mut fenced = false;
618    for line in source.split_inclusive('\n') {
619        let without_lf = line.strip_suffix('\n').unwrap_or(line);
620        let content = without_lf.strip_suffix('\r').unwrap_or(without_lf);
621        let trimmed_start = content.trim_start();
622        let is_fence = trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~");
623        if is_fence {
624            fenced = !fenced;
625        }
626        let span = SourceSpan {
627            start_byte: offset as u32,
628            end_byte: (offset + content.len()) as u32,
629        };
630        let marker = if !fenced && !trimmed_start.starts_with('>') && content == trimmed_start {
631            if is_blank_marker(content) {
632                Some(PreparedBlockMarker::Blank(span))
633            } else if let Some(variant) = pagebreak(content) {
634                Some(PreparedBlockMarker::Pagebreak(span, variant))
635            } else {
636                pending_block(content).map(|marker| match marker {
637                    PendingBlock::Indent { amount, .. } => {
638                        PreparedBlockMarker::Indent(span, true, amount)
639                    }
640                    PendingBlock::Bottom { amount, .. } => {
641                        PreparedBlockMarker::Indent(span, false, amount)
642                    }
643                })
644            }
645        } else {
646            None
647        };
648        if let Some(marker) = marker {
649            markers.push(marker);
650            markdown.push_str(&" ".repeat(content.len()));
651            markdown.push_str(&line[content.len()..]);
652        } else {
653            markdown.push_str(line);
654        }
655        offset += line.len();
656    }
657    PreparedSource { markdown, markers }
658}
659
660fn inject_block_markers(root: &mut serde_json::Value, markers: &[PreparedBlockMarker]) {
661    let Some(children) = root
662        .get_mut("children")
663        .and_then(serde_json::Value::as_array_mut)
664    else {
665        return;
666    };
667    let mut output = Vec::with_capacity(children.len() + markers.len());
668    let mut marker_index = 0;
669    let mut pending: Option<(SourceSpan, bool, u32)> = None;
670    for mut child in children.drain(..) {
671        let start = child
672            .pointer("/span/startByte")
673            .and_then(serde_json::Value::as_u64)
674            .unwrap_or(u64::MAX) as u32;
675        while let Some(marker) = markers.get(marker_index) {
676            if marker_span(marker).end_byte > start {
677                break;
678            }
679            marker_index += 1;
680            match marker.clone() {
681                PreparedBlockMarker::Blank(span) => {
682                    flush_pending_literal(&mut output, &mut pending);
683                    output.push(serde_json::json!({ "type": "blank", "span": span }));
684                }
685                PreparedBlockMarker::Pagebreak(span, variant) => {
686                    flush_pending_literal(&mut output, &mut pending);
687                    output.push(serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }));
688                }
689                PreparedBlockMarker::Indent(span, is_indent, amount) => {
690                    if pending.is_some() {
691                        flush_pending_literal(&mut output, &mut pending);
692                    }
693                    pending = Some((span, is_indent, amount));
694                }
695            }
696        }
697        if let Some((_span, is_indent, amount)) = pending.take() {
698            if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
699                child.as_object_mut().expect("node is object").insert(
700                    if is_indent { "indent" } else { "bottom" }.to_owned(),
701                    serde_json::json!(amount),
702                );
703            } else {
704                flush_pending_literal(&mut output, &mut Some((_span, is_indent, amount)));
705            }
706        }
707        output.push(child);
708    }
709    while let Some(marker) = markers.get(marker_index) {
710        marker_index += 1;
711        match marker.clone() {
712            PreparedBlockMarker::Blank(span) => {
713                flush_pending_literal(&mut output, &mut pending);
714                output.push(serde_json::json!({ "type": "blank", "span": span }));
715            }
716            PreparedBlockMarker::Pagebreak(span, variant) => {
717                flush_pending_literal(&mut output, &mut pending);
718                output.push(
719                    serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }),
720                );
721            }
722            PreparedBlockMarker::Indent(span, is_indent, amount) => {
723                if pending.is_some() {
724                    flush_pending_literal(&mut output, &mut pending);
725                }
726                pending = Some((span, is_indent, amount));
727            }
728        }
729    }
730    flush_pending_literal(&mut output, &mut pending);
731    *children = output;
732}
733
734fn marker_span(marker: &PreparedBlockMarker) -> SourceSpan {
735    match marker {
736        PreparedBlockMarker::Blank(span)
737        | PreparedBlockMarker::Pagebreak(span, _)
738        | PreparedBlockMarker::Indent(span, _, _) => *span,
739    }
740}
741
742fn flush_pending_literal(
743    output: &mut Vec<serde_json::Value>,
744    pending: &mut Option<(SourceSpan, bool, u32)>,
745) {
746    let Some((span, is_indent, amount)) = pending.take() else {
747        return;
748    };
749    let value = if is_indent {
750        format!("[[indent:{amount}]]")
751    } else if amount == 0 {
752        "[[bottom]]".to_owned()
753    } else {
754        format!("[[bottom:{amount}]]")
755    };
756    output.push(serde_json::json!({ "type": "paragraph", "children": [{ "type": "text", "value": value, "span": span }], "span": span }));
757}
758
759fn annotate_and_lower(node: &mut serde_json::Value, source: &str, protected: bool) {
760    let Some(object) = node.as_object_mut() else {
761        return;
762    };
763    let node_type = object
764        .get("type")
765        .and_then(serde_json::Value::as_str)
766        .unwrap_or("")
767        .to_owned();
768    // Code and raw nodes have no `text` children in mdast, but are also
769    // protected here for future markdown-rs node changes.  Block quotes are
770    // deliberately *not* protected: their ordinary inline MDI remains valid;
771    // only flow markers are excluded in `prepare_block_markers`.
772    let protected = protected || matches!(node_type.as_str(), "code" | "inlineCode" | "html");
773    let span = object
774        .remove("position")
775        .as_ref()
776        .and_then(|position| span_from_position(position, source));
777    if let Some(span) = span {
778        object.insert("span".to_owned(), serde_json::json!(span));
779    }
780    if let Some(children) = object
781        .get_mut("children")
782        .and_then(serde_json::Value::as_array_mut)
783    {
784        for child in children.iter_mut() {
785            annotate_and_lower(child, source, protected);
786        }
787        let mut flattened = Vec::with_capacity(children.len());
788        for child in children.drain(..) {
789            if child.get("type").and_then(serde_json::Value::as_str) == Some("mdiFragment")
790                && let Some(mut fragment) = child
791                    .get("children")
792                    .and_then(serde_json::Value::as_array)
793                    .cloned()
794            {
795                flattened.append(&mut fragment);
796                continue;
797            }
798            flattened.push(child);
799        }
800        *children = flattened;
801    }
802    if protected || node_type != "text" {
803        return;
804    }
805    let Some(rendered_value) = object.get("value").and_then(serde_json::Value::as_str) else {
806        return;
807    };
808    // Markdown may consume a backslash while decoding a text node.  MDI must
809    // still recognize the decoded spelling (notably `\|` inside a GFM table
810    // cell), but its spans must refer to the original bytes.  Keep a mapping
811    // from decoded byte boundaries back to the source range for that case.
812    let source_offsets = span
813        .as_ref()
814        .and_then(|span| source.get(span.start_byte as usize..span.end_byte as usize))
815        .and_then(|raw| decoded_byte_offsets(rendered_value, raw));
816    if !looks_like_mdi(rendered_value) {
817        return;
818    }
819    let span = object.get("span").cloned();
820    let parsed = parse_inline_parts(rendered_value);
821    if parsed.len() == 1 && matches!(parsed.first(), Some((Inline::Text(_), _, _))) {
822        return;
823    }
824    let replacement: Vec<serde_json::Value> = parsed
825        .into_iter()
826        .map(|(inline, start, end)| {
827            let mut value = serde_json::to_value(inline).expect("MDI inline is serializable");
828            if let (Some(token_span), Some(object)) = (&span, value.as_object_mut()) {
829                let start_byte = token_span
830                    .get("startByte")
831                    .and_then(serde_json::Value::as_u64);
832                if let Some(start_byte) = start_byte {
833                    let start = source_offsets
834                        .as_ref()
835                        .and_then(|offsets| source_offset(offsets, start))
836                        .unwrap_or(start);
837                    let end = source_offsets
838                        .as_ref()
839                        .and_then(|offsets| source_offset(offsets, end))
840                        .unwrap_or(end);
841                    object.insert(
842                        "span".to_owned(),
843                        serde_json::json!(SourceSpan {
844                            start_byte: (start_byte as usize + start) as u32,
845                            end_byte: (start_byte as usize + end) as u32,
846                        }),
847                    );
848                }
849            }
850            value
851        })
852        .collect();
853    *node = serde_json::json!({ "type": "mdiFragment", "children": replacement, "span": span });
854}
855
856fn looks_like_mdi(value: &str) -> bool {
857    value.contains(['{', '^', '《', '[', '\\'])
858}
859
860fn span_from_position(value: &serde_json::Value, source: &str) -> Option<SourceSpan> {
861    let start = value.pointer("/start/offset")?.as_u64()? as usize;
862    let end = value.pointer("/end/offset")?.as_u64()? as usize;
863    Some(SourceSpan {
864        start_byte: character_offset_to_byte(source, start) as u32,
865        end_byte: character_offset_to_byte(source, end) as u32,
866    })
867}
868
869fn character_offset_to_byte(source: &str, offset: usize) -> usize {
870    // `markdown-rs` stores this field as a UTF-8 byte offset.
871    offset.min(source.len())
872}
873
874fn extract_frontmatter(root: &serde_json::Value, source: &str) -> Option<Frontmatter> {
875    let yaml = root.get("children")?.as_array()?.first()?;
876    if yaml.get("type")?.as_str()? != "yaml" {
877        return None;
878    }
879    let raw = yaml.get("value")?.as_str()?.to_owned();
880    let span = yaml
881        .get("position")
882        .and_then(|value| span_from_position(value, source))?;
883    let entries = match serde_yaml::from_str::<serde_yaml::Value>(&raw) {
884        Ok(serde_yaml::Value::Mapping(mapping)) => mapping
885            .into_iter()
886            .filter_map(|(key, value)| {
887                let key = key.as_str()?.to_owned();
888                let value = serde_json::to_value(value).ok()?;
889                Some(FrontmatterEntry { key, value })
890            })
891            .collect(),
892        _ => Vec::new(),
893    };
894    Some(Frontmatter { span, raw, entries })
895}
896
897fn diagnostics(document: &Document) -> Vec<Diagnostic> {
898    let Some(frontmatter) = document.frontmatter.as_ref() else {
899        return Vec::new();
900    };
901    let declared = frontmatter.entries.iter().find(|entry| entry.key == "mdi");
902    let Some(declared) = declared.and_then(|entry| entry.value.as_str()) else {
903        return Vec::new();
904    };
905    if declared > MDI_SPEC_VERSION {
906        vec![Diagnostic {
907            severity: DiagnosticSeverity::Warning,
908            code: "mdi.version.unsupported".to_owned(),
909            message: format!("MDI {declared} is newer than the supported {MDI_SPEC_VERSION}"),
910            span: Some(frontmatter.span),
911        }]
912    } else {
913        Vec::new()
914    }
915}
916
917/// Parse the complete CommonMark, GFM, front-matter, and MDI document and
918/// return the versioned wire envelope used by language bindings.
919pub fn parse_output(source: &str) -> ParseOutput {
920    let document = parse_document(source);
921    ParseOutput {
922        ir_version: MDI_IR_VERSION,
923        syntax_version: MDI_SPEC_VERSION,
924        capabilities: ParserCapabilities {
925            mdi: true,
926            common_mark: true,
927            gfm: true,
928            front_matter: true,
929            source_spans: true,
930        },
931        diagnostics: diagnostics(&document),
932        document,
933    }
934}
935
936/// Serialize [`parse_output`] for FFI boundaries.
937///
938/// JSON is the first portable contract because it behaves identically through
939/// WebAssembly, N-API, PyO3, and a C ABI. Native bindings may later provide
940/// zero-copy views without changing this wire representation.
941pub fn parse_json(source: &str) -> String {
942    serde_json::to_string(&parse_output(source))
943        .expect("serializing the MDI parse output cannot fail")
944}
945
946/// Render a complete source document to a standalone HTML document directly
947/// from the Rust-owned IR.  This is intentionally source-oriented for FFI:
948/// bindings pass UTF-8 source once and never reconstruct MDI syntax in a host
949/// renderer.
950pub fn render_html(source: &str) -> String {
951    render_html_document(&parse_document(source))
952}
953
954/// Render a previously parsed document to standalone HTML.
955pub fn render_html_document(document: &Document) -> String {
956    let frontmatter = document.frontmatter.as_ref();
957    let field = |key: &str| {
958        frontmatter
959            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
960            .and_then(|entry| entry.value.as_str())
961    };
962    let lang = field("lang").unwrap_or("ja");
963    let title = field("title")
964        .map(|title| format!("<title>{}</title>", escape_html(title)))
965        .unwrap_or_default();
966    let vertical = matches!(field("writing-mode"), Some("vertical"));
967    let writing_mode = if vertical {
968        " style=\"writing-mode: vertical-rl;\""
969    } else {
970        ""
971    };
972    let mut body = String::new();
973    let mut footnotes = Vec::new();
974    for child in &document.children {
975        if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
976            footnotes.push(child);
977        } else {
978            render_html_node(child, &mut body);
979        }
980    }
981    if !footnotes.is_empty() {
982        body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\">Footnotes</h2><ol>");
983        for (index, footnote) in footnotes.into_iter().enumerate() {
984            body.push_str("<li id=\"user-content-fn-");
985            body.push_str(&escape_html(
986                footnote
987                    .get("identifier")
988                    .and_then(serde_json::Value::as_str)
989                    .unwrap_or(&format!("{}", index + 1)),
990            ));
991            body.push_str("\">");
992            render_html_children(footnote, &mut body);
993            body.push_str("</li>");
994        }
995        body.push_str("</ol></section>");
996    }
997    format!(
998        "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
999        escape_html(lang),
1000        writing_mode,
1001        title,
1002        MDI_STYLESHEET,
1003        body
1004    )
1005}
1006
1007/// Parse and serialize source to canonical MDI/Markdown spelling in Rust.
1008pub fn serialize_mdi(source: &str) -> String {
1009    serialize_mdi_document(&parse_document(source))
1010}
1011
1012/// Serialize a parsed document without invoking a host Markdown serializer.
1013pub fn serialize_mdi_document(document: &Document) -> String {
1014    let mut output = String::new();
1015    if let Some(frontmatter) = &document.frontmatter {
1016        output.push_str("---\n");
1017        output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1018        output.push_str("\n---\n\n");
1019    }
1020    for (index, node) in document.children.iter().enumerate() {
1021        if index > 0 && !output.ends_with("\n\n") {
1022            output.push('\n');
1023        }
1024        serialize_block(node, &mut output, "");
1025    }
1026    output
1027}
1028
1029/// Render source to deterministic plain text from the Rust IR. Typography
1030/// annotations are represented by their readable base text; page and blank
1031/// paragraph boundaries remain visible as newlines.
1032pub fn render_text(source: &str) -> String {
1033    render_text_document(&parse_document(source))
1034}
1035
1036/// Render a parsed document to plain text.
1037pub fn render_text_document(document: &Document) -> String {
1038    let mut output = String::new();
1039    for node in &document.children {
1040        render_text_node(node, &mut output);
1041        if !output.ends_with('\n') {
1042            output.push('\n');
1043        }
1044    }
1045    output
1046}
1047
1048/// Text export conventions supported by the portable core.
1049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum TextFormat {
1051    Plain,
1052    Ruby,
1053    Narou,
1054    Kakuyomu,
1055    Aozora,
1056}
1057
1058impl TextFormat {
1059    /// Parse the stable binding names used by the CLI and JavaScript API.
1060    pub fn parse(value: &str) -> Option<Self> {
1061        match value {
1062            "txt" => Some(Self::Plain),
1063            "txt-ruby" => Some(Self::Ruby),
1064            "narou" => Some(Self::Narou),
1065            "kakuyomu" => Some(Self::Kakuyomu),
1066            "aozora" => Some(Self::Aozora),
1067            _ => None,
1068        }
1069    }
1070}
1071
1072/// Render a publication-platform text variant from Rust IR. `indent_prefix`
1073/// is supplied by the host's already-resolved export profile.
1074pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1075    let document = parse_document(source);
1076    let definitions: Vec<&serde_json::Value> = document
1077        .children
1078        .iter()
1079        .filter(|node| {
1080            node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1081        })
1082        .collect();
1083    let mut blocks = Vec::new();
1084    for node in &document.children {
1085        text_format_block(node, format, indent_prefix, &definitions, &mut blocks);
1086    }
1087    if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1088        blocks.push(String::new());
1089        blocks.push("Footnotes".to_owned());
1090        for (index, definition) in definitions.iter().enumerate() {
1091            let text = children(definition)
1092                .iter()
1093                .filter(|child| {
1094                    child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1095                })
1096                .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1097                .collect::<Vec<_>>()
1098                .join(" ");
1099            blocks.push(format!("{}. {text}", index + 1));
1100        }
1101    }
1102    let output = blocks.join("\n");
1103    if matches!(format, TextFormat::Aozora) {
1104        output.replace('\n', "\r\n")
1105    } else {
1106        output
1107    }
1108}
1109
1110fn text_format_block(
1111    node: &serde_json::Value,
1112    format: TextFormat,
1113    prefix: &str,
1114    definitions: &[&serde_json::Value],
1115    output: &mut Vec<String>,
1116) {
1117    let kind = node
1118        .get("type")
1119        .and_then(serde_json::Value::as_str)
1120        .unwrap_or_default();
1121    match kind {
1122        "footnoteDefinition" | "definition" => {}
1123        "paragraph" => output.push(format!(
1124            "{prefix}{}",
1125            text_format_inline_children(node, format, definitions)
1126        )),
1127        "heading" => {
1128            let value = text_format_inline_children(node, format, definitions);
1129            if matches!(format, TextFormat::Aozora) {
1130                let size = match node
1131                    .get("depth")
1132                    .and_then(serde_json::Value::as_u64)
1133                    .unwrap_or(3)
1134                {
1135                    1 => "大",
1136                    2 => "中",
1137                    _ => "小",
1138                };
1139                output.push(format!("{value}[#「{value}」は{size}見出し]"));
1140            } else {
1141                output.push(value);
1142            }
1143        }
1144        "list" => {
1145            for (index, item) in children(node).iter().enumerate() {
1146                for child in children(item) {
1147                    if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1148                        let bullet = if node
1149                            .get("ordered")
1150                            .and_then(serde_json::Value::as_bool)
1151                            .unwrap_or(false)
1152                        {
1153                            format!("{}. ", index + 1)
1154                        } else {
1155                            "- ".to_owned()
1156                        };
1157                        output.push(format!(
1158                            "{prefix}{bullet}{}",
1159                            text_format_inline_children(child, format, definitions)
1160                        ));
1161                    } else {
1162                        text_format_block(child, format, prefix, definitions, output);
1163                    }
1164                }
1165            }
1166        }
1167        "blockquote" => {
1168            for child in children(node) {
1169                text_format_block(child, format, prefix, definitions, output);
1170            }
1171        }
1172        "code" => output.extend(
1173            node.get("value")
1174                .and_then(serde_json::Value::as_str)
1175                .unwrap_or_default()
1176                .lines()
1177                .map(str::to_owned),
1178        ),
1179        "table" => {
1180            for row in children(node) {
1181                output.push(
1182                    children(row)
1183                        .iter()
1184                        .map(|cell| text_format_inline_children(cell, format, definitions))
1185                        .collect::<Vec<_>>()
1186                        .join("\t"),
1187                );
1188            }
1189        }
1190        "thematicBreak" => output.push("――――――".to_owned()),
1191        "blank" | "pagebreak" => output.push(String::new()),
1192        _ => {}
1193    }
1194}
1195
1196fn text_format_inline_children(
1197    node: &serde_json::Value,
1198    format: TextFormat,
1199    definitions: &[&serde_json::Value],
1200) -> String {
1201    children(node)
1202        .iter()
1203        .map(|node| text_format_inline(node, format, definitions))
1204        .collect()
1205}
1206fn text_format_inline(
1207    node: &serde_json::Value,
1208    format: TextFormat,
1209    definitions: &[&serde_json::Value],
1210) -> String {
1211    match node
1212        .get("type")
1213        .and_then(serde_json::Value::as_str)
1214        .unwrap_or_default()
1215    {
1216        "text" | "inlineCode" | "tcy" => node
1217            .get("value")
1218            .and_then(serde_json::Value::as_str)
1219            .unwrap_or_default()
1220            .to_owned(),
1221        "break" => "\n".to_owned(),
1222        "ruby" => {
1223            let base = node
1224                .get("base")
1225                .and_then(serde_json::Value::as_str)
1226                .unwrap_or_default();
1227            let reading = node
1228                .pointer("/ruby/value")
1229                .map(|value| match value {
1230                    serde_json::Value::Array(parts) => parts
1231                        .iter()
1232                        .filter_map(serde_json::Value::as_str)
1233                        .collect::<Vec<_>>()
1234                        .join(if matches!(format, TextFormat::Ruby) {
1235                            "."
1236                        } else {
1237                            ""
1238                        }),
1239                    serde_json::Value::String(value) => value.to_owned(),
1240                    _ => String::new(),
1241                })
1242                .unwrap_or_default();
1243            match format {
1244                TextFormat::Plain => base.to_owned(),
1245                TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1246                _ => format!("|{base}《{reading}》"),
1247            }
1248        }
1249        "em" => {
1250            let value = text_format_inline_children(node, format, definitions);
1251            match format {
1252                TextFormat::Aozora => format!("{value}[#「{value}」に傍点]"),
1253                TextFormat::Kakuyomu => format!("《《{value}》》"),
1254                _ => value,
1255            }
1256        }
1257        "image" => node
1258            .get("alt")
1259            .and_then(serde_json::Value::as_str)
1260            .filter(|alt| !alt.is_empty())
1261            .map(|alt| format!("[画像: {alt}]"))
1262            .unwrap_or_else(|| "[画像]".to_owned()),
1263        "footnoteReference" => {
1264            if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1265                String::new()
1266            } else {
1267                let identifier = node
1268                    .get("identifier")
1269                    .and_then(serde_json::Value::as_str)
1270                    .unwrap_or_default();
1271                let index = definitions
1272                    .iter()
1273                    .position(|definition| {
1274                        definition
1275                            .get("identifier")
1276                            .and_then(serde_json::Value::as_str)
1277                            == Some(identifier)
1278                    })
1279                    .map(|index| index + 1)
1280                    .unwrap_or(0);
1281                format!("[注{index}]")
1282            }
1283        }
1284        _ => text_format_inline_children(node, format, definitions),
1285    }
1286}
1287
1288fn render_text_node(node: &serde_json::Value, out: &mut String) {
1289    match node
1290        .get("type")
1291        .and_then(serde_json::Value::as_str)
1292        .unwrap_or_default()
1293    {
1294        "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
1295            node.get("value")
1296                .and_then(serde_json::Value::as_str)
1297                .unwrap_or_default(),
1298        ),
1299        "ruby" => out.push_str(
1300            node.get("base")
1301                .and_then(serde_json::Value::as_str)
1302                .unwrap_or_default(),
1303        ),
1304        "image" => out.push_str(
1305            node.get("alt")
1306                .and_then(serde_json::Value::as_str)
1307                .unwrap_or_default(),
1308        ),
1309        "break" => out.push('\n'),
1310        "blank" => out.push('\n'),
1311        "pagebreak" => out.push_str("\n\x0C\n"),
1312        "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
1313            render_text_children(node, out);
1314            out.push('\n');
1315        }
1316        "tableCell" => {
1317            render_text_children(node, out);
1318            out.push('\t');
1319        }
1320        _ => render_text_children(node, out),
1321    }
1322}
1323
1324fn render_text_children(node: &serde_json::Value, out: &mut String) {
1325    for child in children(node) {
1326        render_text_node(child, out);
1327    }
1328}
1329
1330fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
1331    let kind = node
1332        .get("type")
1333        .and_then(serde_json::Value::as_str)
1334        .unwrap_or_default();
1335    match kind {
1336        "paragraph" => {
1337            if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
1338                out.push_str(prefix);
1339                out.push_str(&format!("[[indent:{amount}]]\n"));
1340            }
1341            if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
1342                out.push_str(prefix);
1343                if amount == 0 {
1344                    out.push_str("[[bottom]]\n");
1345                } else {
1346                    out.push_str(&format!("[[bottom:{amount}]]\n"));
1347                }
1348            }
1349            out.push_str(prefix);
1350            serialize_inline_children(node, out);
1351            out.push('\n');
1352        }
1353        "heading" => {
1354            out.push_str(prefix);
1355            out.push_str(
1356                &"#".repeat(
1357                    node.get("depth")
1358                        .and_then(serde_json::Value::as_u64)
1359                        .unwrap_or(1) as usize,
1360                ),
1361            );
1362            out.push(' ');
1363            serialize_inline_children(node, out);
1364            out.push('\n');
1365        }
1366        "blockquote" => {
1367            let mut content = String::new();
1368            for child in children(node) {
1369                serialize_block(child, &mut content, "");
1370            }
1371            for line in content.trim_end_matches('\n').lines() {
1372                out.push_str(prefix);
1373                out.push_str("> ");
1374                out.push_str(line);
1375                out.push('\n');
1376            }
1377        }
1378        "list" => {
1379            let ordered = node
1380                .get("ordered")
1381                .and_then(serde_json::Value::as_bool)
1382                .unwrap_or(false);
1383            let start = node
1384                .get("start")
1385                .and_then(serde_json::Value::as_u64)
1386                .unwrap_or(1);
1387            for (index, item) in children(node).iter().enumerate() {
1388                out.push_str(prefix);
1389                if ordered {
1390                    out.push_str(&format!("{}.", start + index as u64));
1391                } else {
1392                    out.push('-');
1393                }
1394                out.push(' ');
1395                if let Some(first) = children(item).first() {
1396                    serialize_block(first, out, "");
1397                }
1398                for child in children(item).iter().skip(1) {
1399                    serialize_block(child, out, "  ");
1400                }
1401            }
1402        }
1403        "code" => {
1404            out.push_str(prefix);
1405            out.push_str("```");
1406            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
1407                out.push_str(lang);
1408            }
1409            out.push('\n');
1410            out.push_str(
1411                node.get("value")
1412                    .and_then(serde_json::Value::as_str)
1413                    .unwrap_or_default(),
1414            );
1415            out.push_str("\n```\n");
1416        }
1417        "thematicBreak" => out.push_str("---\n"),
1418        "blank" => out.push_str("\\\n"),
1419        "pagebreak" => {
1420            out.push_str("[[pagebreak");
1421            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
1422                out.push(':');
1423                out.push_str(variant);
1424            }
1425            out.push_str("]]\n");
1426        }
1427        "table" => serialize_table(node, out),
1428        "html" => {
1429            out.push_str(
1430                node.get("value")
1431                    .and_then(serde_json::Value::as_str)
1432                    .unwrap_or_default(),
1433            );
1434            out.push('\n');
1435        }
1436        _ => {
1437            serialize_inline(node, out);
1438            out.push('\n');
1439        }
1440    }
1441}
1442
1443fn serialize_table(node: &serde_json::Value, out: &mut String) {
1444    for (row_index, row) in children(node).iter().enumerate() {
1445        out.push('|');
1446        for cell in children(row) {
1447            out.push(' ');
1448            serialize_inline_children(cell, out);
1449            out.push_str(" |");
1450        }
1451        out.push('\n');
1452        if row_index == 0 {
1453            out.push('|');
1454            for _ in children(row) {
1455                out.push_str(" --- |");
1456            }
1457            out.push('\n');
1458        }
1459    }
1460}
1461
1462fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
1463    for child in children(node) {
1464        serialize_inline(child, out);
1465    }
1466}
1467
1468fn serialize_inline(node: &serde_json::Value, out: &mut String) {
1469    let kind = node
1470        .get("type")
1471        .and_then(serde_json::Value::as_str)
1472        .unwrap_or_default();
1473    match kind {
1474        "text" | "html" => out.push_str(
1475            node.get("value")
1476                .and_then(serde_json::Value::as_str)
1477                .unwrap_or_default(),
1478        ),
1479        "emphasis" => {
1480            out.push('*');
1481            serialize_inline_children(node, out);
1482            out.push('*');
1483        }
1484        "strong" => {
1485            out.push_str("**");
1486            serialize_inline_children(node, out);
1487            out.push_str("**");
1488        }
1489        "delete" => {
1490            out.push_str("~~");
1491            serialize_inline_children(node, out);
1492            out.push_str("~~");
1493        }
1494        "inlineCode" => {
1495            out.push('`');
1496            out.push_str(
1497                node.get("value")
1498                    .and_then(serde_json::Value::as_str)
1499                    .unwrap_or_default(),
1500            );
1501            out.push('`');
1502        }
1503        "link" => {
1504            out.push('[');
1505            serialize_inline_children(node, out);
1506            out.push_str("](");
1507            out.push_str(
1508                node.get("url")
1509                    .and_then(serde_json::Value::as_str)
1510                    .unwrap_or_default(),
1511            );
1512            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
1513                out.push_str(" \\");
1514                out.push_str(title);
1515                out.push('\"');
1516            }
1517            out.push(')');
1518        }
1519        "image" => {
1520            out.push_str("![");
1521            out.push_str(
1522                node.get("alt")
1523                    .and_then(serde_json::Value::as_str)
1524                    .unwrap_or_default(),
1525            );
1526            out.push_str("](");
1527            out.push_str(
1528                node.get("url")
1529                    .and_then(serde_json::Value::as_str)
1530                    .unwrap_or_default(),
1531            );
1532            out.push(')');
1533        }
1534        "ruby" => {
1535            out.push('{');
1536            out.push_str(
1537                node.get("base")
1538                    .and_then(serde_json::Value::as_str)
1539                    .unwrap_or_default(),
1540            );
1541            out.push('|');
1542            if let Some(values) = node
1543                .pointer("/ruby/value")
1544                .and_then(serde_json::Value::as_array)
1545            {
1546                for (index, value) in values.iter().enumerate() {
1547                    if index > 0 {
1548                        out.push('.');
1549                    }
1550                    out.push_str(value.as_str().unwrap_or_default());
1551                }
1552            } else {
1553                out.push_str(
1554                    node.pointer("/ruby/value")
1555                        .and_then(serde_json::Value::as_str)
1556                        .unwrap_or_default(),
1557                );
1558            }
1559            out.push('}');
1560        }
1561        "tcy" => {
1562            out.push('^');
1563            out.push_str(
1564                node.get("value")
1565                    .and_then(serde_json::Value::as_str)
1566                    .unwrap_or_default(),
1567            );
1568            out.push('^');
1569        }
1570        // Markdown hard breaks and MDI `[[br]]` have the same IR node; the
1571        // canonical serializer chooses the explicit MDI spelling.
1572        "break" => out.push_str("[[br]]"),
1573        "em" => {
1574            out.push_str("[[em:");
1575            let mark = node
1576                .get("mark")
1577                .and_then(serde_json::Value::as_str)
1578                .unwrap_or("﹅");
1579            if mark != "﹅" {
1580                out.push_str(mark);
1581                out.push(':');
1582            }
1583            serialize_inline_children(node, out);
1584            out.push_str("]]");
1585        }
1586        "noBreak" => {
1587            out.push_str("[[no-break:");
1588            serialize_inline_children(node, out);
1589            out.push_str("]]");
1590        }
1591        "warichu" => {
1592            out.push_str("[[warichu:");
1593            serialize_inline_children(node, out);
1594            out.push_str("]]");
1595        }
1596        "kern" => {
1597            out.push_str("[[kern:");
1598            out.push_str(
1599                node.get("amount")
1600                    .and_then(serde_json::Value::as_str)
1601                    .unwrap_or_default(),
1602            );
1603            out.push(':');
1604            serialize_inline_children(node, out);
1605            out.push_str("]]");
1606        }
1607        "footnoteReference" => {
1608            out.push_str("[^");
1609            out.push_str(
1610                node.get("identifier")
1611                    .and_then(serde_json::Value::as_str)
1612                    .unwrap_or_default(),
1613            );
1614            out.push(']');
1615        }
1616        _ => serialize_inline_children(node, out),
1617    }
1618}
1619
1620fn children(node: &serde_json::Value) -> &[serde_json::Value] {
1621    node.get("children")
1622        .and_then(serde_json::Value::as_array)
1623        .map(Vec::as_slice)
1624        .unwrap_or(&[])
1625}
1626
1627/// The base stylesheet is intentionally shipped by the core alongside the
1628/// semantic HTML. Hosts may add presentation CSS, but not reinterpret nodes.
1629pub const MDI_STYLESHEET: &str = ".mdi-tcy{text-combine-upright:all}.mdi-nobr{white-space:nowrap}.mdi-warichu{font-size:.6em}.mdi-em{text-emphasis:var(--mdi-em,filled sesame)}.mdi-kern{letter-spacing:var(--mdi-kern)}.mdi-blank{min-height:1em}.mdi-indent{margin-inline-start:calc(var(--mdi-indent)*1em)}.mdi-bottom{text-align:end}.mdi-pagebreak{break-after:page}";
1630
1631/// Build a reflowable EPUB 3 archive entirely from Rust's document IR.
1632/// Metadata comes from front matter; richer cover and print-profile options
1633/// are intentionally layered on this deterministic core API later.
1634pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
1635    render_epub_document(&parse_document(source))
1636}
1637
1638/// Build a reflowable EPUB 3 archive from a parsed document.
1639pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
1640    let cursor = Cursor::new(Vec::new());
1641    let mut zip = ZipWriter::new(cursor);
1642    write_epub_document(document, &mut zip)?;
1643    zip.finish()
1644        .map(|cursor| cursor.into_inner())
1645        .map_err(|error| error.to_string())
1646}
1647
1648fn write_epub_document<W: Write + Seek>(
1649    document: &Document,
1650    zip: &mut ZipWriter<W>,
1651) -> Result<(), String> {
1652    let field = |key: &str| {
1653        document
1654            .frontmatter
1655            .as_ref()
1656            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1657            .and_then(|entry| entry.value.as_str())
1658    };
1659    let title = field("title").unwrap_or("Untitled");
1660    let author = field("author");
1661    let language = field("lang").unwrap_or("ja");
1662    let identifier = field("identifier").unwrap_or("urn:mdi:document");
1663    let vertical = matches!(field("writing-mode"), Some("vertical"));
1664    let chapters = epub_chapters(document);
1665    let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1666    epub_file(zip, "mimetype", "application/epub+zip", stored)?;
1667    let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1668    epub_file(
1669        zip,
1670        "META-INF/container.xml",
1671        "<?xml version=\"1.0\"?><container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/package.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>",
1672        compressed,
1673    )?;
1674    let writing = if vertical {
1675        "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
1676    } else {
1677        ""
1678    };
1679    epub_file(
1680        zip,
1681        "OEBPS/style.css",
1682        &format!(
1683            "body{{font-family:serif;{writing}line-height:1.8;margin:1em}}p{{text-indent:1em;margin:.3em 0}}{MDI_STYLESHEET}"
1684        ),
1685        compressed,
1686    )?;
1687    let nav_items = chapters
1688        .iter()
1689        .enumerate()
1690        .map(|(index, chapter)| {
1691            format!(
1692                "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
1693                index + 1,
1694                escape_html(&chapter.title)
1695            )
1696        })
1697        .collect::<String>();
1698    epub_file(
1699        zip,
1700        "OEBPS/nav.xhtml",
1701        &epub_xhtml(
1702            "Contents",
1703            language,
1704            &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
1705        ),
1706        compressed,
1707    )?;
1708    for (index, chapter) in chapters.iter().enumerate() {
1709        epub_file(
1710            zip,
1711            &format!("OEBPS/chapter-{}.xhtml", index + 1),
1712            &epub_xhtml(
1713                if chapter.title.is_empty() {
1714                    title
1715                } else {
1716                    &chapter.title
1717                },
1718                language,
1719                &chapter.html,
1720            ),
1721            compressed,
1722        )?;
1723    }
1724    let manifest = format!("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/><item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>{}", chapters.iter().enumerate().map(|(index, _)| format!("<item id=\"chapter-{}\" href=\"chapter-{}.xhtml\" media-type=\"application/xhtml+xml\"/>", index + 1, index + 1)).collect::<String>());
1725    let spine = chapters
1726        .iter()
1727        .enumerate()
1728        .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
1729        .collect::<String>();
1730    let creator = author
1731        .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
1732        .unwrap_or_default();
1733    let progression = if vertical {
1734        " page-progression-direction=\"rtl\""
1735    } else {
1736        ""
1737    };
1738    epub_file(
1739        zip,
1740        "OEBPS/package.opf",
1741        &format!(
1742            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"book-id\"><metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:identifier id=\"book-id\">{}</dc:identifier><dc:title>{}</dc:title><dc:language>{}</dc:language>{creator}</metadata><manifest>{manifest}</manifest><spine{progression}>{spine}</spine></package>",
1743            escape_html(identifier),
1744            escape_html(title),
1745            escape_html(language)
1746        ),
1747        compressed,
1748    )?;
1749    Ok(())
1750}
1751
1752/// Build a baseline DOCX archive directly from Rust IR. It deliberately uses
1753/// WordprocessingML rather than a host document library, so source syntax and
1754/// document meaning remain inside the core.
1755pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
1756    render_docx_document(&parse_document(source))
1757}
1758
1759/// Build a baseline DOCX archive from a parsed document.
1760pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
1761    let cursor = Cursor::new(Vec::new());
1762    let mut zip = ZipWriter::new(cursor);
1763    write_docx_document(document, &mut zip)?;
1764    zip.finish()
1765        .map(|cursor| cursor.into_inner())
1766        .map_err(|error| error.to_string())
1767}
1768
1769fn write_docx_document<W: Write + Seek>(
1770    document: &Document,
1771    zip: &mut ZipWriter<W>,
1772) -> Result<(), String> {
1773    let title = document
1774        .frontmatter
1775        .as_ref()
1776        .and_then(|frontmatter| {
1777            frontmatter
1778                .entries
1779                .iter()
1780                .find(|entry| entry.key == "title")
1781        })
1782        .and_then(|entry| entry.value.as_str())
1783        .unwrap_or("Untitled");
1784    let mut content = String::new();
1785    for node in &document.children {
1786        if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
1787            content.push('\x0C');
1788            content.push('\n');
1789        } else {
1790            render_text_node(node, &mut content);
1791            if !content.ends_with('\n') {
1792                content.push('\n');
1793            }
1794        }
1795    }
1796    let paragraphs = content
1797        .split('\n')
1798        .map(|line| {
1799            if line.contains('\x0C') {
1800                "<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>".to_owned()
1801            } else {
1802                format!(
1803                    "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
1804                    escape_xml(line)
1805                )
1806            }
1807        })
1808        .collect::<String>();
1809    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1810    docx_file(
1811        zip,
1812        "[Content_Types].xml",
1813        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/><Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/></Types>",
1814        options,
1815    )?;
1816    docx_file(
1817        zip,
1818        "_rels/.rels",
1819        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/><Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/></Relationships>",
1820        options,
1821    )?;
1822    docx_file(
1823        zip,
1824        "docProps/core.xml",
1825        &format!(
1826            "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
1827            escape_xml(title)
1828        ),
1829        options,
1830    )?;
1831    docx_file(
1832        zip,
1833        "word/document.xml",
1834        &format!(
1835            "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>{paragraphs}<w:sectPr/></w:body></w:document>"
1836        ),
1837        options,
1838    )?;
1839    Ok(())
1840}
1841
1842/// Native configuration for Chromium PDF layout. WebAssembly deliberately
1843/// cannot expose this operation because it cannot launch a local process.
1844#[derive(Debug, Clone, Default)]
1845pub struct PdfOptions {
1846    pub chromium_path: Option<PathBuf>,
1847}
1848
1849/// Render PDF by asking a locally installed Chromium-compatible browser to
1850/// lay out Rust-generated HTML. Chromium never receives MDI source.
1851pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
1852    let chromium = options
1853        .chromium_path
1854        .clone()
1855        .or_else(find_chromium)
1856        .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
1857    let nonce = SystemTime::now()
1858        .duration_since(UNIX_EPOCH)
1859        .map_err(|error| error.to_string())?
1860        .as_nanos();
1861    let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
1862    fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
1863    let html_path = directory.join("document.html");
1864    let pdf_path = directory.join("document.pdf");
1865    let result = (|| {
1866        fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
1867        let output = Command::new(&chromium)
1868            .arg("--headless=new")
1869            .arg("--disable-gpu")
1870            .arg("--no-pdf-header-footer")
1871            .arg(format!("--print-to-pdf={}", pdf_path.display()))
1872            .arg(format!("file://{}", html_path.display()))
1873            .output()
1874            .map_err(|error| {
1875                format!(
1876                    "failed to start Chromium at {}: {error}",
1877                    chromium.display()
1878                )
1879            })?;
1880        if !output.status.success() {
1881            return Err(format!(
1882                "Chromium PDF rendering failed: {}",
1883                String::from_utf8_lossy(&output.stderr)
1884            ));
1885        }
1886        fs::read(&pdf_path).map_err(|error| error.to_string())
1887    })();
1888    let _ = fs::remove_dir_all(&directory);
1889    result
1890}
1891
1892/// Locate common native Chromium installations. Production callers should
1893/// prefer the explicit `PdfOptions.chromium_path` for deterministic deploys.
1894pub fn find_chromium() -> Option<PathBuf> {
1895    let candidates = [
1896        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1897        "/Applications/Chromium.app/Contents/MacOS/Chromium",
1898        "/usr/bin/google-chrome",
1899        "/usr/bin/chromium",
1900        "/usr/bin/chromium-browser",
1901    ];
1902    candidates
1903        .iter()
1904        .map(Path::new)
1905        .find(|path| path.is_file())
1906        .map(Path::to_path_buf)
1907}
1908
1909fn docx_file<W: Write + Seek>(
1910    zip: &mut ZipWriter<W>,
1911    path: &str,
1912    content: &str,
1913    options: SimpleFileOptions,
1914) -> Result<(), String> {
1915    zip.start_file(path, options)
1916        .map_err(|error| error.to_string())?;
1917    zip.write_all(content.as_bytes())
1918        .map_err(|error| error.to_string())
1919}
1920fn escape_xml(value: &str) -> String {
1921    value
1922        .replace('&', "&amp;")
1923        .replace('<', "&lt;")
1924        .replace('>', "&gt;")
1925        .replace('"', "&quot;")
1926        .replace('\'', "&apos;")
1927}
1928
1929struct EpubChapter {
1930    title: String,
1931    html: String,
1932}
1933fn epub_chapters(document: &Document) -> Vec<EpubChapter> {
1934    let mut chapters = vec![EpubChapter {
1935        title: String::new(),
1936        html: String::new(),
1937    }];
1938    for node in &document.children {
1939        if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
1940            if !chapters
1941                .last()
1942                .is_some_and(|chapter| chapter.html.is_empty())
1943            {
1944                chapters.push(EpubChapter {
1945                    title: String::new(),
1946                    html: String::new(),
1947                });
1948            }
1949            continue;
1950        }
1951        if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
1952            && node.get("depth").and_then(serde_json::Value::as_u64) == Some(1)
1953            && !chapters
1954                .last()
1955                .is_some_and(|chapter| chapter.html.is_empty())
1956        {
1957            chapters.push(EpubChapter {
1958                title: String::new(),
1959                html: String::new(),
1960            });
1961        }
1962        let chapter = chapters.last_mut().expect("one chapter exists");
1963        if chapter.title.is_empty()
1964            && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
1965        {
1966            chapter.title = plain_node_text(node);
1967        }
1968        render_html_node(node, &mut chapter.html);
1969    }
1970    let chapters: Vec<_> = chapters
1971        .into_iter()
1972        .filter(|chapter| !chapter.html.is_empty())
1973        .collect();
1974    if chapters.is_empty() {
1975        vec![EpubChapter {
1976            title: String::new(),
1977            html: String::new(),
1978        }]
1979    } else {
1980        chapters
1981    }
1982}
1983fn plain_node_text(node: &serde_json::Value) -> String {
1984    let mut text = String::new();
1985    render_text_children(node, &mut text);
1986    text
1987}
1988fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
1989    format!(
1990        "<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE html><html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{}\" lang=\"{}\"><head><meta charset=\"UTF-8\"/><title>{}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/></head><body>{}</body></html>",
1991        escape_html(language),
1992        escape_html(language),
1993        escape_html(title),
1994        body.replace("<br>", "<br/>").replace("<hr>", "<hr/>")
1995    )
1996}
1997fn epub_file<W: Write + Seek>(
1998    zip: &mut ZipWriter<W>,
1999    path: &str,
2000    content: &str,
2001    options: SimpleFileOptions,
2002) -> Result<(), String> {
2003    zip.start_file(path, options)
2004        .map_err(|error| error.to_string())?;
2005    zip.write_all(content.as_bytes())
2006        .map_err(|error| error.to_string())
2007}
2008
2009fn render_html_node(node: &serde_json::Value, out: &mut String) {
2010    let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
2011        return;
2012    };
2013    let children = |out: &mut String| render_html_children(node, out);
2014    match kind {
2015        "text" => out.push_str(&escape_html(
2016            node.get("value")
2017                .and_then(serde_json::Value::as_str)
2018                .unwrap_or_default(),
2019        )),
2020        "root" => children(out),
2021        "paragraph" => {
2022            let class = if node.get("indent").is_some() {
2023                " class=\"mdi-indent\""
2024            } else if node.get("bottom").is_some() {
2025                " class=\"mdi-bottom\""
2026            } else {
2027                ""
2028            };
2029            let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
2030            {
2031                format!(" style=\"--mdi-indent:{amount};\"")
2032            } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2033                format!(" style=\"--mdi-shift:{amount};\"")
2034            } else {
2035                String::new()
2036            };
2037            out.push_str("<p");
2038            out.push_str(class);
2039            out.push_str(&style);
2040            out.push('>');
2041            children(out);
2042            out.push_str("</p>");
2043        }
2044        "heading" => {
2045            let depth = node
2046                .get("depth")
2047                .and_then(serde_json::Value::as_u64)
2048                .filter(|depth| (1..=6).contains(depth))
2049                .unwrap_or(1);
2050            out.push_str(&format!("<h{depth}>"));
2051            children(out);
2052            out.push_str(&format!("</h{depth}>"));
2053        }
2054        "emphasis" => wrapped(out, "em", children),
2055        "strong" => wrapped(out, "strong", children),
2056        "delete" => wrapped(out, "del", children),
2057        "blockquote" => wrapped(out, "blockquote", children),
2058        "list" => {
2059            let ordered = node
2060                .get("ordered")
2061                .and_then(serde_json::Value::as_bool)
2062                .unwrap_or(false);
2063            let tag = if ordered { "ol" } else { "ul" };
2064            let start = node
2065                .get("start")
2066                .and_then(serde_json::Value::as_u64)
2067                .filter(|start| *start != 1)
2068                .map(|start| format!(" start=\"{start}\""))
2069                .unwrap_or_default();
2070            out.push('<');
2071            out.push_str(tag);
2072            out.push_str(&start);
2073            out.push('>');
2074            children(out);
2075            out.push_str("</");
2076            out.push_str(tag);
2077            out.push('>');
2078        }
2079        "listItem" => wrapped(out, "li", children),
2080        "thematicBreak" => out.push_str("<hr>"),
2081        "break" => out.push_str("<br>"),
2082        "inlineCode" => {
2083            out.push_str("<code>");
2084            out.push_str(&escape_html(
2085                node.get("value")
2086                    .and_then(serde_json::Value::as_str)
2087                    .unwrap_or_default(),
2088            ));
2089            out.push_str("</code>");
2090        }
2091        "code" => {
2092            out.push_str("<pre><code");
2093            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2094                out.push_str(" class=\"language-");
2095                out.push_str(&escape_html(lang));
2096                out.push('"');
2097            }
2098            out.push('>');
2099            out.push_str(&escape_html(
2100                node.get("value")
2101                    .and_then(serde_json::Value::as_str)
2102                    .unwrap_or_default(),
2103            ));
2104            out.push_str("</code></pre>");
2105        }
2106        "link" => {
2107            out.push_str("<a href=\"");
2108            out.push_str(&escape_html(
2109                node.get("url")
2110                    .and_then(serde_json::Value::as_str)
2111                    .unwrap_or_default(),
2112            ));
2113            out.push('"');
2114            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2115                out.push_str(" title=\"");
2116                out.push_str(&escape_html(title));
2117                out.push('"');
2118            }
2119            out.push('>');
2120            children(out);
2121            out.push_str("</a>");
2122        }
2123        "image" => {
2124            out.push_str("<img src=\"");
2125            out.push_str(&escape_html(
2126                node.get("url")
2127                    .and_then(serde_json::Value::as_str)
2128                    .unwrap_or_default(),
2129            ));
2130            out.push_str("\" alt=\"");
2131            out.push_str(&escape_html(
2132                node.get("alt")
2133                    .and_then(serde_json::Value::as_str)
2134                    .unwrap_or_default(),
2135            ));
2136            out.push_str("\">");
2137        }
2138        "table" => wrapped(out, "table", children),
2139        "tableRow" => wrapped(out, "tr", children),
2140        "tableCell" => wrapped(out, "td", children),
2141        "footnoteReference" => {
2142            let label = node
2143                .get("label")
2144                .and_then(serde_json::Value::as_str)
2145                .unwrap_or_default();
2146            out.push_str("<sup class=\"footnote-ref\">");
2147            out.push_str(&escape_html(label));
2148            out.push_str("</sup>");
2149        }
2150        "footnoteDefinition" | "definition" => {}
2151        "html" => out.push_str(&escape_html(
2152            node.get("value")
2153                .and_then(serde_json::Value::as_str)
2154                .unwrap_or_default(),
2155        )),
2156        "ruby" => render_ruby(node, out),
2157        "tcy" => {
2158            out.push_str("<span class=\"mdi-tcy\">");
2159            out.push_str(&escape_html(
2160                node.get("value")
2161                    .and_then(serde_json::Value::as_str)
2162                    .unwrap_or_default(),
2163            ));
2164            out.push_str("</span>");
2165        }
2166        "em" => {
2167            let mark = node
2168                .get("mark")
2169                .and_then(serde_json::Value::as_str)
2170                .unwrap_or("﹅");
2171            out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:&quot;");
2172            out.push_str(&escape_css_string(mark));
2173            out.push_str("&quot;;\">");
2174            children(out);
2175            out.push_str("</span>");
2176        }
2177        "noBreak" => {
2178            out.push_str("<span class=\"mdi-nobr\">");
2179            children(out);
2180            out.push_str("</span>");
2181        }
2182        "warichu" => {
2183            out.push_str("<span class=\"mdi-warichu\">");
2184            children(out);
2185            out.push_str("</span>");
2186        }
2187        "kern" => {
2188            out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
2189            out.push_str(&escape_html(
2190                node.get("amount")
2191                    .and_then(serde_json::Value::as_str)
2192                    .unwrap_or_default(),
2193            ));
2194            out.push_str(";\">");
2195            children(out);
2196            out.push_str("</span>");
2197        }
2198        "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
2199        "pagebreak" => {
2200            out.push_str("<div class=\"mdi-pagebreak");
2201            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2202                out.push_str(" mdi-pagebreak-");
2203                out.push_str(&escape_html(variant));
2204            }
2205            out.push_str("\" role=\"presentation\"></div>");
2206        }
2207        _ => children(out),
2208    }
2209}
2210
2211fn render_html_children(node: &serde_json::Value, out: &mut String) {
2212    if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
2213        for child in children {
2214            render_html_node(child, out);
2215        }
2216    }
2217}
2218
2219fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
2220    out.push('<');
2221    out.push_str(tag);
2222    out.push('>');
2223    children(out);
2224    out.push_str("</");
2225    out.push_str(tag);
2226    out.push('>');
2227}
2228
2229fn render_ruby(node: &serde_json::Value, out: &mut String) {
2230    let base = node
2231        .get("base")
2232        .and_then(serde_json::Value::as_str)
2233        .unwrap_or_default();
2234    let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
2235    out.push_str("<ruby class=\"mdi-ruby\">");
2236    if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
2237        for (base, reading) in base.graphemes(true).zip(parts) {
2238            out.push_str(&escape_html(base));
2239            render_ruby_reading(reading.as_str().unwrap_or_default(), out);
2240        }
2241    } else {
2242        out.push_str(&escape_html(base));
2243        render_ruby_reading(
2244            reading
2245                .and_then(serde_json::Value::as_str)
2246                .unwrap_or_default(),
2247            out,
2248        );
2249    }
2250    out.push_str("</ruby>");
2251}
2252
2253fn render_ruby_reading(reading: &str, out: &mut String) {
2254    out.push_str("<rp>(</rp><rt>");
2255    out.push_str(&escape_html(reading));
2256    out.push_str("</rt><rp>)</rp>");
2257}
2258
2259fn escape_html(value: &str) -> String {
2260    value
2261        .replace('&', "&amp;")
2262        .replace('<', "&lt;")
2263        .replace('>', "&gt;")
2264        .replace('"', "&quot;")
2265}
2266fn escape_css_string(value: &str) -> String {
2267    value.replace('\\', "\\\\").replace('"', "\\\"")
2268}
2269
2270/// Parse MDI inline syntax while keeping all other text literal.
2271pub fn parse_inlines(source: &str) -> Vec<Inline> {
2272    parse_inline_parts(source)
2273        .into_iter()
2274        .map(|(inline, _, _)| inline)
2275        .collect()
2276}
2277
2278/// Parse MDI inline syntax and retain each node's raw byte range relative to
2279/// `source`.  Escaped text deliberately retains the range of its spelling in
2280/// the source even when its rendered value is shorter.
2281fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
2282    let mut out = Vec::new();
2283    let mut text = String::new();
2284    let mut text_start = 0;
2285    let mut index = 0;
2286
2287    while index < source.len() {
2288        let rest = &source[index..];
2289        if rest.starts_with('\\') {
2290            let mut chars = rest.chars();
2291            let slash = chars.next().expect("prefix was checked");
2292            let Some(next) = chars.next() else {
2293                text.push(slash);
2294                index += slash.len_utf8();
2295                continue;
2296            };
2297            if is_escapable(next) {
2298                text.push(next);
2299            } else {
2300                text.push(slash);
2301                text.push(next);
2302            }
2303            index += slash.len_utf8() + next.len_utf8();
2304            continue;
2305        }
2306        if let Some((inline, consumed)) = ruby(rest) {
2307            push_inline_text(&mut out, &mut text, text_start, index);
2308            out.push((inline, index, index + consumed));
2309            index += consumed;
2310            text_start = index;
2311            continue;
2312        }
2313        if let Some((inline, consumed)) = tcy(rest) {
2314            push_inline_text(&mut out, &mut text, text_start, index);
2315            out.push((inline, index, index + consumed));
2316            index += consumed;
2317            text_start = index;
2318            continue;
2319        }
2320        if let Some((inline, consumed)) = boten(rest) {
2321            push_inline_text(&mut out, &mut text, text_start, index);
2322            out.push((inline, index, index + consumed));
2323            index += consumed;
2324            text_start = index;
2325            continue;
2326        }
2327        if let Some((inline, consumed)) = bracket_macro(rest) {
2328            push_inline_text(&mut out, &mut text, text_start, index);
2329            out.push((inline, index, index + consumed));
2330            index += consumed;
2331            text_start = index;
2332            continue;
2333        }
2334        let character = rest.chars().next().expect("index is in bounds");
2335        text.push(character);
2336        index += character.len_utf8();
2337    }
2338    push_inline_text(&mut out, &mut text, text_start, index);
2339    out
2340}
2341
2342#[derive(Clone)]
2343enum PendingBlock {
2344    Indent { amount: u32, source: String },
2345    Bottom { amount: u32, source: String },
2346}
2347
2348fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
2349    let (indent, bottom) = match pending {
2350        Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
2351        Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
2352        None => (None, None),
2353    };
2354    MdiBlock::Paragraph {
2355        inlines: parse_inlines(line),
2356        indent,
2357        bottom,
2358    }
2359}
2360
2361fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
2362    if let Some(marker) = pending.take() {
2363        // As in the micromark adapter, an alignment macro without a following
2364        // eligible block remains literal source.
2365        let source = match marker {
2366            PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
2367        };
2368        blocks.push(paragraph(&source, None));
2369    }
2370}
2371
2372fn is_blank_marker(line: &str) -> bool {
2373    let value = line.trim_end_matches([' ', '\t']);
2374    value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
2375}
2376
2377fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
2378    match line.trim() {
2379        "[[pagebreak]]" => Some(None),
2380        "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
2381        "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
2382        _ => None,
2383    }
2384}
2385
2386fn pending_block(line: &str) -> Option<PendingBlock> {
2387    let value = line.trim();
2388    if value == "[[bottom]]" {
2389        return Some(PendingBlock::Bottom {
2390            amount: 0,
2391            source: value.to_owned(),
2392        });
2393    }
2394    let (kind, amount) = value
2395        .strip_prefix("[[")?
2396        .strip_suffix("]]")?
2397        .split_once(':')?;
2398    if amount.is_empty()
2399        || amount.starts_with('0')
2400        || !amount.bytes().all(|byte| byte.is_ascii_digit())
2401    {
2402        return None;
2403    }
2404    let amount = amount.parse().ok()?;
2405    match kind {
2406        "indent" => Some(PendingBlock::Indent {
2407            amount,
2408            source: value.to_owned(),
2409        }),
2410        "bottom" => Some(PendingBlock::Bottom {
2411            amount,
2412            source: value.to_owned(),
2413        }),
2414        _ => None,
2415    }
2416}
2417
2418fn ruby(value: &str) -> Option<(Inline, usize)> {
2419    if !value.starts_with('{') {
2420        return None;
2421    }
2422    let end = close_unescaped(value, 1, '}')?;
2423    let body = &value[1..end];
2424    let separator = bare_index(body, '|')?;
2425    let base = unescape_ruby(&body[..separator]);
2426    let raw_ruby = &body[separator + 1..];
2427    let ruby = split_ruby(&base, raw_ruby);
2428    Some((Inline::Ruby { base, ruby }, end + 1))
2429}
2430
2431fn split_ruby(base: &str, raw: &str) -> RubyReading {
2432    let segments = split_unescaped(raw, '.');
2433    if segments.len() == 1 {
2434        return RubyReading::Group(unescape_ruby(raw));
2435    }
2436    let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
2437    if segments.len() == base.graphemes(true).count()
2438        && segments.iter().all(|part| !part.is_empty())
2439    {
2440        RubyReading::Split(segments)
2441    } else {
2442        RubyReading::Group(segments.concat())
2443    }
2444}
2445
2446fn tcy(value: &str) -> Option<(Inline, usize)> {
2447    if !value.starts_with('^') {
2448        return None;
2449    }
2450    let closing = value[1..].find('^')? + 1;
2451    let body = &value[1..closing];
2452    if body.is_empty()
2453        || body.chars().count() > 6
2454        || !body
2455            .chars()
2456            .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
2457    {
2458        return None;
2459    }
2460    Some((Inline::Tcy(body.to_owned()), closing + 1))
2461}
2462
2463fn boten(value: &str) -> Option<(Inline, usize)> {
2464    let prefix = "《《";
2465    if !value.starts_with(prefix) {
2466        return None;
2467    }
2468    let end = value.find("》》")?;
2469    let body = &value[prefix.len()..end];
2470    if body.is_empty()
2471        || body.contains('\n')
2472        || contains_unescaped(body, '《')
2473        || contains_unescaped(body, '》')
2474    {
2475        return None;
2476    }
2477    Some((
2478        Inline::Em {
2479            mark: "﹅".to_owned(),
2480            children: vec![Inline::Text(unescape_mdi(body))],
2481        },
2482        end + "》》".len(),
2483    ))
2484}
2485
2486fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
2487    if !value.starts_with("[[") {
2488        return None;
2489    }
2490    if value.starts_with("[[br]]") {
2491        return Some((Inline::Break, "[[br]]".len()));
2492    }
2493    let end = close_macro(value)?;
2494    let body = &value[2..end];
2495    let (name, payload) = body.split_once(':')?;
2496    let children = |content: &str| parse_inlines(content);
2497    let inline = match name {
2498        "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
2499        "warichu" => Inline::Warichu(children(payload)),
2500        "kern" => {
2501            let (amount, content) = payload.split_once(':')?;
2502            if !valid_kern(amount) {
2503                return None;
2504            }
2505            Inline::Kern {
2506                amount: unescape_mdi(amount),
2507                children: children(content),
2508            }
2509        }
2510        "em" => {
2511            let (mark, content) = match bare_index(payload, ':') {
2512                Some(index) => {
2513                    let candidate = unescape_mdi(&payload[..index]);
2514                    if candidate.graphemes(true).count() == 1
2515                        && !candidate
2516                            .chars()
2517                            .any(|c| c.is_whitespace() || c.is_control())
2518                    {
2519                        (candidate, &payload[index + 1..])
2520                    } else {
2521                        ("﹅".to_owned(), payload)
2522                    }
2523                }
2524                None => ("﹅".to_owned(), payload),
2525            };
2526            Inline::Em {
2527                mark,
2528                children: children(content),
2529            }
2530        }
2531        _ => return None,
2532    };
2533    Some((inline, end + 2))
2534}
2535
2536fn close_macro(value: &str) -> Option<usize> {
2537    let mut index = 2;
2538    let mut depth = 1;
2539    while index < value.len() {
2540        let rest = &value[index..];
2541        if rest.starts_with('\\') {
2542            index += rest.chars().nth(1)?.len_utf8() + 1;
2543        } else if rest.starts_with("[[") {
2544            depth += 1;
2545            index += 2;
2546        } else if rest.starts_with("]]") {
2547            depth -= 1;
2548            if depth == 0 {
2549                return Some(index);
2550            }
2551            index += 2;
2552        } else {
2553            index += rest.chars().next()?.len_utf8();
2554        }
2555    }
2556    None
2557}
2558
2559fn valid_kern(value: &str) -> bool {
2560    let value = value.strip_suffix("em").unwrap_or("");
2561    let value = value.strip_prefix(['+', '-']).unwrap_or(value);
2562    let mut parts = value.split('.');
2563    let whole = parts.next().unwrap_or("");
2564    let fraction = parts.next();
2565    parts.next().is_none()
2566        && !whole.is_empty()
2567        && whole.bytes().all(|b| b.is_ascii_digit())
2568        && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
2569}
2570
2571fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
2572    let mut escaped = false;
2573    for (index, character) in value[start..].char_indices() {
2574        if escaped {
2575            escaped = false;
2576            continue;
2577        }
2578        if character == '\\' {
2579            escaped = true;
2580            continue;
2581        }
2582        if character == needle {
2583            return Some(start + index);
2584        }
2585        if character == '\n' {
2586            return None;
2587        }
2588    }
2589    None
2590}
2591
2592fn bare_index(value: &str, needle: char) -> Option<usize> {
2593    let mut escaped = false;
2594    for (index, character) in value.char_indices() {
2595        if escaped {
2596            escaped = false;
2597            continue;
2598        }
2599        if character == '\\' {
2600            escaped = true;
2601            continue;
2602        }
2603        if character == needle {
2604            return Some(index);
2605        }
2606    }
2607    None
2608}
2609
2610fn contains_unescaped(value: &str, needle: char) -> bool {
2611    bare_index(value, needle).is_some()
2612}
2613
2614fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
2615    let mut parts = Vec::new();
2616    let mut start = 0;
2617    let mut escaped = false;
2618    for (index, character) in value.char_indices() {
2619        if escaped {
2620            escaped = false;
2621            continue;
2622        }
2623        if character == '\\' {
2624            escaped = true;
2625            continue;
2626        }
2627        if character == separator {
2628            parts.push(&value[start..index]);
2629            start = index + character.len_utf8();
2630        }
2631    }
2632    parts.push(&value[start..]);
2633    parts
2634}
2635
2636/// SYNTAX.md §13's delimiter set, plus `\\` itself — CommonMark treats
2637/// backslash as ordinary escapable ASCII punctuation, and JS's
2638/// `unescapeMdi`/`unescapeRubyText` follow suit (`\\` -> `\`), so the Rust
2639/// core must too or `\\` is left as two literal characters instead of one.
2640const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
2641const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
2642
2643fn unescape_mdi(value: &str) -> String {
2644    unescape(value, ESCAPABLE_MDI)
2645}
2646
2647fn unescape_ruby(value: &str) -> String {
2648    unescape(value, ESCAPABLE_RUBY)
2649}
2650
2651fn unescape(value: &str, allowed: &str) -> String {
2652    let mut out = String::new();
2653    let mut chars = value.chars();
2654    while let Some(character) = chars.next() {
2655        if character == '\\' {
2656            if let Some(next) = chars.next() {
2657                if allowed.contains(next) {
2658                    out.push(next);
2659                } else {
2660                    out.push(character);
2661                    out.push(next);
2662                }
2663            } else {
2664                out.push(character);
2665            }
2666        } else {
2667            out.push(character);
2668        }
2669    }
2670    out
2671}
2672
2673fn is_escapable(character: char) -> bool {
2674    ESCAPABLE_MDI.contains(character)
2675}
2676
2677fn push_inline_text(
2678    out: &mut Vec<(Inline, usize, usize)>,
2679    text: &mut String,
2680    start: usize,
2681    end: usize,
2682) {
2683    if !text.is_empty() {
2684        out.push((Inline::Text(std::mem::take(text)), start, end));
2685    }
2686}
2687
2688/// A single `[[indent:N]]` / `[[bottom]]` / `[[pagebreak…]]` token's source,
2689/// classified the same way as `mdast-util-mdi`'s `parseBlockMacro` (from
2690/// which this was ported): trims the token source once, then matches the
2691/// fixed pagebreak/bottom spellings before falling back to the generic
2692/// `name:digits` form. Kept separate from `pending_block`/`pagebreak`
2693/// above, which classify whole *lines* mid-scan rather than one already
2694/// isolated token.
2695#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
2696enum BlockMacroClass {
2697    Indent(u32),
2698    Bottom(u32),
2699    Pagebreak(Option<PagebreakVariant>),
2700    Literal,
2701}
2702
2703#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
2704fn classify_block_macro(source: &str) -> BlockMacroClass {
2705    let value = source.trim();
2706    match value {
2707        "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
2708        "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
2709        "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
2710        "[[bottom]]" => return BlockMacroClass::Bottom(0),
2711        _ => {}
2712    }
2713    if let Some((kind, amount)) = value
2714        .strip_prefix("[[")
2715        .and_then(|rest| rest.strip_suffix("]]"))
2716        .and_then(|inner| inner.split_once(':'))
2717    {
2718        let valid_amount = !amount.is_empty()
2719            && !amount.starts_with('0')
2720            && amount.bytes().all(|b| b.is_ascii_digit());
2721        if valid_amount && let Ok(amount) = amount.parse::<u32>() {
2722            match kind {
2723                "indent" => return BlockMacroClass::Indent(amount),
2724                "bottom" => return BlockMacroClass::Bottom(amount),
2725                _ => {}
2726            }
2727        }
2728    }
2729    BlockMacroClass::Literal
2730}
2731
2732/// wasm-bindgen bindings for the complete JavaScript interface and legacy
2733/// semantic helpers retained for compatibility tests.
2734#[cfg(feature = "wasm")]
2735mod wasm {
2736    use super::{
2737        BlockMacroClass, PagebreakVariant, RubyReading, TextFormat, classify_block_macro,
2738        parse_json, render_docx, render_epub, render_html, render_text, render_text_format,
2739        serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
2740    };
2741    use wasm_bindgen::prelude::*;
2742
2743    /// Parse with Rust and return the versioned MDI IR as JSON.
2744    ///
2745    /// The JavaScript package parses this string and performs no syntax work.
2746    #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
2747    pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
2748        parse_json(source)
2749    }
2750
2751    /// Render source through the Rust parser and Rust HTML renderer.
2752    #[wasm_bindgen(js_name = renderHtml)]
2753    pub fn wasm_render_html(source: &str) -> String {
2754        render_html(source)
2755    }
2756
2757    /// Normalize source through the Rust parser and canonical serializer.
2758    #[wasm_bindgen(js_name = serializeMdi)]
2759    pub fn wasm_serialize_mdi(source: &str) -> String {
2760        serialize_mdi(source)
2761    }
2762
2763    /// Render plain text directly from the Rust IR.
2764    #[wasm_bindgen(js_name = renderText)]
2765    pub fn wasm_render_text(source: &str) -> String {
2766        render_text(source)
2767    }
2768
2769    /// Render a named text export convention through the Rust core.
2770    #[wasm_bindgen(js_name = renderTextFormat)]
2771    pub fn wasm_render_text_format(
2772        source: &str,
2773        format: &str,
2774        indent_prefix: &str,
2775    ) -> Result<String, JsValue> {
2776        let format = TextFormat::parse(format)
2777            .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
2778        Ok(render_text_format(source, format, indent_prefix))
2779    }
2780
2781    /// Build a baseline EPUB 3 archive entirely in Rust.
2782    #[wasm_bindgen(js_name = renderEpub)]
2783    pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
2784        render_epub(source)
2785            .map(Vec::into_boxed_slice)
2786            .map_err(|message| JsValue::from_str(&message))
2787    }
2788
2789    /// Build a baseline DOCX archive entirely in Rust.
2790    #[wasm_bindgen(js_name = renderDocx)]
2791    pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
2792        render_docx(source)
2793            .map(Vec::into_boxed_slice)
2794            .map_err(|message| JsValue::from_str(&message))
2795    }
2796
2797    #[wasm_bindgen(js_name = unescapeMdi)]
2798    pub fn wasm_unescape_mdi(value: &str) -> String {
2799        unescape_mdi(value)
2800    }
2801
2802    #[wasm_bindgen(js_name = unescapeRubyText)]
2803    pub fn wasm_unescape_ruby(value: &str) -> String {
2804        unescape_ruby(value)
2805    }
2806
2807    /// Mirrors `resolveRuby(base, rawRuby): string | string[]`.
2808    #[wasm_bindgen(js_name = resolveRuby)]
2809    pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
2810        match split_ruby(base, raw_ruby) {
2811            RubyReading::Group(value) => JsValue::from_str(&value),
2812            RubyReading::Split(parts) => {
2813                let array = js_sys::Array::new();
2814                for part in parts {
2815                    array.push(&JsValue::from_str(&part));
2816                }
2817                array.into()
2818            }
2819        }
2820    }
2821
2822    #[wasm_bindgen(js_name = blockMacroKind)]
2823    pub fn wasm_block_macro_kind(source: &str) -> String {
2824        match classify_block_macro(source) {
2825            BlockMacroClass::Indent(_) => "indent",
2826            BlockMacroClass::Bottom(_) => "bottom",
2827            BlockMacroClass::Pagebreak(_) => "pagebreak",
2828            BlockMacroClass::Literal => "literal",
2829        }
2830        .to_owned()
2831    }
2832
2833    /// -1 when the token has no amount (pagebreak / literal).
2834    #[wasm_bindgen(js_name = blockMacroAmount)]
2835    pub fn wasm_block_macro_amount(source: &str) -> i32 {
2836        match classify_block_macro(source) {
2837            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
2838            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
2839        }
2840    }
2841
2842    /// `"left"` / `"right"` / `""` (no variant, including non-pagebreak kinds).
2843    #[wasm_bindgen(js_name = blockMacroVariant)]
2844    pub fn wasm_block_macro_variant(source: &str) -> String {
2845        match classify_block_macro(source) {
2846            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
2847            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
2848            _ => "",
2849        }
2850        .to_owned()
2851    }
2852}
2853
2854#[cfg(test)]
2855mod tests {
2856    use super::*;
2857    use std::io::{Error, ErrorKind, Read, SeekFrom};
2858    use zip::ZipArchive;
2859
2860    struct FailAfterWrites {
2861        inner: Cursor<Vec<u8>>,
2862        remaining: usize,
2863    }
2864
2865    impl Write for FailAfterWrites {
2866        fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
2867            if self.remaining == 0 {
2868                return Err(Error::new(
2869                    ErrorKind::Other,
2870                    "injected archive write failure",
2871                ));
2872            }
2873            self.remaining -= 1;
2874            self.inner.write(buffer)
2875        }
2876
2877        fn flush(&mut self) -> std::io::Result<()> {
2878            self.inner.flush()
2879        }
2880    }
2881
2882    impl Seek for FailAfterWrites {
2883        fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
2884            self.inner.seek(position)
2885        }
2886    }
2887
2888    fn assert_valid_spans(value: &serde_json::Value, source: &str) {
2889        let Some(object) = value.as_object() else {
2890            return;
2891        };
2892        if let Some(span) = object.get("span") {
2893            let start = span
2894                .get("startByte")
2895                .and_then(serde_json::Value::as_u64)
2896                .expect("span has startByte") as usize;
2897            let end = span
2898                .get("endByte")
2899                .and_then(serde_json::Value::as_u64)
2900                .expect("span has endByte") as usize;
2901            assert!(start <= end, "span start must not exceed end: {span}");
2902            assert!(
2903                end <= source.len(),
2904                "span must be inside its source: {span}"
2905            );
2906            assert!(
2907                source.is_char_boundary(start),
2908                "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
2909            );
2910            let remainder = &source[start..];
2911            assert!(
2912                source.is_char_boundary(end),
2913                "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
2914                remainder
2915            );
2916        }
2917        if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
2918            for child in children {
2919                assert_valid_spans(child, source);
2920            }
2921        }
2922    }
2923
2924    fn generated_source(mut state: u64) -> String {
2925        // A deterministic adversarial corpus: every fragment is individually
2926        // valid UTF-8, while their arbitrary adjacency exercises delimiter,
2927        // Markdown, front-matter, and MDI recovery boundaries.
2928        if state == 0 {
2929            return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
2930        }
2931        const FRAGMENTS: &[&str] = &[
2932            "東京",
2933            "𠮟る",
2934            "👨‍👩‍👧",
2935            " ",
2936            "\\n",
2937            "\\\\",
2938            "{",
2939            "}",
2940            "|",
2941            ".",
2942            "^12^",
2943            "^_^",
2944            "{東京|とう.きょう}",
2945            "[[em:強調]]",
2946            "[[no-break:^12^]]",
2947            "[[kern:wide:x]]",
2948            "[[indent:2]]",
2949            "[[pagebreak:left]]",
2950            "《《傍点》》",
2951            "**strong**",
2952            "`^12^`",
2953            "[^n]",
2954            "\n\n",
2955            "| a | b |\n| - | - |\n| 1 | 2 |\n",
2956            "[link](https://example.test/?a=1&b=2)",
2957            "<script>x</script>",
2958            "\\{",
2959            "\\[",
2960        ];
2961        let mut source = String::new();
2962        for _ in 0..32 {
2963            state ^= state << 13;
2964            state ^= state >> 7;
2965            state ^= state << 17;
2966            source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
2967        }
2968        source
2969    }
2970
2971    #[test]
2972    fn parses_ruby_and_split_ruby() {
2973        assert_eq!(
2974            parse_inlines("{東京|とう.きょう}"),
2975            vec![Inline::Ruby {
2976                base: "東京".into(),
2977                ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
2978            }]
2979        );
2980        assert_eq!(
2981            parse_inlines("{東京|.きょう}"),
2982            vec![Inline::Ruby {
2983                base: "東京".into(),
2984                ruby: RubyReading::Group("きょう".into()),
2985            }]
2986        );
2987    }
2988
2989    #[test]
2990    fn parses_nested_macros_and_escapes() {
2991        assert_eq!(
2992            parse_inlines("[[em:●:a[[no-break:b]]]]"),
2993            vec![Inline::Em {
2994                mark: "●".into(),
2995                children: vec![
2996                    Inline::Text("a".into()),
2997                    Inline::NoBreak(vec![Inline::Text("b".into())])
2998                ],
2999            }]
3000        );
3001    }
3002
3003    #[test]
3004    fn parses_tcy_and_boten() {
3005        assert_eq!(
3006            parse_inlines("第^12^話《《重要》》"),
3007            vec![
3008                Inline::Text("第".into()),
3009                Inline::Tcy("12".into()),
3010                Inline::Text("話".into()),
3011                Inline::Em {
3012                    mark: "﹅".into(),
3013                    children: vec![Inline::Text("重要".into())]
3014                },
3015            ]
3016        );
3017    }
3018
3019    #[test]
3020    fn recognises_block_macros() {
3021        assert_eq!(
3022            parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
3023            MdiSyntaxDocument {
3024                blocks: vec![
3025                    MdiBlock::Paragraph {
3026                        inlines: vec![Inline::Text("本文".into())],
3027                        indent: Some(2),
3028                        bottom: None
3029                    },
3030                    MdiBlock::Pagebreak {
3031                        variant: Some(PagebreakVariant::Left)
3032                    },
3033                    MdiBlock::Blank,
3034                ]
3035            }
3036        );
3037    }
3038
3039    #[test]
3040    fn keeps_invalid_syntax_literal() {
3041        assert_eq!(
3042            parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
3043            vec![Inline::Text(
3044                "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
3045            )]
3046        );
3047    }
3048
3049    #[test]
3050    fn applies_mdi_escapes_once_before_recognition() {
3051        assert_eq!(
3052            parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
3053            vec![Inline::Text(
3054                "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
3055            )]
3056        );
3057    }
3058
3059    #[test]
3060    fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
3061        assert_eq!(
3062            parse_inlines(r"\\ \n \a \0 \-"),
3063            vec![Inline::Text(r"\ \n \a \0 \-".into())]
3064        );
3065    }
3066
3067    #[test]
3068    fn treats_boten_alias_content_as_plain_text() {
3069        assert_eq!(
3070            parse_inlines(r"《《a\《b》》"),
3071            vec![Inline::Em {
3072                mark: "﹅".into(),
3073                children: vec![Inline::Text("a《b".into())]
3074            }]
3075        );
3076        assert_eq!(
3077            parse_inlines("《《雪》考》"),
3078            vec![Inline::Text("《《雪》考》".into())]
3079        );
3080    }
3081
3082    #[test]
3083    fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
3084        assert_eq!(
3085            parse_inlines("[[em:ab:cd]]"),
3086            vec![Inline::Em {
3087                mark: "﹅".into(),
3088                children: vec![Inline::Text("ab:cd".into())]
3089            }]
3090        );
3091    }
3092
3093    #[test]
3094    fn counts_extended_graphemes_for_split_ruby() {
3095        assert_eq!(
3096            parse_inlines("{𠮟る|しか.る}"),
3097            vec![Inline::Ruby {
3098                base: "𠮟る".into(),
3099                ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
3100            }]
3101        );
3102        assert_eq!(
3103            parse_inlines("{👨‍👩‍👧|かぞく}"),
3104            vec![Inline::Ruby {
3105                base: "👨‍👩‍👧".into(),
3106                ruby: RubyReading::Group("かぞく".into())
3107            }]
3108        );
3109    }
3110
3111    #[test]
3112    fn leaves_unattached_or_stacked_block_macros_literal() {
3113        assert_eq!(
3114            parse_mdi_syntax("[[indent:2]]"),
3115            MdiSyntaxDocument {
3116                blocks: vec![MdiBlock::Paragraph {
3117                    inlines: vec![Inline::Text("[[indent:2]]".into())],
3118                    indent: None,
3119                    bottom: None
3120                }]
3121            }
3122        );
3123        assert_eq!(
3124            parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
3125            MdiSyntaxDocument {
3126                blocks: vec![
3127                    MdiBlock::Paragraph {
3128                        inlines: vec![Inline::Text("[[indent:2]]".into())],
3129                        indent: None,
3130                        bottom: None
3131                    },
3132                    MdiBlock::Paragraph {
3133                        inlines: vec![Inline::Text("[[bottom]]".into())],
3134                        indent: None,
3135                        bottom: None
3136                    },
3137                    MdiBlock::Paragraph {
3138                        inlines: vec![Inline::Text("本文".into())],
3139                        indent: None,
3140                        bottom: None
3141                    }
3142                ]
3143            }
3144        );
3145    }
3146
3147    #[test]
3148    fn serializes_the_versioned_binding_contract() {
3149        let value: serde_json::Value =
3150            serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
3151                .expect("parse output is valid JSON");
3152
3153        assert_eq!(value["irVersion"], "1.0");
3154        assert_eq!(value["syntaxVersion"], "2.0");
3155        assert_eq!(value["capabilities"]["mdi"], true);
3156        assert_eq!(value["capabilities"]["commonMark"], true);
3157        assert_eq!(value["capabilities"]["gfm"], true);
3158        assert_eq!(value["capabilities"]["frontMatter"], true);
3159        assert_eq!(value["capabilities"]["sourceSpans"], true);
3160        assert_eq!(value["diagnostics"], serde_json::json!([]));
3161        assert_eq!(value["document"]["children"][0]["type"], "paragraph");
3162        assert_eq!(
3163            value["document"]["children"][0]["children"][1]["type"],
3164            "tcy"
3165        );
3166        assert_eq!(value["document"]["span"]["endByte"], 43);
3167    }
3168
3169    #[test]
3170    fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
3171        let document = parse_document(
3172            "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
3173        );
3174        assert_eq!(
3175            document.frontmatter.as_ref().unwrap().entries[0].key,
3176            "title"
3177        );
3178        assert_eq!(document.children[0]["type"], "heading");
3179        assert_eq!(document.children[1]["type"], "list");
3180        assert_eq!(document.children[2]["type"], "table");
3181        assert_eq!(document.children[0]["span"]["startByte"], 43);
3182    }
3183
3184    #[test]
3185    fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
3186        let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
3187        assert_eq!(document.children[0]["type"], "blockquote");
3188        assert!(document.children.iter().any(|node| node["type"] == "code"));
3189    }
3190
3191    #[test]
3192    fn lowers_root_flow_markers_without_a_host_markdown_parser() {
3193        let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
3194        assert_eq!(document.children[0]["type"], "paragraph");
3195        assert_eq!(document.children[0]["indent"], 2);
3196        assert_eq!(document.children[1]["type"], "pagebreak");
3197        assert_eq!(document.children[1]["variant"], "right");
3198        assert_eq!(document.children[2]["type"], "blank");
3199    }
3200
3201    #[test]
3202    fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
3203        let document = parse_document("[[em:**重要**]]");
3204        let em = &document.children[0]["children"][0];
3205        assert_eq!(em["type"], "em");
3206        assert_eq!(em["mark"], "﹅");
3207        assert_eq!(em["children"][0]["type"], "strong");
3208        assert_eq!(em["children"][0]["children"][0]["value"], "重要");
3209    }
3210
3211    #[test]
3212    fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
3213        assert!(
3214            markdown_paragraph_children(
3215                "前 [[em:**重要**]] 後",
3216                &serde_json::json!({"startByte": 0, "endByte": 26})
3217            )
3218            .is_some()
3219        );
3220        let document = parse_document("前 [[em:**重要**]] 後");
3221        let children = &document.children[0]["children"];
3222        assert_eq!(children[0]["value"], "前");
3223        assert_eq!(children[1]["value"], " ");
3224        assert_eq!(children[2]["type"], "em");
3225        assert_eq!(children[2]["children"][0]["type"], "strong");
3226        assert_eq!(children[3]["value"], " ");
3227        assert_eq!(children[4]["value"], "後");
3228        assert_eq!(children[2]["span"]["startByte"], 4);
3229        assert_eq!(children[2]["span"]["endByte"], 21);
3230        assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
3231        assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
3232    }
3233
3234    #[test]
3235    fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
3236        let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
3237        let children = &document.children[0]["children"][0]["children"];
3238        assert_eq!(children[0]["type"], "ruby");
3239        assert_eq!(children[1]["type"], "noBreak");
3240        assert_eq!(children[1]["children"][0]["type"], "tcy");
3241    }
3242
3243    #[test]
3244    fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
3245        let document =
3246            parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
3247        let paragraph = &document.children[1];
3248        assert_eq!(paragraph["span"]["startByte"], 7);
3249        assert_eq!(paragraph["children"][0]["type"], "ruby");
3250        assert_eq!(paragraph["children"][0]["base"], "東京");
3251        assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
3252        assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
3253        assert_eq!(paragraph["children"][2]["type"], "em");
3254        assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
3255        assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
3256        assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
3257        assert_eq!(paragraph["children"][4]["identifier"], "n");
3258    }
3259
3260    #[test]
3261    fn renders_a_standalone_html_document_from_rust_ir() {
3262        let html = render_html(
3263            "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
3264        );
3265        assert!(html.starts_with("<!DOCTYPE html>"));
3266        assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
3267        assert!(html.contains("<title>雪女</title>"));
3268        assert!(html.contains("<h1>題</h1>"));
3269        assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
3270        assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
3271    }
3272
3273    #[test]
3274    fn renders_footnote_definitions_in_html() {
3275        let html = render_html("本文[^n]\n\n[^n]: 注の本文");
3276        assert!(html.contains("data-footnotes"));
3277        assert!(html.contains("id=\"user-content-fn-n\""));
3278        assert!(html.contains("注の本文"));
3279    }
3280
3281    #[test]
3282    fn escapes_raw_html_in_the_rust_renderer() {
3283        let html = render_html("<script>alert(1)</script>");
3284        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
3285        assert!(!html.contains("<script>alert(1)</script>"));
3286    }
3287
3288    #[test]
3289    fn serializes_mdi_from_rust_ir() {
3290        let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
3291        assert_eq!(
3292            serialize_mdi(source),
3293            "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
3294        );
3295    }
3296
3297    #[test]
3298    fn renders_plain_text_from_rust_ir() {
3299        assert_eq!(
3300            render_text("# 題\n\n{東京|とうきょう} ^12^"),
3301            "題\n東京 12\n"
3302        );
3303    }
3304
3305    #[test]
3306    fn renders_platform_text_formats_from_rust_ir() {
3307        let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
3308        assert_eq!(
3309            render_text_format(source, TextFormat::Plain, ""),
3310            "題\n東京強調。"
3311        );
3312        assert_eq!(
3313            render_text_format(source, TextFormat::Ruby, ""),
3314            "題\n{東京|とう.きょう}強調。"
3315        );
3316        assert_eq!(
3317            render_text_format(source, TextFormat::Kakuyomu, ""),
3318            "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
3319        );
3320        assert!(
3321            render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は大見出し]")
3322        );
3323    }
3324
3325    #[test]
3326    fn renders_and_serializes_every_public_inline_and_block_variant() {
3327        let source = "---\ntitle: Variants\n---\n\n[[bottom]]\n本文\n\n## 中見出し\n\n### 小見出し\n\n> 引用\n\n1. 一\n2. 二\n\n- 箇条\n  - 巢狀\n\n```rust\nlet x = 1;\n```\n\n---\n\n| 見出し | 値 |\n| --- | --- |\n| [リンク](https://example.test \"題\") | ![画像](image.png) |\n\n~~削除~~ `code` [[br]][[warichu:割書]][[kern:1em:字]][[em:●:傍点]]\n";
3328
3329        let html = render_html(source);
3330        for expected in [
3331            "<h2>中見出し</h2>",
3332            "<h3>小見出し</h3>",
3333            "<blockquote><p>引用</p>",
3334            "<ol>",
3335            "<ul>",
3336            "<pre><code class=\"language-rust\">",
3337            "<hr>",
3338            "<table>",
3339            "<a href=\"https://example.test\" title=\"題\">リンク</a>",
3340            "<img src=\"image.png\" alt=\"画像\">",
3341            "<del>削除</del>",
3342            "<code>code</code>",
3343            "<br>",
3344            "mdi-warichu",
3345            "mdi-kern",
3346            "--mdi-em:&quot;●&quot;",
3347        ] {
3348            assert!(html.contains(expected), "HTML contains {expected}");
3349        }
3350
3351        let canonical = serialize_mdi(source);
3352        for expected in [
3353            "[[bottom]]",
3354            "## 中見出し",
3355            "> 引用",
3356            "1. 一",
3357            "- 箇条",
3358            "```rust",
3359            "| 見出し | 値 |",
3360            "[リンク](https://example.test \\題\")",
3361            "![画像](image.png)",
3362            "~~削除~~",
3363            "`code`",
3364            "[[br]]",
3365            "[[warichu:割書]]",
3366            "[[kern:1em:字]]",
3367            "[[em:●:傍点]]",
3368        ] {
3369            assert!(
3370                canonical.contains(expected),
3371                "canonical MDI contains {expected}"
3372            );
3373        }
3374
3375        let plain = render_text(source);
3376        assert!(plain.contains("画像"));
3377        assert!(plain.contains("巢狀"));
3378
3379        for (name, format) in [
3380            ("txt", TextFormat::Plain),
3381            ("txt-ruby", TextFormat::Ruby),
3382            ("narou", TextFormat::Narou),
3383            ("kakuyomu", TextFormat::Kakuyomu),
3384            ("aozora", TextFormat::Aozora),
3385        ] {
3386            assert_eq!(TextFormat::parse(name), Some(format));
3387            assert!(!render_text_format(source, format, " ").is_empty());
3388        }
3389        assert_eq!(TextFormat::parse("unknown"), None);
3390    }
3391
3392    #[test]
3393    fn packages_an_epub_from_rust_ir() {
3394        let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
3395        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3396        let mut mimetype = String::new();
3397        zip.by_name("mimetype")
3398            .unwrap()
3399            .read_to_string(&mut mimetype)
3400            .unwrap();
3401        assert_eq!(mimetype, "application/epub+zip");
3402        let mut opf = String::new();
3403        zip.by_name("OEBPS/package.opf")
3404            .unwrap()
3405            .read_to_string(&mut opf)
3406            .unwrap();
3407        assert!(opf.contains("<dc:title>Test</dc:title>"));
3408        assert!(opf.contains("page-progression-direction=\"rtl\""));
3409        assert!(opf.contains("chapter-2.xhtml"));
3410    }
3411
3412    #[test]
3413    fn packages_a_docx_from_rust_ir() {
3414        let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
3415        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3416        let mut document = String::new();
3417        zip.by_name("word/document.xml")
3418            .unwrap()
3419            .read_to_string(&mut document)
3420            .unwrap();
3421        assert!(document.contains("題"));
3422        assert!(document.contains("東京"));
3423        let mut core = String::new();
3424        zip.by_name("docProps/core.xml")
3425            .unwrap()
3426            .read_to_string(&mut core)
3427            .unwrap();
3428        assert!(core.contains("<dc:title>Test</dc:title>"));
3429    }
3430
3431    #[test]
3432    fn renders_pdf_with_an_available_native_chromium() {
3433        let Some(chromium_path) = find_chromium() else {
3434            return;
3435        };
3436        let pdf = render_pdf(
3437            "# 題\n\n{東京|とうきょう}",
3438            &PdfOptions {
3439                chromium_path: Some(chromium_path),
3440            },
3441        )
3442        .unwrap();
3443        assert!(pdf.starts_with(b"%PDF-"));
3444    }
3445
3446    #[test]
3447    fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
3448        for seed in 0..256 {
3449            let source = generated_source(seed);
3450            let output = parse_output(&source);
3451            assert_eq!(output.document.span.start_byte, 0);
3452            assert_eq!(output.document.span.end_byte as usize, source.len());
3453            for child in &output.document.children {
3454                assert_valid_spans(child, &source);
3455            }
3456            for diagnostic in &output.diagnostics {
3457                let span = diagnostic
3458                    .span
3459                    .expect("parser diagnostics have source spans");
3460                assert!(span.start_byte <= span.end_byte);
3461                assert!((span.end_byte as usize) <= source.len());
3462            }
3463
3464            let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
3465                .expect("every UTF-8 input has a serializable wire result");
3466            assert_eq!(wire["irVersion"], MDI_IR_VERSION);
3467            assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
3468            assert_valid_spans(&wire["document"], &source);
3469
3470            // Rendering and canonicalization are total functions over parser
3471            // output: malformed delimiters may become literal text, never a
3472            // host-visible failure.
3473            assert!(!render_html_document(&output.document).is_empty());
3474            let canonical = serialize_mdi_document(&output.document);
3475            let reparsed = parse_document(&canonical);
3476            for child in &reparsed.children {
3477                assert_valid_spans(child, &canonical);
3478            }
3479            let _ = render_text_document(&reparsed);
3480        }
3481    }
3482
3483    #[test]
3484    fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
3485        let cases = [
3486            "",
3487            "plain\n",
3488            "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
3489            "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
3490            "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
3491            "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
3492            "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
3493        ];
3494        for source in cases {
3495            let first = serialize_mdi(source);
3496            let second = serialize_mdi(&first);
3497            assert_eq!(
3498                second, first,
3499                "canonical output must stabilize for {source:?}"
3500            );
3501            assert_valid_spans(
3502                &serde_json::to_value(parse_document(&first)).unwrap(),
3503                &first,
3504            );
3505        }
3506    }
3507
3508    #[test]
3509    fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
3510        let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
3511
3512        let epub = render_epub(source).unwrap();
3513        let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
3514        assert_eq!(
3515            epub.by_name("mimetype").unwrap().compression(),
3516            CompressionMethod::Stored,
3517            "EPUB requires its mimetype member to be uncompressed"
3518        );
3519        for path in [
3520            "META-INF/container.xml",
3521            "OEBPS/package.opf",
3522            "OEBPS/nav.xhtml",
3523            "OEBPS/style.css",
3524            "OEBPS/chapter-1.xhtml",
3525            "OEBPS/chapter-2.xhtml",
3526        ] {
3527            assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
3528        }
3529        let mut opf = String::new();
3530        epub.by_name("OEBPS/package.opf")
3531            .unwrap()
3532            .read_to_string(&mut opf)
3533            .unwrap();
3534        assert!(opf.contains("A &amp; &lt; B &quot;quoted&quot;"));
3535        assert!(opf.contains("O'Brien &amp; Co."));
3536
3537        let docx = render_docx(source).unwrap();
3538        let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
3539        for path in [
3540            "[Content_Types].xml",
3541            "_rels/.rels",
3542            "docProps/core.xml",
3543            "word/document.xml",
3544        ] {
3545            assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
3546        }
3547        let mut core = String::new();
3548        docx.by_name("docProps/core.xml")
3549            .unwrap()
3550            .read_to_string(&mut core)
3551            .unwrap();
3552        assert!(core.contains("A &amp; &lt; B &quot;quoted&quot;"));
3553        let mut document = String::new();
3554        docx.by_name("word/document.xml")
3555            .unwrap()
3556            .read_to_string(&mut document)
3557            .unwrap();
3558        assert!(document.contains("&lt;unsafe&gt;&amp;"));
3559    }
3560
3561    #[test]
3562    fn classifies_every_legacy_block_macro_shape() {
3563        let amount = |value| match classify_block_macro(value) {
3564            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
3565            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
3566        };
3567        assert_eq!(amount(" [[indent:12]] "), Some(12));
3568        assert_eq!(amount("[[bottom]]"), Some(0));
3569        assert_eq!(amount("[[bottom:3]]"), Some(3));
3570        assert_eq!(amount("literal"), None);
3571        assert!(matches!(
3572            classify_block_macro("[[pagebreak]]"),
3573            BlockMacroClass::Pagebreak(None)
3574        ));
3575        assert!(matches!(
3576            classify_block_macro("[[pagebreak:left]]"),
3577            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
3578        ));
3579        assert!(matches!(
3580            classify_block_macro("[[pagebreak:right]]"),
3581            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
3582        ));
3583        for literal in [
3584            "text",
3585            "[[unknown:1]]",
3586            "[[indent:]]",
3587            "[[indent:0]]",
3588            "[[indent:01]]",
3589            "[[indent:x]]",
3590            "[[indent:999999999999999999999999999999]]",
3591        ] {
3592            assert!(matches!(
3593                classify_block_macro(literal),
3594                BlockMacroClass::Literal
3595            ));
3596        }
3597    }
3598
3599    #[test]
3600    fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
3601        let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
3602        assert_eq!(newer.diagnostics.len(), 1);
3603        assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
3604        assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
3605        assert_eq!(
3606            newer.diagnostics[0].span,
3607            newer.document.frontmatter.map(|f| f.span)
3608        );
3609
3610        for source in [
3611            "---\nmdi: '2.0'\n---\n",
3612            "---\nmdi: 3\n---\n",
3613            "---\n- sequence\n- values\n---\n",
3614            "---\n[malformed\n---\n",
3615        ] {
3616            let output = parse_output(source);
3617            assert!(output.diagnostics.is_empty());
3618            assert!(output.document.frontmatter.is_some());
3619        }
3620    }
3621
3622    #[test]
3623    fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
3624        let missing =
3625            std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
3626        let error = render_pdf(
3627            "text",
3628            &PdfOptions {
3629                chromium_path: Some(missing),
3630            },
3631        )
3632        .unwrap_err();
3633        assert!(error.contains("failed to start Chromium"));
3634
3635        let current_exe = std::env::current_exe().unwrap();
3636        let error = render_pdf(
3637            "text",
3638            &PdfOptions {
3639                chromium_path: Some(current_exe),
3640            },
3641        )
3642        .unwrap_err();
3643        assert!(error.contains("Chromium PDF rendering failed"));
3644    }
3645
3646    #[test]
3647    fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
3648        let mut scalar = serde_json::json!(null);
3649        lower_markdown_inside_mdi(&mut scalar, "");
3650        shift_spans(&mut scalar, 10);
3651        annotate_and_lower(&mut scalar, "", false);
3652        inject_block_markers(&mut scalar, &[]);
3653        assert_valid_spans(&scalar, "");
3654
3655        let mut paragraph_with_invalid_span = serde_json::json!({
3656            "type": "paragraph",
3657            "span": {"startByte": 2, "endByte": 3}
3658        });
3659        lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
3660
3661        let mut text_without_value = serde_json::json!({
3662            "type": "text",
3663            "position": {"start": {"offset": 0}, "end": {"offset": 0}}
3664        });
3665        annotate_and_lower(&mut text_without_value, "", false);
3666        let mut text_without_position = serde_json::json!({
3667            "type": "text",
3668            "value": "^12^"
3669        });
3670        annotate_and_lower(&mut text_without_position, "^12^", false);
3671        assert_eq!(text_without_position["children"][0]["type"], "tcy");
3672        let mut text_with_partial_span = serde_json::json!({
3673            "type": "text",
3674            "value": "^12^",
3675            "span": {}
3676        });
3677        annotate_and_lower(&mut text_with_partial_span, "^12^", false);
3678        assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
3679
3680        assert!(markdown_macro_children("not-a-macro", 0).is_none());
3681        assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
3682
3683        let span = SourceSpan {
3684            start_byte: 0,
3685            end_byte: 10,
3686        };
3687        for (is_indent, amount, expected) in [
3688            (true, 2, "[[indent:2]]"),
3689            (false, 0, "[[bottom]]"),
3690            (false, 3, "[[bottom:3]]"),
3691        ] {
3692            let mut output = Vec::new();
3693            let mut pending = Some((span, is_indent, amount));
3694            flush_pending_literal(&mut output, &mut pending);
3695            assert_eq!(output[0]["children"][0]["value"], expected);
3696        }
3697
3698        assert!(matches!(
3699            paragraph(
3700                "text",
3701                Some(PendingBlock::Bottom {
3702                    amount: 2,
3703                    source: "[[bottom:2]]".to_owned()
3704                })
3705            ),
3706            MdiBlock::Paragraph {
3707                bottom: Some(2),
3708                ..
3709            }
3710        ));
3711        assert!(pending_block("[[unknown:2]]").is_none());
3712        assert!(boten("《《》》").is_none());
3713        assert!(!valid_kern("1.em"));
3714        assert!(!valid_kern("1.2.3em"));
3715        assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
3716
3717        let mut writer = FailAfterWrites {
3718            inner: Cursor::new(Vec::new()),
3719            remaining: 1,
3720        };
3721        writer.flush().unwrap();
3722
3723        let mut formatted = Vec::new();
3724        text_format_block(
3725            &serde_json::json!({"type": "blank"}),
3726            TextFormat::Plain,
3727            "",
3728            &[],
3729            &mut formatted,
3730        );
3731        text_format_block(
3732            &serde_json::json!({"type": "unknown"}),
3733            TextFormat::Plain,
3734            "",
3735            &[],
3736            &mut formatted,
3737        );
3738        assert_eq!(formatted, vec![String::new()]);
3739
3740        assert_eq!(
3741            text_format_inline(
3742                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
3743                TextFormat::Ruby,
3744                &[]
3745            ),
3746            "{字|じ}"
3747        );
3748        assert_eq!(
3749            text_format_inline(
3750                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
3751                TextFormat::Plain,
3752                &[]
3753            ),
3754            "字"
3755        );
3756        assert_eq!(
3757            text_format_inline(
3758                &serde_json::json!({"type":"image", "alt":""}),
3759                TextFormat::Plain,
3760                &[]
3761            ),
3762            "[画像]"
3763        );
3764
3765        let mut html = String::new();
3766        render_html_node(&serde_json::json!({}), &mut html);
3767        render_html_node(
3768            &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
3769            &mut html,
3770        );
3771        render_html_children(&serde_json::json!({"type":"root"}), &mut html);
3772        assert_eq!(html, "ok");
3773
3774        let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
3775        assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
3776        let heading = parse_document("[[indent:2]]\n# heading");
3777        assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
3778        let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
3779        assert_eq!(trailing.children.len(), 2);
3780
3781        let partial_document = Document {
3782            span: SourceSpan::default(),
3783            frontmatter: None,
3784            children: vec![serde_json::json!({
3785                "type": "root",
3786                "children": [{"type":"text", "value":"partial"}]
3787            })],
3788        };
3789        assert!(render_docx_document(&partial_document).is_ok());
3790    }
3791
3792    #[test]
3793    fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
3794        let empty = render_epub("").unwrap();
3795        let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
3796        assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
3797
3798        let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
3799        let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
3800        assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
3801    }
3802
3803    #[test]
3804    fn archive_writers_propagate_failures_from_every_write_stage() {
3805        let document = parse_document(
3806            "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
3807        );
3808
3809        let mut epub_errors = 0;
3810        let mut epub_success = false;
3811        for remaining in 0..256 {
3812            let writer = FailAfterWrites {
3813                inner: Cursor::new(Vec::new()),
3814                remaining,
3815            };
3816            let mut zip = ZipWriter::new(writer);
3817            let result = write_epub_document(&document, &mut zip);
3818            if result.is_err() {
3819                epub_errors += 1;
3820            } else {
3821                epub_success = true;
3822            }
3823            // A deliberately broken writer can leave `ZipWriter` midway
3824            // through a local header, where its Drop finalizer cannot safely
3825            // recover. The writer owns only this test's in-memory buffer.
3826            std::mem::forget(zip);
3827            if epub_success {
3828                break;
3829            }
3830        }
3831        assert!(epub_errors > 0);
3832        assert!(epub_success);
3833
3834        let mut docx_errors = 0;
3835        let mut docx_success = false;
3836        for remaining in 0..256 {
3837            let writer = FailAfterWrites {
3838                inner: Cursor::new(Vec::new()),
3839                remaining,
3840            };
3841            let mut zip = ZipWriter::new(writer);
3842            let result = write_docx_document(&document, &mut zip);
3843            if result.is_err() {
3844                docx_errors += 1;
3845            } else {
3846                docx_success = true;
3847            }
3848            std::mem::forget(zip);
3849            if docx_success {
3850                break;
3851            }
3852        }
3853        assert!(docx_errors > 0);
3854        assert!(docx_success);
3855    }
3856}