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 let Some((Inline::Text(value), _, _)) = parsed.first()
822        && parsed.len() == 1
823        && value == rendered_value
824    {
825        return;
826    }
827    let replacement: Vec<serde_json::Value> = parsed
828        .into_iter()
829        .map(|(inline, start, end)| {
830            let mut value = serde_json::to_value(inline).expect("MDI inline is serializable");
831            if let (Some(token_span), Some(object)) = (&span, value.as_object_mut()) {
832                let start_byte = token_span
833                    .get("startByte")
834                    .and_then(serde_json::Value::as_u64);
835                if let Some(start_byte) = start_byte {
836                    let start = source_offsets
837                        .as_ref()
838                        .and_then(|offsets| source_offset(offsets, start))
839                        .unwrap_or(start);
840                    let end = source_offsets
841                        .as_ref()
842                        .and_then(|offsets| source_offset(offsets, end))
843                        .unwrap_or(end);
844                    object.insert(
845                        "span".to_owned(),
846                        serde_json::json!(SourceSpan {
847                            start_byte: (start_byte as usize + start) as u32,
848                            end_byte: (start_byte as usize + end) as u32,
849                        }),
850                    );
851                }
852            }
853            value
854        })
855        .collect();
856    *node = serde_json::json!({ "type": "mdiFragment", "children": replacement, "span": span });
857}
858
859fn looks_like_mdi(value: &str) -> bool {
860    value.contains(['{', '^', '《', '[', '\\'])
861}
862
863fn span_from_position(value: &serde_json::Value, source: &str) -> Option<SourceSpan> {
864    let start = value.pointer("/start/offset")?.as_u64()? as usize;
865    let end = value.pointer("/end/offset")?.as_u64()? as usize;
866    Some(SourceSpan {
867        start_byte: character_offset_to_byte(source, start) as u32,
868        end_byte: character_offset_to_byte(source, end) as u32,
869    })
870}
871
872fn character_offset_to_byte(source: &str, offset: usize) -> usize {
873    // `markdown-rs` stores this field as a UTF-8 byte offset.
874    offset.min(source.len())
875}
876
877fn extract_frontmatter(root: &serde_json::Value, source: &str) -> Option<Frontmatter> {
878    let yaml = root.get("children")?.as_array()?.first()?;
879    if yaml.get("type")?.as_str()? != "yaml" {
880        return None;
881    }
882    let raw = yaml.get("value")?.as_str()?.to_owned();
883    let span = yaml
884        .get("position")
885        .and_then(|value| span_from_position(value, source))?;
886    let entries = match serde_yaml::from_str::<serde_yaml::Value>(&raw) {
887        Ok(serde_yaml::Value::Mapping(mapping)) => mapping
888            .into_iter()
889            .filter_map(|(key, value)| {
890                let key = key.as_str()?.to_owned();
891                let value = serde_json::to_value(value).ok()?;
892                Some(FrontmatterEntry { key, value })
893            })
894            .collect(),
895        _ => Vec::new(),
896    };
897    Some(Frontmatter { span, raw, entries })
898}
899
900fn diagnostics(document: &Document) -> Vec<Diagnostic> {
901    let Some(frontmatter) = document.frontmatter.as_ref() else {
902        return Vec::new();
903    };
904    let declared = frontmatter.entries.iter().find(|entry| entry.key == "mdi");
905    let Some(declared) = declared.and_then(|entry| entry.value.as_str()) else {
906        return Vec::new();
907    };
908    if declared > MDI_SPEC_VERSION {
909        vec![Diagnostic {
910            severity: DiagnosticSeverity::Warning,
911            code: "mdi.version.unsupported".to_owned(),
912            message: format!("MDI {declared} is newer than the supported {MDI_SPEC_VERSION}"),
913            span: Some(frontmatter.span),
914        }]
915    } else {
916        Vec::new()
917    }
918}
919
920/// Parse the complete CommonMark, GFM, front-matter, and MDI document and
921/// return the versioned wire envelope used by language bindings.
922pub fn parse_output(source: &str) -> ParseOutput {
923    let document = parse_document(source);
924    ParseOutput {
925        ir_version: MDI_IR_VERSION,
926        syntax_version: MDI_SPEC_VERSION,
927        capabilities: ParserCapabilities {
928            mdi: true,
929            common_mark: true,
930            gfm: true,
931            front_matter: true,
932            source_spans: true,
933        },
934        diagnostics: diagnostics(&document),
935        document,
936    }
937}
938
939/// Serialize [`parse_output`] for FFI boundaries.
940///
941/// JSON is the first portable contract because it behaves identically through
942/// WebAssembly, N-API, PyO3, and a C ABI. Native bindings may later provide
943/// zero-copy views without changing this wire representation.
944pub fn parse_json(source: &str) -> String {
945    serde_json::to_string(&parse_output(source))
946        .expect("serializing the MDI parse output cannot fail")
947}
948
949/// Stable C ABI used by native language bindings.
950///
951/// Every operation accepts UTF-8 bytes and returns owned bytes. Callers must
952/// release both buffers in an [`MdiFfiResult`] with [`mdi_free_buffer`]. The
953/// JSON returned by `mdi_parse_json` is the same versioned wire contract used
954/// by the JavaScript and future Python bindings.
955#[allow(unsafe_code)]
956pub mod ffi {
957    use super::{parse_json, render_docx, render_epub, render_html, render_text, serialize_mdi};
958    use std::slice;
959
960    #[repr(C)]
961    #[derive(Debug, Clone, Copy)]
962    pub struct MdiFfiBuffer {
963        pub data: *mut u8,
964        pub len: usize,
965    }
966
967    #[repr(C)]
968    #[derive(Debug, Clone, Copy)]
969    pub struct MdiFfiResult {
970        pub value: MdiFfiBuffer,
971        pub error: MdiFfiBuffer,
972    }
973
974    fn empty_buffer() -> MdiFfiBuffer {
975        MdiFfiBuffer {
976            data: std::ptr::null_mut(),
977            len: 0,
978        }
979    }
980
981    fn buffer(value: Vec<u8>) -> MdiFfiBuffer {
982        if value.is_empty() {
983            return empty_buffer();
984        }
985        let mut value = value.into_boxed_slice();
986        let result = MdiFfiBuffer {
987            data: value.as_mut_ptr(),
988            len: value.len(),
989        };
990        std::mem::forget(value);
991        result
992    }
993
994    fn success(value: Vec<u8>) -> MdiFfiResult {
995        MdiFfiResult {
996            value: buffer(value),
997            error: empty_buffer(),
998        }
999    }
1000
1001    fn failure(message: impl Into<String>) -> MdiFfiResult {
1002        MdiFfiResult {
1003            value: empty_buffer(),
1004            error: buffer(message.into().into_bytes()),
1005        }
1006    }
1007
1008    fn source<'a>(data: *const u8, len: usize) -> Result<&'a str, String> {
1009        if data.is_null() && len != 0 {
1010            return Err("MDI source pointer is null".to_owned());
1011        }
1012        let bytes = if len == 0 {
1013            &[]
1014        } else {
1015            unsafe { slice::from_raw_parts(data, len) }
1016        };
1017        std::str::from_utf8(bytes).map_err(|_| "MDI source must be valid UTF-8".to_owned())
1018    }
1019
1020    fn string_result(
1021        data: *const u8,
1022        len: usize,
1023        operation: impl FnOnce(&str) -> String,
1024    ) -> MdiFfiResult {
1025        match source(data, len) {
1026            Ok(source) => success(operation(source).into_bytes()),
1027            Err(error) => failure(error),
1028        }
1029    }
1030
1031    #[unsafe(no_mangle)]
1032    pub extern "C" fn mdi_parse_json(data: *const u8, len: usize) -> MdiFfiResult {
1033        string_result(data, len, parse_json)
1034    }
1035    #[unsafe(no_mangle)]
1036    pub extern "C" fn mdi_render_html(data: *const u8, len: usize) -> MdiFfiResult {
1037        string_result(data, len, render_html)
1038    }
1039    #[unsafe(no_mangle)]
1040    pub extern "C" fn mdi_serialize_mdi(data: *const u8, len: usize) -> MdiFfiResult {
1041        string_result(data, len, serialize_mdi)
1042    }
1043    #[unsafe(no_mangle)]
1044    pub extern "C" fn mdi_render_text(data: *const u8, len: usize) -> MdiFfiResult {
1045        string_result(data, len, render_text)
1046    }
1047
1048    fn binary_result(
1049        data: *const u8,
1050        len: usize,
1051        operation: impl FnOnce(&str) -> Result<Vec<u8>, String>,
1052    ) -> MdiFfiResult {
1053        match source(data, len).and_then(operation) {
1054            Ok(value) => success(value),
1055            Err(error) => failure(error),
1056        }
1057    }
1058
1059    #[unsafe(no_mangle)]
1060    pub extern "C" fn mdi_render_epub(data: *const u8, len: usize) -> MdiFfiResult {
1061        binary_result(data, len, render_epub)
1062    }
1063    #[unsafe(no_mangle)]
1064    pub extern "C" fn mdi_render_docx(data: *const u8, len: usize) -> MdiFfiResult {
1065        binary_result(data, len, render_docx)
1066    }
1067
1068    /// Releases a buffer returned by this module.
1069    ///
1070    /// # Safety
1071    ///
1072    /// `buffer` must have been returned by one of this module's functions and
1073    /// each non-empty buffer must be passed here at most once.
1074    #[unsafe(no_mangle)]
1075    pub unsafe extern "C" fn mdi_free_buffer(buffer: MdiFfiBuffer) {
1076        if !buffer.data.is_null() && buffer.len != 0 {
1077            unsafe {
1078                drop(Vec::from_raw_parts(buffer.data, buffer.len, buffer.len));
1079            }
1080        }
1081    }
1082}
1083
1084/// Render a complete source document to a standalone HTML document directly
1085/// from the Rust-owned IR.  This is intentionally source-oriented for FFI:
1086/// bindings pass UTF-8 source once and never reconstruct MDI syntax in a host
1087/// renderer.
1088pub fn render_html(source: &str) -> String {
1089    render_html_document(&parse_document(source))
1090}
1091
1092/// Render a previously parsed document to standalone HTML.
1093pub fn render_html_document(document: &Document) -> String {
1094    let frontmatter = document.frontmatter.as_ref();
1095    let field = |key: &str| {
1096        frontmatter
1097            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1098            .and_then(|entry| entry.value.as_str())
1099    };
1100    let lang = field("lang").unwrap_or("ja");
1101    let title = field("title")
1102        .map(|title| format!("<title>{}</title>", escape_html(title)))
1103        .unwrap_or_default();
1104    let vertical = matches!(field("writing-mode"), Some("vertical"));
1105    let writing_mode = if vertical {
1106        " style=\"writing-mode: vertical-rl;\""
1107    } else {
1108        ""
1109    };
1110    let mut body = String::new();
1111    let mut footnotes = Vec::new();
1112    for child in &document.children {
1113        if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
1114            footnotes.push(child);
1115        } else {
1116            render_html_node(child, &mut body);
1117        }
1118    }
1119    if !footnotes.is_empty() {
1120        body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\">Footnotes</h2><ol>");
1121        for (index, footnote) in footnotes.into_iter().enumerate() {
1122            body.push_str("<li id=\"user-content-fn-");
1123            body.push_str(&escape_html(
1124                footnote
1125                    .get("identifier")
1126                    .and_then(serde_json::Value::as_str)
1127                    .unwrap_or(&format!("{}", index + 1)),
1128            ));
1129            body.push_str("\">");
1130            render_html_children(footnote, &mut body);
1131            body.push_str("</li>");
1132        }
1133        body.push_str("</ol></section>");
1134    }
1135    format!(
1136        "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
1137        escape_html(lang),
1138        writing_mode,
1139        title,
1140        MDI_STYLESHEET,
1141        body
1142    )
1143}
1144
1145/// Parse and serialize source to canonical MDI/Markdown spelling in Rust.
1146pub fn serialize_mdi(source: &str) -> String {
1147    serialize_mdi_document(&parse_document(source))
1148}
1149
1150/// Serialize a parsed document without invoking a host Markdown serializer.
1151pub fn serialize_mdi_document(document: &Document) -> String {
1152    let mut output = String::new();
1153    if let Some(frontmatter) = &document.frontmatter {
1154        output.push_str("---\n");
1155        output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1156        output.push_str("\n---\n\n");
1157    }
1158    for (index, node) in document.children.iter().enumerate() {
1159        if index > 0 && !output.ends_with("\n\n") {
1160            output.push('\n');
1161        }
1162        serialize_block(node, &mut output, "");
1163    }
1164    output
1165}
1166
1167/// Render source to deterministic plain text from the Rust IR. Typography
1168/// annotations are represented by their readable base text; page and blank
1169/// paragraph boundaries remain visible as newlines.
1170pub fn render_text(source: &str) -> String {
1171    render_text_document(&parse_document(source))
1172}
1173
1174/// Render a parsed document to plain text.
1175pub fn render_text_document(document: &Document) -> String {
1176    let mut output = String::new();
1177    for node in &document.children {
1178        render_text_node(node, &mut output);
1179        if !output.ends_with('\n') {
1180            output.push('\n');
1181        }
1182    }
1183    output
1184}
1185
1186/// Text export conventions supported by the portable core.
1187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1188pub enum TextFormat {
1189    Plain,
1190    Ruby,
1191    Narou,
1192    Kakuyomu,
1193    Aozora,
1194}
1195
1196impl TextFormat {
1197    /// Parse the stable binding names used by the CLI and JavaScript API.
1198    pub fn parse(value: &str) -> Option<Self> {
1199        match value {
1200            "txt" => Some(Self::Plain),
1201            "txt-ruby" => Some(Self::Ruby),
1202            "narou" => Some(Self::Narou),
1203            "kakuyomu" => Some(Self::Kakuyomu),
1204            "aozora" => Some(Self::Aozora),
1205            _ => None,
1206        }
1207    }
1208}
1209
1210/// Render a publication-platform text variant from Rust IR. `indent_prefix`
1211/// is supplied by the host's already-resolved export profile.
1212pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1213    let document = parse_document(source);
1214    let mut heading_depths = document
1215        .children
1216        .iter()
1217        .filter(|node| node.get("type").and_then(serde_json::Value::as_str) == Some("heading"))
1218        .filter_map(|node| node.get("depth").and_then(serde_json::Value::as_u64))
1219        .collect::<Vec<_>>();
1220    heading_depths.sort_unstable();
1221    heading_depths.dedup();
1222    let definitions: Vec<&serde_json::Value> = document
1223        .children
1224        .iter()
1225        .filter(|node| {
1226            node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1227        })
1228        .collect();
1229    let mut blocks = Vec::new();
1230    for node in &document.children {
1231        text_format_block(
1232            node,
1233            format,
1234            indent_prefix,
1235            &definitions,
1236            &heading_depths,
1237            &mut blocks,
1238        );
1239    }
1240    if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1241        blocks.push(String::new());
1242        blocks.push("Footnotes".to_owned());
1243        for (index, definition) in definitions.iter().enumerate() {
1244            let text = children(definition)
1245                .iter()
1246                .filter(|child| {
1247                    child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1248                })
1249                .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1250                .collect::<Vec<_>>()
1251                .join(" ");
1252            blocks.push(format!("{}. {text}", index + 1));
1253        }
1254    }
1255    if matches!(format, TextFormat::Aozora) && heading_depths.len() > 3 {
1256        blocks.push(String::new());
1257        blocks.push("※小見出しよりもさらに下位の見出しには、注記しませんでした。".to_owned());
1258    }
1259    let output = blocks.join("\n");
1260    if matches!(format, TextFormat::Aozora) {
1261        output.replace('\n', "\r\n")
1262    } else {
1263        output
1264    }
1265}
1266
1267fn text_format_block(
1268    node: &serde_json::Value,
1269    format: TextFormat,
1270    prefix: &str,
1271    definitions: &[&serde_json::Value],
1272    heading_depths: &[u64],
1273    output: &mut Vec<String>,
1274) {
1275    let kind = node
1276        .get("type")
1277        .and_then(serde_json::Value::as_str)
1278        .unwrap_or_default();
1279    match kind {
1280        "footnoteDefinition" | "definition" => {}
1281        "paragraph" => {
1282            let value = text_format_inline_children(node, format, definitions);
1283            let block_prefix = text_format_block_prefix(node, format);
1284            output.push(format!("{prefix}{block_prefix}{value}"));
1285        }
1286        "heading" => {
1287            let value = text_format_inline_children(node, format, definitions);
1288            if matches!(format, TextFormat::Aozora) {
1289                let depth = node
1290                    .get("depth")
1291                    .and_then(serde_json::Value::as_u64)
1292                    .unwrap_or(3);
1293                if let Some(size) = aozora_heading_size(depth, heading_depths) {
1294                    let reference = text_format_plain_inline_children(node);
1295                    if aozora_needs_range_annotation(node) {
1296                        output.push(format!("[#{size}見出し]{value}[#{size}見出し終わり]"));
1297                    } else {
1298                        output.push(format!("{value}[#「{reference}」は{size}見出し]"));
1299                    }
1300                } else {
1301                    output.push(value);
1302                }
1303            } else {
1304                output.push(value);
1305            }
1306        }
1307        "list" => {
1308            for (index, item) in children(node).iter().enumerate() {
1309                for child in children(item) {
1310                    if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1311                        let bullet = if node
1312                            .get("ordered")
1313                            .and_then(serde_json::Value::as_bool)
1314                            .unwrap_or(false)
1315                        {
1316                            format!("{}. ", index + 1)
1317                        } else {
1318                            "- ".to_owned()
1319                        };
1320                        output.push(format!(
1321                            "{prefix}{bullet}{}",
1322                            text_format_inline_children(child, format, definitions)
1323                        ));
1324                    } else {
1325                        text_format_block(
1326                            child,
1327                            format,
1328                            prefix,
1329                            definitions,
1330                            heading_depths,
1331                            output,
1332                        );
1333                    }
1334                }
1335            }
1336        }
1337        "blockquote" => {
1338            for child in children(node) {
1339                text_format_block(child, format, prefix, definitions, heading_depths, output);
1340            }
1341        }
1342        "code" => output.extend(
1343            node.get("value")
1344                .and_then(serde_json::Value::as_str)
1345                .unwrap_or_default()
1346                .lines()
1347                .map(|line| text_format_literal(line, format)),
1348        ),
1349        "table" => {
1350            for row in children(node) {
1351                output.push(
1352                    children(row)
1353                        .iter()
1354                        .map(|cell| text_format_inline_children(cell, format, definitions))
1355                        .collect::<Vec<_>>()
1356                        .join("\t"),
1357                );
1358            }
1359        }
1360        "thematicBreak" => output.push("――――――".to_owned()),
1361        "blank" => output.push(String::new()),
1362        "pagebreak" => {
1363            if matches!(format, TextFormat::Aozora) {
1364                let annotation = match node.get("variant").and_then(serde_json::Value::as_str) {
1365                    Some("left") => "[#改丁]",
1366                    Some("right") => "[#改見開き]",
1367                    _ => "[#改ページ]",
1368                };
1369                output.push(annotation.to_owned());
1370            } else {
1371                output.push(String::new());
1372            }
1373        }
1374        _ => {}
1375    }
1376}
1377
1378fn aozora_heading_size(depth: u64, heading_depths: &[u64]) -> Option<&'static str> {
1379    let index = heading_depths
1380        .iter()
1381        .position(|candidate| *candidate == depth)?;
1382    match heading_depths.len() {
1383        1 => Some("中"),
1384        2 => ["大", "中"].get(index).copied(),
1385        _ => ["大", "中", "小"].get(index).copied(),
1386    }
1387}
1388
1389fn text_format_block_prefix(node: &serde_json::Value, format: TextFormat) -> String {
1390    let indent = node
1391        .get("indent")
1392        .and_then(serde_json::Value::as_u64)
1393        .filter(|amount| *amount > 0);
1394    let bottom = node.get("bottom").and_then(serde_json::Value::as_u64);
1395    if matches!(format, TextFormat::Aozora) {
1396        if let Some(amount) = bottom {
1397            return if amount == 0 {
1398                "[#地付き]".to_owned()
1399            } else {
1400                format!("[#地から{}字上げ]", fullwidth_digits(amount))
1401            };
1402        }
1403        return indent
1404            .map(|amount| format!("[#{}字下げ]", fullwidth_digits(amount)))
1405            .unwrap_or_default();
1406    }
1407    indent
1408        .map(|amount| " ".repeat(amount as usize))
1409        .unwrap_or_default()
1410}
1411
1412fn fullwidth_digits(value: u64) -> String {
1413    value
1414        .to_string()
1415        .replace('0', "0")
1416        .replace('1', "1")
1417        .replace('2', "2")
1418        .replace('3', "3")
1419        .replace('4', "4")
1420        .replace('5', "5")
1421        .replace('6', "6")
1422        .replace('7', "7")
1423        .replace('8', "8")
1424        .replace('9', "9")
1425}
1426
1427fn text_format_inline_children(
1428    node: &serde_json::Value,
1429    format: TextFormat,
1430    definitions: &[&serde_json::Value],
1431) -> String {
1432    children(node)
1433        .iter()
1434        .map(|node| text_format_inline(node, format, definitions))
1435        .collect()
1436}
1437fn text_format_inline(
1438    node: &serde_json::Value,
1439    format: TextFormat,
1440    definitions: &[&serde_json::Value],
1441) -> String {
1442    match node
1443        .get("type")
1444        .and_then(serde_json::Value::as_str)
1445        .unwrap_or_default()
1446    {
1447        "text" | "inlineCode" => text_format_literal(
1448            node.get("value")
1449                .and_then(serde_json::Value::as_str)
1450                .unwrap_or_default(),
1451            format,
1452        ),
1453        "tcy" => {
1454            let value = text_format_literal(
1455                node.get("value")
1456                    .and_then(serde_json::Value::as_str)
1457                    .unwrap_or_default(),
1458                format,
1459            );
1460            if matches!(format, TextFormat::Aozora) {
1461                format!("{value}[#「{value}」は縦中横]")
1462            } else {
1463                value
1464            }
1465        }
1466        "break" => "\n".to_owned(),
1467        "ruby" => {
1468            let base = node
1469                .get("base")
1470                .and_then(serde_json::Value::as_str)
1471                .unwrap_or_default();
1472            let reading = node
1473                .pointer("/ruby/value")
1474                .map(|value| match value {
1475                    serde_json::Value::Array(parts) => parts
1476                        .iter()
1477                        .filter_map(serde_json::Value::as_str)
1478                        .collect::<Vec<_>>()
1479                        .join(if matches!(format, TextFormat::Ruby) {
1480                            "."
1481                        } else {
1482                            ""
1483                        }),
1484                    serde_json::Value::String(value) => value.to_owned(),
1485                    _ => String::new(),
1486                })
1487                .unwrap_or_default();
1488            match format {
1489                TextFormat::Plain => base.to_owned(),
1490                TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1491                _ => text_format_platform_ruby(base, &reading, format),
1492            }
1493        }
1494        "em" => {
1495            let value = text_format_inline_children(node, format, definitions);
1496            match format {
1497                TextFormat::Aozora if !value.is_empty() && !value.contains('\n') => {
1498                    let name = aozora_boten_name(
1499                        node.get("mark")
1500                            .and_then(serde_json::Value::as_str)
1501                            .unwrap_or("﹅"),
1502                    );
1503                    format!("[#{name}]{value}[#{name}終わり]")
1504                }
1505                TextFormat::Kakuyomu
1506                    if !value.is_empty()
1507                        && !value.contains('\n')
1508                        && !value.contains('《')
1509                        && !node_contains_type(node, "ruby") =>
1510                {
1511                    format!("《《{value}》》")
1512                }
1513                TextFormat::Narou
1514                    if !value.is_empty()
1515                        && !value.contains('\n')
1516                        && !node_contains_type(node, "ruby") =>
1517                {
1518                    value
1519                        .graphemes(true)
1520                        .map(|character| {
1521                            text_format_platform_ruby(character, "・", TextFormat::Narou)
1522                        })
1523                        .collect()
1524                }
1525                _ => value,
1526            }
1527        }
1528        "image" => node
1529            .get("alt")
1530            .and_then(serde_json::Value::as_str)
1531            .filter(|alt| !alt.is_empty())
1532            .map(|alt| format!("[画像: {}]", text_format_literal(alt, format)))
1533            .unwrap_or_else(|| "[画像]".to_owned()),
1534        "warichu" => {
1535            let value = text_format_inline_children(node, format, definitions);
1536            if matches!(format, TextFormat::Aozora) && !value.is_empty() && !value.contains('\n') {
1537                format!("[#割り注]{value}[#割り注終わり]")
1538            } else {
1539                value
1540            }
1541        }
1542        "footnoteReference" => {
1543            if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1544                String::new()
1545            } else {
1546                let identifier = node
1547                    .get("identifier")
1548                    .and_then(serde_json::Value::as_str)
1549                    .unwrap_or_default();
1550                let index = definitions
1551                    .iter()
1552                    .position(|definition| {
1553                        definition
1554                            .get("identifier")
1555                            .and_then(serde_json::Value::as_str)
1556                            == Some(identifier)
1557                    })
1558                    .map(|index| index + 1)
1559                    .unwrap_or(0);
1560                if matches!(format, TextFormat::Aozora) {
1561                    format!("(注{index})")
1562                } else {
1563                    format!("[注{index}]")
1564                }
1565            }
1566        }
1567        _ => text_format_inline_children(node, format, definitions),
1568    }
1569}
1570
1571fn text_format_platform_ruby(base: &str, reading: &str, format: TextFormat) -> String {
1572    let valid = match format {
1573        TextFormat::Narou => {
1574            (1..=10).contains(&base.graphemes(true).count())
1575                && (1..=10).contains(&reading.graphemes(true).count())
1576                && !base.chars().any(narou_ruby_problem_character)
1577                && !reading.chars().any(narou_ruby_problem_character)
1578        }
1579        TextFormat::Kakuyomu => {
1580            (1..=20).contains(&base.graphemes(true).count())
1581                && (1..=50).contains(&reading.graphemes(true).count())
1582                && !base.contains(['\r', '\n'])
1583                && !reading.contains(['\r', '\n'])
1584                && !base.contains(['《', '》'])
1585                && !reading.contains(['《', '》'])
1586        }
1587        TextFormat::Aozora => {
1588            !base.is_empty()
1589                && !reading.is_empty()
1590                && !base.contains(['\r', '\n'])
1591                && !reading.contains(['\r', '\n'])
1592                && !base.chars().any(aozora_reserved_character)
1593                && !reading.chars().any(aozora_reserved_character)
1594        }
1595        TextFormat::Plain | TextFormat::Ruby => false,
1596    };
1597    if valid {
1598        format!("|{base}《{reading}》")
1599    } else {
1600        text_format_literal(base, format)
1601    }
1602}
1603
1604fn narou_ruby_problem_character(character: char) -> bool {
1605    matches!(character, '&' | '"' | '<' | '>')
1606}
1607
1608fn aozora_boten_name(mark: &str) -> &'static str {
1609    match mark {
1610        "﹆" => "白ゴマ傍点",
1611        "●" => "丸傍点",
1612        "○" => "白丸傍点",
1613        "▲" => "黒三角傍点",
1614        "△" => "白三角傍点",
1615        "◎" => "二重丸傍点",
1616        "×" => "ばつ傍点",
1617        _ => "傍点",
1618    }
1619}
1620
1621fn node_contains_type(node: &serde_json::Value, expected: &str) -> bool {
1622    node.get("type").and_then(serde_json::Value::as_str) == Some(expected)
1623        || children(node)
1624            .iter()
1625            .any(|child| node_contains_type(child, expected))
1626}
1627
1628fn text_format_plain_inline_children(node: &serde_json::Value) -> String {
1629    children(node)
1630        .iter()
1631        .map(text_format_plain_inline)
1632        .collect()
1633}
1634
1635fn text_format_plain_inline(node: &serde_json::Value) -> String {
1636    match node
1637        .get("type")
1638        .and_then(serde_json::Value::as_str)
1639        .unwrap_or_default()
1640    {
1641        "text" | "inlineCode" | "tcy" => node
1642            .get("value")
1643            .and_then(serde_json::Value::as_str)
1644            .unwrap_or_default()
1645            .to_owned(),
1646        "ruby" => node
1647            .get("base")
1648            .and_then(serde_json::Value::as_str)
1649            .unwrap_or_default()
1650            .to_owned(),
1651        "break" => "\n".to_owned(),
1652        "image" => node
1653            .get("alt")
1654            .and_then(serde_json::Value::as_str)
1655            .unwrap_or_default()
1656            .to_owned(),
1657        _ => text_format_plain_inline_children(node),
1658    }
1659}
1660
1661fn aozora_needs_range_annotation(node: &serde_json::Value) -> bool {
1662    node_contains_type(node, "em")
1663        || text_format_plain_inline_children(node)
1664            .chars()
1665            .any(aozora_reserved_character)
1666}
1667
1668fn aozora_reserved_character(character: char) -> bool {
1669    matches!(
1670        character,
1671        '《' | '》' | '[' | ']' | '〔' | '〕' | '|' | '#' | '※'
1672    )
1673}
1674
1675fn text_format_literal(value: &str, format: TextFormat) -> String {
1676    match format {
1677        TextFormat::Kakuyomu => value.replace('《', "|《"),
1678        TextFormat::Narou => value.replace('(', "|(").replace('(', "|("),
1679        TextFormat::Aozora => value
1680            .chars()
1681            .map(|character| match character {
1682                '《' => "※[#始め二重山括弧、1-1-52]".to_owned(),
1683                '》' => "※[#終わり二重山括弧、1-1-53]".to_owned(),
1684                '[' => "※[#始め角括弧、1-1-46]".to_owned(),
1685                ']' => "※[#終わり角括弧、1-1-47]".to_owned(),
1686                '〔' => "※[#始めきっこう(亀甲)括弧、1-1-44]".to_owned(),
1687                '〕' => "※[#終わりきっこう(亀甲)括弧、1-1-45]".to_owned(),
1688                '|' => "※[#縦線、1-1-35]".to_owned(),
1689                '#' => "※[#井げた、1-1-84]".to_owned(),
1690                '※' => "※[#米印、1-2-8]".to_owned(),
1691                _ => character.to_string(),
1692            })
1693            .collect(),
1694        TextFormat::Plain | TextFormat::Ruby => value.to_owned(),
1695    }
1696}
1697
1698fn render_text_node(node: &serde_json::Value, out: &mut String) {
1699    match node
1700        .get("type")
1701        .and_then(serde_json::Value::as_str)
1702        .unwrap_or_default()
1703    {
1704        "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
1705            node.get("value")
1706                .and_then(serde_json::Value::as_str)
1707                .unwrap_or_default(),
1708        ),
1709        "ruby" => out.push_str(
1710            node.get("base")
1711                .and_then(serde_json::Value::as_str)
1712                .unwrap_or_default(),
1713        ),
1714        "image" => out.push_str(
1715            node.get("alt")
1716                .and_then(serde_json::Value::as_str)
1717                .unwrap_or_default(),
1718        ),
1719        "break" => out.push('\n'),
1720        "blank" => out.push('\n'),
1721        "pagebreak" => out.push_str("\n\x0C\n"),
1722        "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
1723            render_text_children(node, out);
1724            out.push('\n');
1725        }
1726        "tableCell" => {
1727            render_text_children(node, out);
1728            out.push('\t');
1729        }
1730        _ => render_text_children(node, out),
1731    }
1732}
1733
1734fn render_text_children(node: &serde_json::Value, out: &mut String) {
1735    for child in children(node) {
1736        render_text_node(child, out);
1737    }
1738}
1739
1740fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
1741    let kind = node
1742        .get("type")
1743        .and_then(serde_json::Value::as_str)
1744        .unwrap_or_default();
1745    match kind {
1746        "paragraph" => {
1747            if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
1748                out.push_str(prefix);
1749                out.push_str(&format!("[[indent:{amount}]]\n"));
1750            }
1751            if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
1752                out.push_str(prefix);
1753                if amount == 0 {
1754                    out.push_str("[[bottom]]\n");
1755                } else {
1756                    out.push_str(&format!("[[bottom:{amount}]]\n"));
1757                }
1758            }
1759            out.push_str(prefix);
1760            serialize_inline_children(node, out);
1761            out.push('\n');
1762        }
1763        "heading" => {
1764            out.push_str(prefix);
1765            out.push_str(
1766                &"#".repeat(
1767                    node.get("depth")
1768                        .and_then(serde_json::Value::as_u64)
1769                        .unwrap_or(1) as usize,
1770                ),
1771            );
1772            out.push(' ');
1773            serialize_inline_children(node, out);
1774            out.push('\n');
1775        }
1776        "blockquote" => {
1777            let mut content = String::new();
1778            for child in children(node) {
1779                serialize_block(child, &mut content, "");
1780            }
1781            for line in content.trim_end_matches('\n').lines() {
1782                out.push_str(prefix);
1783                out.push_str("> ");
1784                out.push_str(line);
1785                out.push('\n');
1786            }
1787        }
1788        "list" => {
1789            let ordered = node
1790                .get("ordered")
1791                .and_then(serde_json::Value::as_bool)
1792                .unwrap_or(false);
1793            let start = node
1794                .get("start")
1795                .and_then(serde_json::Value::as_u64)
1796                .unwrap_or(1);
1797            for (index, item) in children(node).iter().enumerate() {
1798                out.push_str(prefix);
1799                if ordered {
1800                    out.push_str(&format!("{}.", start + index as u64));
1801                } else {
1802                    out.push('-');
1803                }
1804                out.push(' ');
1805                if let Some(first) = children(item).first() {
1806                    serialize_block(first, out, "");
1807                }
1808                for child in children(item).iter().skip(1) {
1809                    serialize_block(child, out, "  ");
1810                }
1811            }
1812        }
1813        "code" => {
1814            out.push_str(prefix);
1815            out.push_str("```");
1816            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
1817                out.push_str(lang);
1818            }
1819            out.push('\n');
1820            out.push_str(
1821                node.get("value")
1822                    .and_then(serde_json::Value::as_str)
1823                    .unwrap_or_default(),
1824            );
1825            out.push_str("\n```\n");
1826        }
1827        "thematicBreak" => out.push_str("---\n"),
1828        "blank" => out.push_str("\\\n"),
1829        "pagebreak" => {
1830            out.push_str("[[pagebreak");
1831            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
1832                out.push(':');
1833                out.push_str(variant);
1834            }
1835            out.push_str("]]\n");
1836        }
1837        "table" => serialize_table(node, out),
1838        "html" => {
1839            out.push_str(
1840                node.get("value")
1841                    .and_then(serde_json::Value::as_str)
1842                    .unwrap_or_default(),
1843            );
1844            out.push('\n');
1845        }
1846        _ => {
1847            serialize_inline(node, out);
1848            out.push('\n');
1849        }
1850    }
1851}
1852
1853fn serialize_table(node: &serde_json::Value, out: &mut String) {
1854    for (row_index, row) in children(node).iter().enumerate() {
1855        out.push('|');
1856        for cell in children(row) {
1857            out.push(' ');
1858            serialize_inline_children(cell, out);
1859            out.push_str(" |");
1860        }
1861        out.push('\n');
1862        if row_index == 0 {
1863            out.push('|');
1864            for _ in children(row) {
1865                out.push_str(" --- |");
1866            }
1867            out.push('\n');
1868        }
1869    }
1870}
1871
1872fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
1873    for child in children(node) {
1874        serialize_inline(child, out);
1875    }
1876}
1877
1878fn serialize_inline(node: &serde_json::Value, out: &mut String) {
1879    let kind = node
1880        .get("type")
1881        .and_then(serde_json::Value::as_str)
1882        .unwrap_or_default();
1883    match kind {
1884        "text" | "html" => out.push_str(
1885            node.get("value")
1886                .and_then(serde_json::Value::as_str)
1887                .unwrap_or_default(),
1888        ),
1889        "emphasis" => {
1890            out.push('*');
1891            serialize_inline_children(node, out);
1892            out.push('*');
1893        }
1894        "strong" => {
1895            out.push_str("**");
1896            serialize_inline_children(node, out);
1897            out.push_str("**");
1898        }
1899        "delete" => {
1900            out.push_str("~~");
1901            serialize_inline_children(node, out);
1902            out.push_str("~~");
1903        }
1904        "inlineCode" => {
1905            out.push('`');
1906            out.push_str(
1907                node.get("value")
1908                    .and_then(serde_json::Value::as_str)
1909                    .unwrap_or_default(),
1910            );
1911            out.push('`');
1912        }
1913        "link" => {
1914            out.push('[');
1915            serialize_inline_children(node, out);
1916            out.push_str("](");
1917            out.push_str(
1918                node.get("url")
1919                    .and_then(serde_json::Value::as_str)
1920                    .unwrap_or_default(),
1921            );
1922            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
1923                out.push_str(" \\");
1924                out.push_str(title);
1925                out.push('\"');
1926            }
1927            out.push(')');
1928        }
1929        "image" => {
1930            out.push_str("![");
1931            out.push_str(
1932                node.get("alt")
1933                    .and_then(serde_json::Value::as_str)
1934                    .unwrap_or_default(),
1935            );
1936            out.push_str("](");
1937            out.push_str(
1938                node.get("url")
1939                    .and_then(serde_json::Value::as_str)
1940                    .unwrap_or_default(),
1941            );
1942            out.push(')');
1943        }
1944        "ruby" => {
1945            out.push('{');
1946            out.push_str(
1947                node.get("base")
1948                    .and_then(serde_json::Value::as_str)
1949                    .unwrap_or_default(),
1950            );
1951            out.push('|');
1952            if let Some(values) = node
1953                .pointer("/ruby/value")
1954                .and_then(serde_json::Value::as_array)
1955            {
1956                for (index, value) in values.iter().enumerate() {
1957                    if index > 0 {
1958                        out.push('.');
1959                    }
1960                    out.push_str(value.as_str().unwrap_or_default());
1961                }
1962            } else {
1963                out.push_str(
1964                    node.pointer("/ruby/value")
1965                        .and_then(serde_json::Value::as_str)
1966                        .unwrap_or_default(),
1967                );
1968            }
1969            out.push('}');
1970        }
1971        "tcy" => {
1972            out.push('^');
1973            out.push_str(
1974                node.get("value")
1975                    .and_then(serde_json::Value::as_str)
1976                    .unwrap_or_default(),
1977            );
1978            out.push('^');
1979        }
1980        // Markdown hard breaks and MDI `[[br]]` have the same IR node; the
1981        // canonical serializer chooses the explicit MDI spelling.
1982        "break" => out.push_str("[[br]]"),
1983        "em" => {
1984            out.push_str("[[em:");
1985            let mark = node
1986                .get("mark")
1987                .and_then(serde_json::Value::as_str)
1988                .unwrap_or("﹅");
1989            if mark != "﹅" {
1990                out.push_str(mark);
1991                out.push(':');
1992            }
1993            serialize_inline_children(node, out);
1994            out.push_str("]]");
1995        }
1996        "noBreak" => {
1997            out.push_str("[[no-break:");
1998            serialize_inline_children(node, out);
1999            out.push_str("]]");
2000        }
2001        "warichu" => {
2002            out.push_str("[[warichu:");
2003            serialize_inline_children(node, out);
2004            out.push_str("]]");
2005        }
2006        "kern" => {
2007            out.push_str("[[kern:");
2008            out.push_str(
2009                node.get("amount")
2010                    .and_then(serde_json::Value::as_str)
2011                    .unwrap_or_default(),
2012            );
2013            out.push(':');
2014            serialize_inline_children(node, out);
2015            out.push_str("]]");
2016        }
2017        "footnoteReference" => {
2018            out.push_str("[^");
2019            out.push_str(
2020                node.get("identifier")
2021                    .and_then(serde_json::Value::as_str)
2022                    .unwrap_or_default(),
2023            );
2024            out.push(']');
2025        }
2026        _ => serialize_inline_children(node, out),
2027    }
2028}
2029
2030fn children(node: &serde_json::Value) -> &[serde_json::Value] {
2031    node.get("children")
2032        .and_then(serde_json::Value::as_array)
2033        .map(Vec::as_slice)
2034        .unwrap_or(&[])
2035}
2036
2037/// The base stylesheet is intentionally shipped by the core alongside the
2038/// semantic HTML. Hosts may add presentation CSS, but not reinterpret nodes.
2039pub 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-block-size:1lh}.mdi-indent{margin-inline-start:calc(var(--mdi-indent)*1em)}.mdi-bottom{text-align:end}.mdi-pagebreak{break-after:page}";
2040
2041/// Build a reflowable EPUB 3 archive entirely from Rust's document IR.
2042/// Metadata comes from front matter; richer cover and print-profile options
2043/// are intentionally layered on this deterministic core API later.
2044pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
2045    render_epub_document(&parse_document(source))
2046}
2047
2048/// Build a reflowable EPUB 3 archive from a parsed document.
2049pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
2050    let cursor = Cursor::new(Vec::new());
2051    let mut zip = ZipWriter::new(cursor);
2052    write_epub_document(document, &mut zip)?;
2053    zip.finish()
2054        .map(|cursor| cursor.into_inner())
2055        .map_err(|error| error.to_string())
2056}
2057
2058fn write_epub_document<W: Write + Seek>(
2059    document: &Document,
2060    zip: &mut ZipWriter<W>,
2061) -> Result<(), String> {
2062    let field = |key: &str| {
2063        document
2064            .frontmatter
2065            .as_ref()
2066            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2067            .and_then(|entry| entry.value.as_str())
2068    };
2069    let title = field("title").unwrap_or("Untitled");
2070    let author = field("author");
2071    let language = field("lang").unwrap_or("ja");
2072    let identifier = field("identifier").unwrap_or("urn:mdi:document");
2073    let vertical = matches!(field("writing-mode"), Some("vertical"));
2074    let chapters = epub_chapters(document);
2075    let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2076    epub_file(zip, "mimetype", "application/epub+zip", stored)?;
2077    let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2078    epub_file(
2079        zip,
2080        "META-INF/container.xml",
2081        "<?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>",
2082        compressed,
2083    )?;
2084    let writing = if vertical {
2085        "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
2086    } else {
2087        ""
2088    };
2089    epub_file(
2090        zip,
2091        "OEBPS/style.css",
2092        &format!(
2093            "body{{font-family:serif;{writing}line-height:1.8;margin:1em}}p{{text-indent:1em;margin:.3em 0}}{MDI_STYLESHEET}"
2094        ),
2095        compressed,
2096    )?;
2097    let nav_items = chapters
2098        .iter()
2099        .enumerate()
2100        .map(|(index, chapter)| {
2101            format!(
2102                "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
2103                index + 1,
2104                escape_html(&chapter.title)
2105            )
2106        })
2107        .collect::<String>();
2108    epub_file(
2109        zip,
2110        "OEBPS/nav.xhtml",
2111        &epub_xhtml(
2112            "Contents",
2113            language,
2114            &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
2115        ),
2116        compressed,
2117    )?;
2118    for (index, chapter) in chapters.iter().enumerate() {
2119        epub_file(
2120            zip,
2121            &format!("OEBPS/chapter-{}.xhtml", index + 1),
2122            &epub_xhtml(
2123                if chapter.title.is_empty() {
2124                    title
2125                } else {
2126                    &chapter.title
2127                },
2128                language,
2129                &chapter.html,
2130            ),
2131            compressed,
2132        )?;
2133    }
2134    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>());
2135    let spine = chapters
2136        .iter()
2137        .enumerate()
2138        .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
2139        .collect::<String>();
2140    let creator = author
2141        .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
2142        .unwrap_or_default();
2143    let progression = if vertical {
2144        " page-progression-direction=\"rtl\""
2145    } else {
2146        ""
2147    };
2148    epub_file(
2149        zip,
2150        "OEBPS/package.opf",
2151        &format!(
2152            "<?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>",
2153            escape_html(identifier),
2154            escape_html(title),
2155            escape_html(language)
2156        ),
2157        compressed,
2158    )?;
2159    Ok(())
2160}
2161
2162/// Build a baseline DOCX archive directly from Rust IR. It deliberately uses
2163/// WordprocessingML rather than a host document library, so source syntax and
2164/// document meaning remain inside the core.
2165pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
2166    render_docx_document(&parse_document(source))
2167}
2168
2169/// Build a baseline DOCX archive from a parsed document.
2170pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
2171    let cursor = Cursor::new(Vec::new());
2172    let mut zip = ZipWriter::new(cursor);
2173    write_docx_document(document, &mut zip)?;
2174    zip.finish()
2175        .map(|cursor| cursor.into_inner())
2176        .map_err(|error| error.to_string())
2177}
2178
2179fn write_docx_document<W: Write + Seek>(
2180    document: &Document,
2181    zip: &mut ZipWriter<W>,
2182) -> Result<(), String> {
2183    let title = document
2184        .frontmatter
2185        .as_ref()
2186        .and_then(|frontmatter| {
2187            frontmatter
2188                .entries
2189                .iter()
2190                .find(|entry| entry.key == "title")
2191        })
2192        .and_then(|entry| entry.value.as_str())
2193        .unwrap_or("Untitled");
2194    let mut content = String::new();
2195    for node in &document.children {
2196        if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2197            content.push('\x0C');
2198            content.push('\n');
2199        } else {
2200            render_text_node(node, &mut content);
2201            if !content.ends_with('\n') {
2202                content.push('\n');
2203            }
2204        }
2205    }
2206    let paragraphs = content
2207        .split('\n')
2208        .map(|line| {
2209            if line.contains('\x0C') {
2210                "<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>".to_owned()
2211            } else {
2212                format!(
2213                    "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
2214                    escape_xml(line)
2215                )
2216            }
2217        })
2218        .collect::<String>();
2219    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2220    docx_file(
2221        zip,
2222        "[Content_Types].xml",
2223        "<?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>",
2224        options,
2225    )?;
2226    docx_file(
2227        zip,
2228        "_rels/.rels",
2229        "<?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>",
2230        options,
2231    )?;
2232    docx_file(
2233        zip,
2234        "docProps/core.xml",
2235        &format!(
2236            "<?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>",
2237            escape_xml(title)
2238        ),
2239        options,
2240    )?;
2241    docx_file(
2242        zip,
2243        "word/document.xml",
2244        &format!(
2245            "<?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>"
2246        ),
2247        options,
2248    )?;
2249    Ok(())
2250}
2251
2252/// Native configuration for Chromium PDF layout. WebAssembly deliberately
2253/// cannot expose this operation because it cannot launch a local process.
2254#[derive(Debug, Clone, Default)]
2255pub struct PdfOptions {
2256    pub chromium_path: Option<PathBuf>,
2257}
2258
2259/// Render PDF by asking a locally installed Chromium-compatible browser to
2260/// lay out Rust-generated HTML. Chromium never receives MDI source.
2261pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
2262    let chromium = options
2263        .chromium_path
2264        .clone()
2265        .or_else(find_chromium)
2266        .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
2267    let nonce = SystemTime::now()
2268        .duration_since(UNIX_EPOCH)
2269        .map_err(|error| error.to_string())?
2270        .as_nanos();
2271    let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
2272    fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
2273    let html_path = directory.join("document.html");
2274    let pdf_path = directory.join("document.pdf");
2275    let result = (|| {
2276        fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
2277        let output = Command::new(&chromium)
2278            .arg("--headless=new")
2279            .arg("--disable-gpu")
2280            .arg("--no-pdf-header-footer")
2281            .arg(format!("--print-to-pdf={}", pdf_path.display()))
2282            .arg(format!("file://{}", html_path.display()))
2283            .output()
2284            .map_err(|error| {
2285                format!(
2286                    "failed to start Chromium at {}: {error}",
2287                    chromium.display()
2288                )
2289            })?;
2290        if !output.status.success() {
2291            return Err(format!(
2292                "Chromium PDF rendering failed: {}",
2293                String::from_utf8_lossy(&output.stderr)
2294            ));
2295        }
2296        fs::read(&pdf_path).map_err(|error| error.to_string())
2297    })();
2298    let _ = fs::remove_dir_all(&directory);
2299    result
2300}
2301
2302/// Locate common native Chromium installations. Production callers should
2303/// prefer the explicit `PdfOptions.chromium_path` for deterministic deploys.
2304pub fn find_chromium() -> Option<PathBuf> {
2305    let candidates = [
2306        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
2307        "/Applications/Chromium.app/Contents/MacOS/Chromium",
2308        "/usr/bin/google-chrome",
2309        "/usr/bin/chromium",
2310        "/usr/bin/chromium-browser",
2311    ];
2312    candidates
2313        .iter()
2314        .map(Path::new)
2315        .find(|path| path.is_file())
2316        .map(Path::to_path_buf)
2317}
2318
2319fn docx_file<W: Write + Seek>(
2320    zip: &mut ZipWriter<W>,
2321    path: &str,
2322    content: &str,
2323    options: SimpleFileOptions,
2324) -> Result<(), String> {
2325    zip.start_file(path, options)
2326        .map_err(|error| error.to_string())?;
2327    zip.write_all(content.as_bytes())
2328        .map_err(|error| error.to_string())
2329}
2330fn escape_xml(value: &str) -> String {
2331    value
2332        .replace('&', "&amp;")
2333        .replace('<', "&lt;")
2334        .replace('>', "&gt;")
2335        .replace('"', "&quot;")
2336        .replace('\'', "&apos;")
2337}
2338
2339struct EpubChapter {
2340    title: String,
2341    html: String,
2342}
2343fn epub_chapters(document: &Document) -> Vec<EpubChapter> {
2344    let mut chapters = vec![EpubChapter {
2345        title: String::new(),
2346        html: String::new(),
2347    }];
2348    for node in &document.children {
2349        if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2350            if !chapters
2351                .last()
2352                .is_some_and(|chapter| chapter.html.is_empty())
2353            {
2354                chapters.push(EpubChapter {
2355                    title: String::new(),
2356                    html: String::new(),
2357                });
2358            }
2359            continue;
2360        }
2361        if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2362            && node.get("depth").and_then(serde_json::Value::as_u64) == Some(1)
2363            && !chapters
2364                .last()
2365                .is_some_and(|chapter| chapter.html.is_empty())
2366        {
2367            chapters.push(EpubChapter {
2368                title: String::new(),
2369                html: String::new(),
2370            });
2371        }
2372        let chapter = chapters.last_mut().expect("one chapter exists");
2373        if chapter.title.is_empty()
2374            && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2375        {
2376            chapter.title = plain_node_text(node);
2377        }
2378        render_html_node(node, &mut chapter.html);
2379    }
2380    let chapters: Vec<_> = chapters
2381        .into_iter()
2382        .filter(|chapter| !chapter.html.is_empty())
2383        .collect();
2384    if chapters.is_empty() {
2385        vec![EpubChapter {
2386            title: String::new(),
2387            html: String::new(),
2388        }]
2389    } else {
2390        chapters
2391    }
2392}
2393fn plain_node_text(node: &serde_json::Value) -> String {
2394    let mut text = String::new();
2395    render_text_children(node, &mut text);
2396    text
2397}
2398fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
2399    format!(
2400        "<?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>",
2401        escape_html(language),
2402        escape_html(language),
2403        escape_html(title),
2404        body.replace("<br>", "<br/>").replace("<hr>", "<hr/>")
2405    )
2406}
2407fn epub_file<W: Write + Seek>(
2408    zip: &mut ZipWriter<W>,
2409    path: &str,
2410    content: &str,
2411    options: SimpleFileOptions,
2412) -> Result<(), String> {
2413    zip.start_file(path, options)
2414        .map_err(|error| error.to_string())?;
2415    zip.write_all(content.as_bytes())
2416        .map_err(|error| error.to_string())
2417}
2418
2419fn render_html_node(node: &serde_json::Value, out: &mut String) {
2420    let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
2421        return;
2422    };
2423    let children = |out: &mut String| render_html_children(node, out);
2424    match kind {
2425        "text" => out.push_str(&escape_html(
2426            node.get("value")
2427                .and_then(serde_json::Value::as_str)
2428                .unwrap_or_default(),
2429        )),
2430        "root" => children(out),
2431        "paragraph" => {
2432            let class = if node.get("indent").is_some() {
2433                " class=\"mdi-indent\""
2434            } else if node.get("bottom").is_some() {
2435                " class=\"mdi-bottom\""
2436            } else {
2437                ""
2438            };
2439            let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
2440            {
2441                format!(" style=\"--mdi-indent:{amount};\"")
2442            } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2443                format!(" style=\"--mdi-shift:{amount};\"")
2444            } else {
2445                String::new()
2446            };
2447            out.push_str("<p");
2448            out.push_str(class);
2449            out.push_str(&style);
2450            out.push('>');
2451            children(out);
2452            out.push_str("</p>");
2453        }
2454        "heading" => {
2455            let depth = node
2456                .get("depth")
2457                .and_then(serde_json::Value::as_u64)
2458                .filter(|depth| (1..=6).contains(depth))
2459                .unwrap_or(1);
2460            out.push_str(&format!("<h{depth}>"));
2461            children(out);
2462            out.push_str(&format!("</h{depth}>"));
2463        }
2464        "emphasis" => wrapped(out, "em", children),
2465        "strong" => wrapped(out, "strong", children),
2466        "delete" => wrapped(out, "del", children),
2467        "blockquote" => wrapped(out, "blockquote", children),
2468        "list" => {
2469            let ordered = node
2470                .get("ordered")
2471                .and_then(serde_json::Value::as_bool)
2472                .unwrap_or(false);
2473            let tag = if ordered { "ol" } else { "ul" };
2474            let start = node
2475                .get("start")
2476                .and_then(serde_json::Value::as_u64)
2477                .filter(|start| *start != 1)
2478                .map(|start| format!(" start=\"{start}\""))
2479                .unwrap_or_default();
2480            out.push('<');
2481            out.push_str(tag);
2482            out.push_str(&start);
2483            out.push('>');
2484            children(out);
2485            out.push_str("</");
2486            out.push_str(tag);
2487            out.push('>');
2488        }
2489        "listItem" => wrapped(out, "li", children),
2490        "thematicBreak" => out.push_str("<hr>"),
2491        "break" => out.push_str("<br>"),
2492        "inlineCode" => {
2493            out.push_str("<code>");
2494            out.push_str(&escape_html(
2495                node.get("value")
2496                    .and_then(serde_json::Value::as_str)
2497                    .unwrap_or_default(),
2498            ));
2499            out.push_str("</code>");
2500        }
2501        "code" => {
2502            out.push_str("<pre><code");
2503            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2504                out.push_str(" class=\"language-");
2505                out.push_str(&escape_html(lang));
2506                out.push('"');
2507            }
2508            out.push('>');
2509            out.push_str(&escape_html(
2510                node.get("value")
2511                    .and_then(serde_json::Value::as_str)
2512                    .unwrap_or_default(),
2513            ));
2514            out.push_str("</code></pre>");
2515        }
2516        "link" => {
2517            out.push_str("<a href=\"");
2518            out.push_str(&escape_html(
2519                node.get("url")
2520                    .and_then(serde_json::Value::as_str)
2521                    .unwrap_or_default(),
2522            ));
2523            out.push('"');
2524            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2525                out.push_str(" title=\"");
2526                out.push_str(&escape_html(title));
2527                out.push('"');
2528            }
2529            out.push('>');
2530            children(out);
2531            out.push_str("</a>");
2532        }
2533        "image" => {
2534            out.push_str("<img src=\"");
2535            out.push_str(&escape_html(
2536                node.get("url")
2537                    .and_then(serde_json::Value::as_str)
2538                    .unwrap_or_default(),
2539            ));
2540            out.push_str("\" alt=\"");
2541            out.push_str(&escape_html(
2542                node.get("alt")
2543                    .and_then(serde_json::Value::as_str)
2544                    .unwrap_or_default(),
2545            ));
2546            out.push_str("\">");
2547        }
2548        "table" => wrapped(out, "table", children),
2549        "tableRow" => wrapped(out, "tr", children),
2550        "tableCell" => wrapped(out, "td", children),
2551        "footnoteReference" => {
2552            let label = node
2553                .get("label")
2554                .and_then(serde_json::Value::as_str)
2555                .unwrap_or_default();
2556            out.push_str("<sup class=\"footnote-ref\">");
2557            out.push_str(&escape_html(label));
2558            out.push_str("</sup>");
2559        }
2560        "footnoteDefinition" | "definition" => {}
2561        "html" => out.push_str(&escape_html(
2562            node.get("value")
2563                .and_then(serde_json::Value::as_str)
2564                .unwrap_or_default(),
2565        )),
2566        "ruby" => render_ruby(node, out),
2567        "tcy" => {
2568            out.push_str("<span class=\"mdi-tcy\">");
2569            out.push_str(&escape_html(
2570                node.get("value")
2571                    .and_then(serde_json::Value::as_str)
2572                    .unwrap_or_default(),
2573            ));
2574            out.push_str("</span>");
2575        }
2576        "em" => {
2577            let mark = node
2578                .get("mark")
2579                .and_then(serde_json::Value::as_str)
2580                .unwrap_or("﹅");
2581            out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:&quot;");
2582            out.push_str(&escape_css_string(mark));
2583            out.push_str("&quot;;\">");
2584            children(out);
2585            out.push_str("</span>");
2586        }
2587        "noBreak" => {
2588            out.push_str("<span class=\"mdi-nobr\">");
2589            children(out);
2590            out.push_str("</span>");
2591        }
2592        "warichu" => {
2593            out.push_str("<span class=\"mdi-warichu\">");
2594            children(out);
2595            out.push_str("</span>");
2596        }
2597        "kern" => {
2598            out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
2599            out.push_str(&escape_html(
2600                node.get("amount")
2601                    .and_then(serde_json::Value::as_str)
2602                    .unwrap_or_default(),
2603            ));
2604            out.push_str(";\">");
2605            children(out);
2606            out.push_str("</span>");
2607        }
2608        "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
2609        "pagebreak" => {
2610            out.push_str("<div class=\"mdi-pagebreak");
2611            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2612                out.push_str(" mdi-pagebreak-");
2613                out.push_str(&escape_html(variant));
2614            }
2615            out.push_str("\" role=\"presentation\"></div>");
2616        }
2617        _ => children(out),
2618    }
2619}
2620
2621fn render_html_children(node: &serde_json::Value, out: &mut String) {
2622    if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
2623        for child in children {
2624            render_html_node(child, out);
2625        }
2626    }
2627}
2628
2629fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
2630    out.push('<');
2631    out.push_str(tag);
2632    out.push('>');
2633    children(out);
2634    out.push_str("</");
2635    out.push_str(tag);
2636    out.push('>');
2637}
2638
2639fn render_ruby(node: &serde_json::Value, out: &mut String) {
2640    let base = node
2641        .get("base")
2642        .and_then(serde_json::Value::as_str)
2643        .unwrap_or_default();
2644    let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
2645    out.push_str("<ruby class=\"mdi-ruby\">");
2646    if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
2647        for (base, reading) in base.graphemes(true).zip(parts) {
2648            out.push_str(&escape_html(base));
2649            render_ruby_reading(reading.as_str().unwrap_or_default(), out);
2650        }
2651    } else {
2652        out.push_str(&escape_html(base));
2653        render_ruby_reading(
2654            reading
2655                .and_then(serde_json::Value::as_str)
2656                .unwrap_or_default(),
2657            out,
2658        );
2659    }
2660    out.push_str("</ruby>");
2661}
2662
2663fn render_ruby_reading(reading: &str, out: &mut String) {
2664    out.push_str("<rp>(</rp><rt>");
2665    out.push_str(&escape_html(reading));
2666    out.push_str("</rt><rp>)</rp>");
2667}
2668
2669fn escape_html(value: &str) -> String {
2670    value
2671        .replace('&', "&amp;")
2672        .replace('<', "&lt;")
2673        .replace('>', "&gt;")
2674        .replace('"', "&quot;")
2675}
2676fn escape_css_string(value: &str) -> String {
2677    value.replace('\\', "\\\\").replace('"', "\\\"")
2678}
2679
2680/// Parse MDI inline syntax while keeping all other text literal.
2681pub fn parse_inlines(source: &str) -> Vec<Inline> {
2682    parse_inline_parts(source)
2683        .into_iter()
2684        .map(|(inline, _, _)| inline)
2685        .collect()
2686}
2687
2688/// Parse MDI inline syntax and retain each node's raw byte range relative to
2689/// `source`.  Escaped text deliberately retains the range of its spelling in
2690/// the source even when its rendered value is shorter.
2691fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
2692    let mut out = Vec::new();
2693    let mut text = String::new();
2694    let mut text_start = 0;
2695    let mut index = 0;
2696
2697    while index < source.len() {
2698        let rest = &source[index..];
2699        if rest.starts_with('\\') {
2700            let mut chars = rest.chars();
2701            let slash = chars.next().expect("prefix was checked");
2702            let Some(next) = chars.next() else {
2703                text.push(slash);
2704                index += slash.len_utf8();
2705                continue;
2706            };
2707            if is_escapable(next) {
2708                text.push(next);
2709            } else {
2710                text.push(slash);
2711                text.push(next);
2712            }
2713            index += slash.len_utf8() + next.len_utf8();
2714            continue;
2715        }
2716        if let Some((inline, consumed)) = ruby(rest) {
2717            push_inline_text(&mut out, &mut text, text_start, index);
2718            out.push((inline, index, index + consumed));
2719            index += consumed;
2720            text_start = index;
2721            continue;
2722        }
2723        if let Some((inline, consumed)) = tcy(rest) {
2724            push_inline_text(&mut out, &mut text, text_start, index);
2725            out.push((inline, index, index + consumed));
2726            index += consumed;
2727            text_start = index;
2728            continue;
2729        }
2730        if let Some((inline, consumed)) = boten(rest) {
2731            push_inline_text(&mut out, &mut text, text_start, index);
2732            out.push((inline, index, index + consumed));
2733            index += consumed;
2734            text_start = index;
2735            continue;
2736        }
2737        if let Some((inline, consumed)) = bracket_macro(rest) {
2738            push_inline_text(&mut out, &mut text, text_start, index);
2739            out.push((inline, index, index + consumed));
2740            index += consumed;
2741            text_start = index;
2742            continue;
2743        }
2744        let character = rest.chars().next().expect("index is in bounds");
2745        text.push(character);
2746        index += character.len_utf8();
2747    }
2748    push_inline_text(&mut out, &mut text, text_start, index);
2749    out
2750}
2751
2752#[derive(Clone)]
2753enum PendingBlock {
2754    Indent { amount: u32, source: String },
2755    Bottom { amount: u32, source: String },
2756}
2757
2758fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
2759    let (indent, bottom) = match pending {
2760        Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
2761        Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
2762        None => (None, None),
2763    };
2764    MdiBlock::Paragraph {
2765        inlines: parse_inlines(line),
2766        indent,
2767        bottom,
2768    }
2769}
2770
2771fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
2772    if let Some(marker) = pending.take() {
2773        // As in the micromark adapter, an alignment macro without a following
2774        // eligible block remains literal source.
2775        let source = match marker {
2776            PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
2777        };
2778        blocks.push(paragraph(&source, None));
2779    }
2780}
2781
2782fn is_blank_marker(line: &str) -> bool {
2783    let value = line.trim_end_matches([' ', '\t']);
2784    value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
2785}
2786
2787fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
2788    match line.trim() {
2789        "[[pagebreak]]" => Some(None),
2790        "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
2791        "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
2792        _ => None,
2793    }
2794}
2795
2796fn pending_block(line: &str) -> Option<PendingBlock> {
2797    let value = line.trim();
2798    if value == "[[bottom]]" {
2799        return Some(PendingBlock::Bottom {
2800            amount: 0,
2801            source: value.to_owned(),
2802        });
2803    }
2804    let (kind, amount) = value
2805        .strip_prefix("[[")?
2806        .strip_suffix("]]")?
2807        .split_once(':')?;
2808    if amount.is_empty()
2809        || amount.starts_with('0')
2810        || !amount.bytes().all(|byte| byte.is_ascii_digit())
2811    {
2812        return None;
2813    }
2814    let amount = amount.parse().ok()?;
2815    match kind {
2816        "indent" => Some(PendingBlock::Indent {
2817            amount,
2818            source: value.to_owned(),
2819        }),
2820        "bottom" => Some(PendingBlock::Bottom {
2821            amount,
2822            source: value.to_owned(),
2823        }),
2824        _ => None,
2825    }
2826}
2827
2828fn ruby(value: &str) -> Option<(Inline, usize)> {
2829    if !value.starts_with('{') {
2830        return None;
2831    }
2832    let end = close_unescaped(value, 1, '}')?;
2833    let body = &value[1..end];
2834    let separator = bare_index(body, '|')?;
2835    let base = unescape_ruby(&body[..separator]);
2836    let raw_ruby = &body[separator + 1..];
2837    let ruby = split_ruby(&base, raw_ruby);
2838    Some((Inline::Ruby { base, ruby }, end + 1))
2839}
2840
2841fn split_ruby(base: &str, raw: &str) -> RubyReading {
2842    let segments = split_unescaped(raw, '.');
2843    if segments.len() == 1 {
2844        return RubyReading::Group(unescape_ruby(raw));
2845    }
2846    let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
2847    if segments.len() == base.graphemes(true).count()
2848        && segments.iter().all(|part| !part.is_empty())
2849    {
2850        RubyReading::Split(segments)
2851    } else {
2852        RubyReading::Group(segments.concat())
2853    }
2854}
2855
2856fn tcy(value: &str) -> Option<(Inline, usize)> {
2857    if !value.starts_with('^') {
2858        return None;
2859    }
2860    let closing = value[1..].find('^')? + 1;
2861    let body = &value[1..closing];
2862    if body.is_empty()
2863        || body.chars().count() > 6
2864        || !body
2865            .chars()
2866            .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
2867    {
2868        return None;
2869    }
2870    Some((Inline::Tcy(body.to_owned()), closing + 1))
2871}
2872
2873fn boten(value: &str) -> Option<(Inline, usize)> {
2874    let prefix = "《《";
2875    if !value.starts_with(prefix) {
2876        return None;
2877    }
2878    let end = close_boten_alias(value)?;
2879    let body = &value[prefix.len()..end];
2880    if body.is_empty()
2881        || body.contains('\n')
2882        || contains_unescaped(body, '《')
2883        || contains_unescaped(body, '》')
2884    {
2885        return None;
2886    }
2887    Some((
2888        Inline::Em {
2889            mark: "﹅".to_owned(),
2890            children: vec![Inline::Text(unescape_mdi(body))],
2891        },
2892        end + "》》".len(),
2893    ))
2894}
2895
2896/// Find the closing `》》` of the supported boten alias.  As with every other
2897/// MDI delimiter, an escaped `》` is content and cannot close the alias.
2898fn close_boten_alias(value: &str) -> Option<usize> {
2899    let mut index = "《《".len();
2900    while index < value.len() {
2901        let rest = &value[index..];
2902        if rest.starts_with('\\') {
2903            let next = rest.chars().nth(1)?;
2904            index += 1 + next.len_utf8();
2905        } else if rest.starts_with("》》") {
2906            return Some(index);
2907        } else {
2908            index += rest.chars().next()?.len_utf8();
2909        }
2910    }
2911    None
2912}
2913
2914fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
2915    if !value.starts_with("[[") {
2916        return None;
2917    }
2918    if value.starts_with("[[br]]") {
2919        return Some((Inline::Break, "[[br]]".len()));
2920    }
2921    let end = close_macro(value)?;
2922    let body = &value[2..end];
2923    let (name, payload) = body.split_once(':')?;
2924    let children = |content: &str| parse_inlines(content);
2925    let inline = match name {
2926        "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
2927        "warichu" => Inline::Warichu(children(payload)),
2928        "kern" => {
2929            let (amount, content) = payload.split_once(':')?;
2930            if !valid_kern(amount) {
2931                return None;
2932            }
2933            Inline::Kern {
2934                amount: unescape_mdi(amount),
2935                children: children(content),
2936            }
2937        }
2938        "em" => {
2939            let (mark, content) = match bare_index(payload, ':') {
2940                Some(index) => {
2941                    let candidate = unescape_mdi(&payload[..index]);
2942                    if candidate.graphemes(true).count() == 1
2943                        && !candidate
2944                            .chars()
2945                            .any(|c| c.is_whitespace() || c.is_control())
2946                    {
2947                        (candidate, &payload[index + 1..])
2948                    } else {
2949                        ("﹅".to_owned(), payload)
2950                    }
2951                }
2952                None => ("﹅".to_owned(), payload),
2953            };
2954            Inline::Em {
2955                mark,
2956                children: children(content),
2957            }
2958        }
2959        _ => return None,
2960    };
2961    Some((inline, end + 2))
2962}
2963
2964fn close_macro(value: &str) -> Option<usize> {
2965    let mut index = 2;
2966    let mut depth = 1;
2967    while index < value.len() {
2968        let rest = &value[index..];
2969        if rest.starts_with('\\') {
2970            index += rest.chars().nth(1)?.len_utf8() + 1;
2971        } else if rest.starts_with("[[") {
2972            depth += 1;
2973            index += 2;
2974        } else if rest.starts_with("]]") {
2975            depth -= 1;
2976            if depth == 0 {
2977                return Some(index);
2978            }
2979            index += 2;
2980        } else {
2981            index += rest.chars().next()?.len_utf8();
2982        }
2983    }
2984    None
2985}
2986
2987fn valid_kern(value: &str) -> bool {
2988    let value = value.strip_suffix("em").unwrap_or("");
2989    let value = value.strip_prefix(['+', '-']).unwrap_or(value);
2990    let mut parts = value.split('.');
2991    let whole = parts.next().unwrap_or("");
2992    let fraction = parts.next();
2993    parts.next().is_none()
2994        && !whole.is_empty()
2995        && whole.bytes().all(|b| b.is_ascii_digit())
2996        && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
2997}
2998
2999fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
3000    let mut escaped = false;
3001    for (index, character) in value[start..].char_indices() {
3002        if escaped {
3003            escaped = false;
3004            continue;
3005        }
3006        if character == '\\' {
3007            escaped = true;
3008            continue;
3009        }
3010        if character == needle {
3011            return Some(start + index);
3012        }
3013        if character == '\n' {
3014            return None;
3015        }
3016    }
3017    None
3018}
3019
3020fn bare_index(value: &str, needle: char) -> Option<usize> {
3021    let mut escaped = false;
3022    for (index, character) in value.char_indices() {
3023        if escaped {
3024            escaped = false;
3025            continue;
3026        }
3027        if character == '\\' {
3028            escaped = true;
3029            continue;
3030        }
3031        if character == needle {
3032            return Some(index);
3033        }
3034    }
3035    None
3036}
3037
3038fn contains_unescaped(value: &str, needle: char) -> bool {
3039    bare_index(value, needle).is_some()
3040}
3041
3042fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
3043    let mut parts = Vec::new();
3044    let mut start = 0;
3045    let mut escaped = false;
3046    for (index, character) in value.char_indices() {
3047        if escaped {
3048            escaped = false;
3049            continue;
3050        }
3051        if character == '\\' {
3052            escaped = true;
3053            continue;
3054        }
3055        if character == separator {
3056            parts.push(&value[start..index]);
3057            start = index + character.len_utf8();
3058        }
3059    }
3060    parts.push(&value[start..]);
3061    parts
3062}
3063
3064/// SYNTAX.md §13's delimiter set, plus `\\` itself — CommonMark treats
3065/// backslash as ordinary escapable ASCII punctuation, and JS's
3066/// `unescapeMdi`/`unescapeRubyText` follow suit (`\\` -> `\`), so the Rust
3067/// core must too or `\\` is left as two literal characters instead of one.
3068const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
3069const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
3070
3071fn unescape_mdi(value: &str) -> String {
3072    unescape(value, ESCAPABLE_MDI)
3073}
3074
3075fn unescape_ruby(value: &str) -> String {
3076    unescape(value, ESCAPABLE_RUBY)
3077}
3078
3079fn unescape(value: &str, allowed: &str) -> String {
3080    let mut out = String::new();
3081    let mut chars = value.chars();
3082    while let Some(character) = chars.next() {
3083        if character == '\\' {
3084            if let Some(next) = chars.next() {
3085                if allowed.contains(next) {
3086                    out.push(next);
3087                } else {
3088                    out.push(character);
3089                    out.push(next);
3090                }
3091            } else {
3092                out.push(character);
3093            }
3094        } else {
3095            out.push(character);
3096        }
3097    }
3098    out
3099}
3100
3101fn is_escapable(character: char) -> bool {
3102    ESCAPABLE_MDI.contains(character)
3103}
3104
3105fn push_inline_text(
3106    out: &mut Vec<(Inline, usize, usize)>,
3107    text: &mut String,
3108    start: usize,
3109    end: usize,
3110) {
3111    if !text.is_empty() {
3112        out.push((Inline::Text(std::mem::take(text)), start, end));
3113    }
3114}
3115
3116/// A single `[[indent:N]]` / `[[bottom]]` / `[[pagebreak…]]` token's source,
3117/// classified the same way as `mdast-util-mdi`'s `parseBlockMacro` (from
3118/// which this was ported): trims the token source once, then matches the
3119/// fixed pagebreak/bottom spellings before falling back to the generic
3120/// `name:digits` form. Kept separate from `pending_block`/`pagebreak`
3121/// above, which classify whole *lines* mid-scan rather than one already
3122/// isolated token.
3123#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3124enum BlockMacroClass {
3125    Indent(u32),
3126    Bottom(u32),
3127    Pagebreak(Option<PagebreakVariant>),
3128    Literal,
3129}
3130
3131#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3132fn classify_block_macro(source: &str) -> BlockMacroClass {
3133    let value = source.trim();
3134    match value {
3135        "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
3136        "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
3137        "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
3138        "[[bottom]]" => return BlockMacroClass::Bottom(0),
3139        _ => {}
3140    }
3141    if let Some((kind, amount)) = value
3142        .strip_prefix("[[")
3143        .and_then(|rest| rest.strip_suffix("]]"))
3144        .and_then(|inner| inner.split_once(':'))
3145    {
3146        let valid_amount = !amount.is_empty()
3147            && !amount.starts_with('0')
3148            && amount.bytes().all(|b| b.is_ascii_digit());
3149        if valid_amount && let Ok(amount) = amount.parse::<u32>() {
3150            match kind {
3151                "indent" => return BlockMacroClass::Indent(amount),
3152                "bottom" => return BlockMacroClass::Bottom(amount),
3153                _ => {}
3154            }
3155        }
3156    }
3157    BlockMacroClass::Literal
3158}
3159
3160/// wasm-bindgen bindings for the complete JavaScript interface and legacy
3161/// semantic helpers retained for compatibility tests.
3162#[cfg(feature = "wasm")]
3163mod wasm {
3164    use super::{
3165        BlockMacroClass, PagebreakVariant, RubyReading, TextFormat, classify_block_macro,
3166        parse_json, render_docx, render_epub, render_html, render_text, render_text_format,
3167        serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
3168    };
3169    use wasm_bindgen::prelude::*;
3170
3171    /// Parse with Rust and return the versioned MDI IR as JSON.
3172    ///
3173    /// The JavaScript package parses this string and performs no syntax work.
3174    #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
3175    pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
3176        parse_json(source)
3177    }
3178
3179    /// Render source through the Rust parser and Rust HTML renderer.
3180    #[wasm_bindgen(js_name = renderHtml)]
3181    pub fn wasm_render_html(source: &str) -> String {
3182        render_html(source)
3183    }
3184
3185    /// Normalize source through the Rust parser and canonical serializer.
3186    #[wasm_bindgen(js_name = serializeMdi)]
3187    pub fn wasm_serialize_mdi(source: &str) -> String {
3188        serialize_mdi(source)
3189    }
3190
3191    /// Render plain text directly from the Rust IR.
3192    #[wasm_bindgen(js_name = renderText)]
3193    pub fn wasm_render_text(source: &str) -> String {
3194        render_text(source)
3195    }
3196
3197    /// Render a named text export convention through the Rust core.
3198    #[wasm_bindgen(js_name = renderTextFormat)]
3199    pub fn wasm_render_text_format(
3200        source: &str,
3201        format: &str,
3202        indent_prefix: &str,
3203    ) -> Result<String, JsValue> {
3204        let format = TextFormat::parse(format)
3205            .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
3206        Ok(render_text_format(source, format, indent_prefix))
3207    }
3208
3209    /// Build a baseline EPUB 3 archive entirely in Rust.
3210    #[wasm_bindgen(js_name = renderEpub)]
3211    pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
3212        render_epub(source)
3213            .map(Vec::into_boxed_slice)
3214            .map_err(|message| JsValue::from_str(&message))
3215    }
3216
3217    /// Build a baseline DOCX archive entirely in Rust.
3218    #[wasm_bindgen(js_name = renderDocx)]
3219    pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
3220        render_docx(source)
3221            .map(Vec::into_boxed_slice)
3222            .map_err(|message| JsValue::from_str(&message))
3223    }
3224
3225    #[wasm_bindgen(js_name = unescapeMdi)]
3226    pub fn wasm_unescape_mdi(value: &str) -> String {
3227        unescape_mdi(value)
3228    }
3229
3230    #[wasm_bindgen(js_name = unescapeRubyText)]
3231    pub fn wasm_unescape_ruby(value: &str) -> String {
3232        unescape_ruby(value)
3233    }
3234
3235    /// Mirrors `resolveRuby(base, rawRuby): string | string[]`.
3236    #[wasm_bindgen(js_name = resolveRuby)]
3237    pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
3238        match split_ruby(base, raw_ruby) {
3239            RubyReading::Group(value) => JsValue::from_str(&value),
3240            RubyReading::Split(parts) => {
3241                let array = js_sys::Array::new();
3242                for part in parts {
3243                    array.push(&JsValue::from_str(&part));
3244                }
3245                array.into()
3246            }
3247        }
3248    }
3249
3250    #[wasm_bindgen(js_name = blockMacroKind)]
3251    pub fn wasm_block_macro_kind(source: &str) -> String {
3252        match classify_block_macro(source) {
3253            BlockMacroClass::Indent(_) => "indent",
3254            BlockMacroClass::Bottom(_) => "bottom",
3255            BlockMacroClass::Pagebreak(_) => "pagebreak",
3256            BlockMacroClass::Literal => "literal",
3257        }
3258        .to_owned()
3259    }
3260
3261    /// -1 when the token has no amount (pagebreak / literal).
3262    #[wasm_bindgen(js_name = blockMacroAmount)]
3263    pub fn wasm_block_macro_amount(source: &str) -> i32 {
3264        match classify_block_macro(source) {
3265            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
3266            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
3267        }
3268    }
3269
3270    /// `"left"` / `"right"` / `""` (no variant, including non-pagebreak kinds).
3271    #[wasm_bindgen(js_name = blockMacroVariant)]
3272    pub fn wasm_block_macro_variant(source: &str) -> String {
3273        match classify_block_macro(source) {
3274            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
3275            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
3276            _ => "",
3277        }
3278        .to_owned()
3279    }
3280}
3281
3282#[cfg(test)]
3283mod tests {
3284    use super::*;
3285    use std::io::{Error, ErrorKind, Read, SeekFrom};
3286    use zip::ZipArchive;
3287
3288    struct FailAfterWrites {
3289        inner: Cursor<Vec<u8>>,
3290        remaining: usize,
3291    }
3292
3293    impl Write for FailAfterWrites {
3294        fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
3295            if self.remaining == 0 {
3296                return Err(Error::new(
3297                    ErrorKind::Other,
3298                    "injected archive write failure",
3299                ));
3300            }
3301            self.remaining -= 1;
3302            self.inner.write(buffer)
3303        }
3304
3305        fn flush(&mut self) -> std::io::Result<()> {
3306            self.inner.flush()
3307        }
3308    }
3309
3310    impl Seek for FailAfterWrites {
3311        fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
3312            self.inner.seek(position)
3313        }
3314    }
3315
3316    fn assert_valid_spans(value: &serde_json::Value, source: &str) {
3317        let Some(object) = value.as_object() else {
3318            return;
3319        };
3320        if let Some(span) = object.get("span") {
3321            let start = span
3322                .get("startByte")
3323                .and_then(serde_json::Value::as_u64)
3324                .expect("span has startByte") as usize;
3325            let end = span
3326                .get("endByte")
3327                .and_then(serde_json::Value::as_u64)
3328                .expect("span has endByte") as usize;
3329            assert!(start <= end, "span start must not exceed end: {span}");
3330            assert!(
3331                end <= source.len(),
3332                "span must be inside its source: {span}"
3333            );
3334            assert!(
3335                source.is_char_boundary(start),
3336                "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
3337            );
3338            let remainder = &source[start..];
3339            assert!(
3340                source.is_char_boundary(end),
3341                "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
3342                remainder
3343            );
3344        }
3345        if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
3346            for child in children {
3347                assert_valid_spans(child, source);
3348            }
3349        }
3350    }
3351
3352    fn generated_source(mut state: u64) -> String {
3353        // A deterministic adversarial corpus: every fragment is individually
3354        // valid UTF-8, while their arbitrary adjacency exercises delimiter,
3355        // Markdown, front-matter, and MDI recovery boundaries.
3356        if state == 0 {
3357            return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
3358        }
3359        const FRAGMENTS: &[&str] = &[
3360            "東京",
3361            "𠮟る",
3362            "👨‍👩‍👧",
3363            " ",
3364            "\\n",
3365            "\\\\",
3366            "{",
3367            "}",
3368            "|",
3369            ".",
3370            "^12^",
3371            "^_^",
3372            "{東京|とう.きょう}",
3373            "[[em:強調]]",
3374            "[[no-break:^12^]]",
3375            "[[kern:wide:x]]",
3376            "[[indent:2]]",
3377            "[[pagebreak:left]]",
3378            "《《傍点》》",
3379            "**strong**",
3380            "`^12^`",
3381            "[^n]",
3382            "\n\n",
3383            "| a | b |\n| - | - |\n| 1 | 2 |\n",
3384            "[link](https://example.test/?a=1&b=2)",
3385            "<script>x</script>",
3386            "\\{",
3387            "\\[",
3388        ];
3389        let mut source = String::new();
3390        for _ in 0..32 {
3391            state ^= state << 13;
3392            state ^= state >> 7;
3393            state ^= state << 17;
3394            source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
3395        }
3396        source
3397    }
3398
3399    #[test]
3400    fn parses_ruby_and_split_ruby() {
3401        assert_eq!(
3402            parse_inlines("{東京|とう.きょう}"),
3403            vec![Inline::Ruby {
3404                base: "東京".into(),
3405                ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
3406            }]
3407        );
3408        assert_eq!(
3409            parse_inlines("{東京|.きょう}"),
3410            vec![Inline::Ruby {
3411                base: "東京".into(),
3412                ruby: RubyReading::Group("きょう".into()),
3413            }]
3414        );
3415    }
3416
3417    #[test]
3418    fn parses_nested_macros_and_escapes() {
3419        assert_eq!(
3420            parse_inlines("[[em:●:a[[no-break:b]]]]"),
3421            vec![Inline::Em {
3422                mark: "●".into(),
3423                children: vec![
3424                    Inline::Text("a".into()),
3425                    Inline::NoBreak(vec![Inline::Text("b".into())])
3426                ],
3427            }]
3428        );
3429    }
3430
3431    #[test]
3432    fn parses_tcy_and_boten() {
3433        assert_eq!(
3434            parse_inlines("第^12^話《《重要》》"),
3435            vec![
3436                Inline::Text("第".into()),
3437                Inline::Tcy("12".into()),
3438                Inline::Text("話".into()),
3439                Inline::Em {
3440                    mark: "﹅".into(),
3441                    children: vec![Inline::Text("重要".into())]
3442                },
3443            ]
3444        );
3445    }
3446
3447    #[test]
3448    fn recognises_block_macros() {
3449        assert_eq!(
3450            parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
3451            MdiSyntaxDocument {
3452                blocks: vec![
3453                    MdiBlock::Paragraph {
3454                        inlines: vec![Inline::Text("本文".into())],
3455                        indent: Some(2),
3456                        bottom: None
3457                    },
3458                    MdiBlock::Pagebreak {
3459                        variant: Some(PagebreakVariant::Left)
3460                    },
3461                    MdiBlock::Blank,
3462                ]
3463            }
3464        );
3465    }
3466
3467    #[test]
3468    fn keeps_invalid_syntax_literal() {
3469        assert_eq!(
3470            parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
3471            vec![Inline::Text(
3472                "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
3473            )]
3474        );
3475    }
3476
3477    #[test]
3478    fn recovers_from_malformed_inline_syntax_at_delimiter_boundaries() {
3479        // A malformed construct must stay literal without preventing a later,
3480        // adjacent construct from being recognized.  These cases cover every
3481        // inline delimiter family, including a delimiter at end of input.
3482        for (source, expected) in [
3483            ("{東京|とうきょう", "{東京|とうきょう"),
3484            ("{東京|とうきょう ^12^", "{東京|とうきょう 12"),
3485            ("^1234567^ ^12^", "^1234567^ 12"),
3486            ("[[no-break:]][[em:強調]]", "[[no-break:]]強調"),
3487            ("[[kern:wide:字]][[warichu:注]]", "[[kern:wide:字]]注"),
3488            ("[[em:未閉", "[[em:未閉"),
3489            ("《《未閉", "《《未閉"),
3490            ("{", "{"),
3491            ("^", "^"),
3492            ("[[", "[["),
3493        ] {
3494            let rendered = render_text(source);
3495            assert_eq!(rendered.trim_end(), expected, "source: {source:?}");
3496            assert!(
3497                parse_output(source).diagnostics.is_empty(),
3498                "source: {source:?}"
3499            );
3500        }
3501    }
3502
3503    #[test]
3504    fn honors_escaped_alias_delimiters_without_consuming_them_as_closers() {
3505        // Escaped delimiters never participate in matching.  In particular,
3506        // the first `》` below is content; the following pair closes the alias.
3507        assert_eq!(
3508            parse_inlines("《《a\\》》》"),
3509            vec![Inline::Em {
3510                mark: "﹅".into(),
3511                children: vec![Inline::Text("a》".into())],
3512            }]
3513        );
3514
3515        // Without a non-escaped closing pair, the complete alias remains
3516        // literal instead of partially consuming its escaped content.
3517        assert_eq!(
3518            parse_inlines("《《a\\》》"),
3519            vec![Inline::Text("《《a》》".into())]
3520        );
3521    }
3522
3523    #[test]
3524    fn applies_mdi_escapes_once_before_recognition() {
3525        assert_eq!(
3526            parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
3527            vec![Inline::Text(
3528                "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
3529            )]
3530        );
3531    }
3532
3533    #[test]
3534    fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
3535        assert_eq!(
3536            parse_inlines(r"\\ \n \a \0 \-"),
3537            vec![Inline::Text(r"\ \n \a \0 \-".into())]
3538        );
3539    }
3540
3541    #[test]
3542    fn treats_boten_alias_content_as_plain_text() {
3543        assert_eq!(
3544            parse_inlines(r"《《a\《b》》"),
3545            vec![Inline::Em {
3546                mark: "﹅".into(),
3547                children: vec![Inline::Text("a《b".into())]
3548            }]
3549        );
3550        assert_eq!(
3551            parse_inlines("《《雪》考》"),
3552            vec![Inline::Text("《《雪》考》".into())]
3553        );
3554    }
3555
3556    #[test]
3557    fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
3558        assert_eq!(
3559            parse_inlines("[[em:ab:cd]]"),
3560            vec![Inline::Em {
3561                mark: "﹅".into(),
3562                children: vec![Inline::Text("ab:cd".into())]
3563            }]
3564        );
3565    }
3566
3567    #[test]
3568    fn counts_extended_graphemes_for_split_ruby() {
3569        assert_eq!(
3570            parse_inlines("{𠮟る|しか.る}"),
3571            vec![Inline::Ruby {
3572                base: "𠮟る".into(),
3573                ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
3574            }]
3575        );
3576        assert_eq!(
3577            parse_inlines("{👨‍👩‍👧|かぞく}"),
3578            vec![Inline::Ruby {
3579                base: "👨‍👩‍👧".into(),
3580                ruby: RubyReading::Group("かぞく".into())
3581            }]
3582        );
3583    }
3584
3585    #[test]
3586    fn leaves_unattached_or_stacked_block_macros_literal() {
3587        assert_eq!(
3588            parse_mdi_syntax("[[indent:2]]"),
3589            MdiSyntaxDocument {
3590                blocks: vec![MdiBlock::Paragraph {
3591                    inlines: vec![Inline::Text("[[indent:2]]".into())],
3592                    indent: None,
3593                    bottom: None
3594                }]
3595            }
3596        );
3597        assert_eq!(
3598            parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
3599            MdiSyntaxDocument {
3600                blocks: vec![
3601                    MdiBlock::Paragraph {
3602                        inlines: vec![Inline::Text("[[indent:2]]".into())],
3603                        indent: None,
3604                        bottom: None
3605                    },
3606                    MdiBlock::Paragraph {
3607                        inlines: vec![Inline::Text("[[bottom]]".into())],
3608                        indent: None,
3609                        bottom: None
3610                    },
3611                    MdiBlock::Paragraph {
3612                        inlines: vec![Inline::Text("本文".into())],
3613                        indent: None,
3614                        bottom: None
3615                    }
3616                ]
3617            }
3618        );
3619    }
3620
3621    #[test]
3622    fn serializes_the_versioned_binding_contract() {
3623        let value: serde_json::Value =
3624            serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
3625                .expect("parse output is valid JSON");
3626
3627        assert_eq!(value["irVersion"], "1.0");
3628        assert_eq!(value["syntaxVersion"], "2.0");
3629        assert_eq!(value["capabilities"]["mdi"], true);
3630        assert_eq!(value["capabilities"]["commonMark"], true);
3631        assert_eq!(value["capabilities"]["gfm"], true);
3632        assert_eq!(value["capabilities"]["frontMatter"], true);
3633        assert_eq!(value["capabilities"]["sourceSpans"], true);
3634        assert_eq!(value["diagnostics"], serde_json::json!([]));
3635        assert_eq!(value["document"]["children"][0]["type"], "paragraph");
3636        assert_eq!(
3637            value["document"]["children"][0]["children"][1]["type"],
3638            "tcy"
3639        );
3640        assert_eq!(value["document"]["span"]["endByte"], 43);
3641    }
3642
3643    #[test]
3644    fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
3645        let document = parse_document(
3646            "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
3647        );
3648        assert_eq!(
3649            document.frontmatter.as_ref().unwrap().entries[0].key,
3650            "title"
3651        );
3652        assert_eq!(document.children[0]["type"], "heading");
3653        assert_eq!(document.children[1]["type"], "list");
3654        assert_eq!(document.children[2]["type"], "table");
3655        assert_eq!(document.children[0]["span"]["startByte"], 43);
3656    }
3657
3658    #[test]
3659    fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
3660        let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
3661        assert_eq!(document.children[0]["type"], "blockquote");
3662        assert!(document.children.iter().any(|node| node["type"] == "code"));
3663    }
3664
3665    #[test]
3666    fn lowers_root_flow_markers_without_a_host_markdown_parser() {
3667        let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
3668        assert_eq!(document.children[0]["type"], "paragraph");
3669        assert_eq!(document.children[0]["indent"], 2);
3670        assert_eq!(document.children[1]["type"], "pagebreak");
3671        assert_eq!(document.children[1]["variant"], "right");
3672        assert_eq!(document.children[2]["type"], "blank");
3673    }
3674
3675    #[test]
3676    fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
3677        let document = parse_document("[[em:**重要**]]");
3678        let em = &document.children[0]["children"][0];
3679        assert_eq!(em["type"], "em");
3680        assert_eq!(em["mark"], "﹅");
3681        assert_eq!(em["children"][0]["type"], "strong");
3682        assert_eq!(em["children"][0]["children"][0]["value"], "重要");
3683    }
3684
3685    #[test]
3686    fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
3687        assert!(
3688            markdown_paragraph_children(
3689                "前 [[em:**重要**]] 後",
3690                &serde_json::json!({"startByte": 0, "endByte": 26})
3691            )
3692            .is_some()
3693        );
3694        let document = parse_document("前 [[em:**重要**]] 後");
3695        let children = &document.children[0]["children"];
3696        assert_eq!(children[0]["value"], "前");
3697        assert_eq!(children[1]["value"], " ");
3698        assert_eq!(children[2]["type"], "em");
3699        assert_eq!(children[2]["children"][0]["type"], "strong");
3700        assert_eq!(children[3]["value"], " ");
3701        assert_eq!(children[4]["value"], "後");
3702        assert_eq!(children[2]["span"]["startByte"], 4);
3703        assert_eq!(children[2]["span"]["endByte"], 21);
3704        assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
3705        assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
3706    }
3707
3708    #[test]
3709    fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
3710        let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
3711        let children = &document.children[0]["children"][0]["children"];
3712        assert_eq!(children[0]["type"], "ruby");
3713        assert_eq!(children[1]["type"], "noBreak");
3714        assert_eq!(children[1]["children"][0]["type"], "tcy");
3715    }
3716
3717    #[test]
3718    fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
3719        let document =
3720            parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
3721        let paragraph = &document.children[1];
3722        assert_eq!(paragraph["span"]["startByte"], 7);
3723        assert_eq!(paragraph["children"][0]["type"], "ruby");
3724        assert_eq!(paragraph["children"][0]["base"], "東京");
3725        assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
3726        assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
3727        assert_eq!(paragraph["children"][2]["type"], "em");
3728        assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
3729        assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
3730        assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
3731        assert_eq!(paragraph["children"][4]["identifier"], "n");
3732    }
3733
3734    #[test]
3735    fn renders_a_standalone_html_document_from_rust_ir() {
3736        let html = render_html(
3737            "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
3738        );
3739        assert!(html.starts_with("<!DOCTYPE html>"));
3740        assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
3741        assert!(html.contains("<title>雪女</title>"));
3742        assert!(html.contains("<h1>題</h1>"));
3743        assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
3744        assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
3745    }
3746
3747    #[test]
3748    fn renders_every_documented_mdi_construct_in_vertical_html() {
3749        let vertical = "---\ntitle: 構文\nlang: ja\nwriting-mode: vertical\n---\n\n";
3750        for (name, source, expected) in [
3751            (
3752                "front matter",
3753                "# 見出し",
3754                "<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">",
3755            ),
3756            (
3757                "group ruby",
3758                "{東京|とうきょう}",
3759                "<ruby class=\"mdi-ruby\">東京",
3760            ),
3761            (
3762                "split ruby",
3763                "{東京|とう.きょう}",
3764                "<ruby class=\"mdi-ruby\"",
3765            ),
3766            ("tate-chu-yoko", "^12^", "<span class=\"mdi-tcy\">12</span>"),
3767            ("default boten", "[[em:傍点]]", "class=\"mdi-em\""),
3768            ("custom boten", "[[em:※:任意]]", "--mdi-em:&quot;※&quot;"),
3769            ("no-break", "[[no-break:改行禁止]]", "class=\"mdi-nobr\""),
3770            ("explicit line break", "前[[br]]次", "<br>"),
3771            ("blank backslash", "\\", "<p class=\"mdi-blank\"></p>"),
3772            ("blank br", "<br>", "<p class=\"mdi-blank\"></p>"),
3773            ("blank br slash", "<br />", "<p class=\"mdi-blank\"></p>"),
3774            (
3775                "blank legacy macro",
3776                "[[blank]]",
3777                "<p class=\"mdi-blank\"></p>",
3778            ),
3779            ("warichu", "[[warichu:割注]]", "class=\"mdi-warichu\""),
3780            ("kerning", "[[kern:-0.1em:詰め]]", "--mdi-kern:-0.1em"),
3781            ("indent", "[[indent:2]]\n字下げ", "class=\"mdi-indent\""),
3782            (
3783                "bottom alignment",
3784                "[[bottom]]\n地付き",
3785                "class=\"mdi-bottom\"",
3786            ),
3787            ("bottom shift", "[[bottom:2]]\n地付き", "--mdi-shift:2"),
3788            ("page break", "[[pagebreak]]", "class=\"mdi-pagebreak\""),
3789            (
3790                "recto page break",
3791                "[[pagebreak:right]]",
3792                "class=\"mdi-pagebreak mdi-pagebreak-right\"",
3793            ),
3794            (
3795                "verso page break",
3796                "[[pagebreak:left]]",
3797                "class=\"mdi-pagebreak mdi-pagebreak-left\"",
3798            ),
3799            ("footnote", "脚注[^n]\n\n[^n]: 注", "data-footnotes"),
3800            (
3801                "escaped delimiters",
3802                "\\{ \\} \\| \\^ \\[ \\: \\《 \\》",
3803                "{ } | ^ [ : 《 》",
3804            ),
3805        ] {
3806            let html = render_html(&format!("{vertical}{source}"));
3807            assert!(
3808                html.contains(expected),
3809                "missing {name}: {expected}; rendered {html}"
3810            );
3811        }
3812        assert!(render_html(&format!("{vertical}\\")).contains(".mdi-blank{min-block-size:1lh}"));
3813    }
3814
3815    #[test]
3816    fn renders_footnote_definitions_in_html() {
3817        let html = render_html("本文[^n]\n\n[^n]: 注の本文");
3818        assert!(html.contains("data-footnotes"));
3819        assert!(html.contains("id=\"user-content-fn-n\""));
3820        assert!(html.contains("注の本文"));
3821    }
3822
3823    #[test]
3824    fn escapes_raw_html_in_the_rust_renderer() {
3825        let html = render_html("<script>alert(1)</script>");
3826        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
3827        assert!(!html.contains("<script>alert(1)</script>"));
3828    }
3829
3830    #[test]
3831    fn serializes_mdi_from_rust_ir() {
3832        let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
3833        assert_eq!(
3834            serialize_mdi(source),
3835            "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
3836        );
3837    }
3838
3839    #[test]
3840    fn renders_plain_text_from_rust_ir() {
3841        assert_eq!(
3842            render_text("# 題\n\n{東京|とうきょう} ^12^"),
3843            "題\n東京 12\n"
3844        );
3845    }
3846
3847    #[test]
3848    fn renders_platform_text_formats_from_rust_ir() {
3849        let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
3850        assert_eq!(
3851            render_text_format(source, TextFormat::Plain, ""),
3852            "題\n東京強調。"
3853        );
3854        assert_eq!(
3855            render_text_format(source, TextFormat::Ruby, ""),
3856            "題\n{東京|とう.きょう}強調。"
3857        );
3858        assert_eq!(
3859            render_text_format(source, TextFormat::Kakuyomu, ""),
3860            "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
3861        );
3862        assert!(
3863            render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は中見出し]")
3864        );
3865    }
3866
3867    #[test]
3868    fn renders_and_serializes_every_public_inline_and_block_variant() {
3869        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";
3870
3871        let html = render_html(source);
3872        for expected in [
3873            "<h2>中見出し</h2>",
3874            "<h3>小見出し</h3>",
3875            "<blockquote><p>引用</p>",
3876            "<ol>",
3877            "<ul>",
3878            "<pre><code class=\"language-rust\">",
3879            "<hr>",
3880            "<table>",
3881            "<a href=\"https://example.test\" title=\"題\">リンク</a>",
3882            "<img src=\"image.png\" alt=\"画像\">",
3883            "<del>削除</del>",
3884            "<code>code</code>",
3885            "<br>",
3886            "mdi-warichu",
3887            "mdi-kern",
3888            "--mdi-em:&quot;●&quot;",
3889        ] {
3890            assert!(html.contains(expected), "HTML contains {expected}");
3891        }
3892
3893        let canonical = serialize_mdi(source);
3894        for expected in [
3895            "[[bottom]]",
3896            "## 中見出し",
3897            "> 引用",
3898            "1. 一",
3899            "- 箇条",
3900            "```rust",
3901            "| 見出し | 値 |",
3902            "[リンク](https://example.test \\題\")",
3903            "![画像](image.png)",
3904            "~~削除~~",
3905            "`code`",
3906            "[[br]]",
3907            "[[warichu:割書]]",
3908            "[[kern:1em:字]]",
3909            "[[em:●:傍点]]",
3910        ] {
3911            assert!(
3912                canonical.contains(expected),
3913                "canonical MDI contains {expected}"
3914            );
3915        }
3916
3917        let plain = render_text(source);
3918        assert!(plain.contains("画像"));
3919        assert!(plain.contains("巢狀"));
3920
3921        for (name, format) in [
3922            ("txt", TextFormat::Plain),
3923            ("txt-ruby", TextFormat::Ruby),
3924            ("narou", TextFormat::Narou),
3925            ("kakuyomu", TextFormat::Kakuyomu),
3926            ("aozora", TextFormat::Aozora),
3927        ] {
3928            assert_eq!(TextFormat::parse(name), Some(format));
3929            assert!(!render_text_format(source, format, " ").is_empty());
3930        }
3931        assert_eq!(TextFormat::parse("unknown"), None);
3932    }
3933
3934    #[test]
3935    fn packages_an_epub_from_rust_ir() {
3936        let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
3937        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3938        let mut mimetype = String::new();
3939        zip.by_name("mimetype")
3940            .unwrap()
3941            .read_to_string(&mut mimetype)
3942            .unwrap();
3943        assert_eq!(mimetype, "application/epub+zip");
3944        let mut opf = String::new();
3945        zip.by_name("OEBPS/package.opf")
3946            .unwrap()
3947            .read_to_string(&mut opf)
3948            .unwrap();
3949        assert!(opf.contains("<dc:title>Test</dc:title>"));
3950        assert!(opf.contains("page-progression-direction=\"rtl\""));
3951        assert!(opf.contains("chapter-2.xhtml"));
3952    }
3953
3954    #[test]
3955    fn packages_a_docx_from_rust_ir() {
3956        let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
3957        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3958        let mut document = String::new();
3959        zip.by_name("word/document.xml")
3960            .unwrap()
3961            .read_to_string(&mut document)
3962            .unwrap();
3963        assert!(document.contains("題"));
3964        assert!(document.contains("東京"));
3965        let mut core = String::new();
3966        zip.by_name("docProps/core.xml")
3967            .unwrap()
3968            .read_to_string(&mut core)
3969            .unwrap();
3970        assert!(core.contains("<dc:title>Test</dc:title>"));
3971    }
3972
3973    #[test]
3974    fn renders_pdf_with_an_available_native_chromium() {
3975        let Some(chromium_path) = find_chromium() else {
3976            return;
3977        };
3978        let pdf = render_pdf(
3979            "# 題\n\n{東京|とうきょう}",
3980            &PdfOptions {
3981                chromium_path: Some(chromium_path),
3982            },
3983        )
3984        .unwrap();
3985        assert!(pdf.starts_with(b"%PDF-"));
3986    }
3987
3988    #[test]
3989    fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
3990        for seed in 0..256 {
3991            let source = generated_source(seed);
3992            let output = parse_output(&source);
3993            assert_eq!(output.document.span.start_byte, 0);
3994            assert_eq!(output.document.span.end_byte as usize, source.len());
3995            for child in &output.document.children {
3996                assert_valid_spans(child, &source);
3997            }
3998            for diagnostic in &output.diagnostics {
3999                let span = diagnostic
4000                    .span
4001                    .expect("parser diagnostics have source spans");
4002                assert!(span.start_byte <= span.end_byte);
4003                assert!((span.end_byte as usize) <= source.len());
4004            }
4005
4006            let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
4007                .expect("every UTF-8 input has a serializable wire result");
4008            assert_eq!(wire["irVersion"], MDI_IR_VERSION);
4009            assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
4010            assert_valid_spans(&wire["document"], &source);
4011
4012            // Rendering and canonicalization are total functions over parser
4013            // output: malformed delimiters may become literal text, never a
4014            // host-visible failure.
4015            assert!(!render_html_document(&output.document).is_empty());
4016            let canonical = serialize_mdi_document(&output.document);
4017            let reparsed = parse_document(&canonical);
4018            for child in &reparsed.children {
4019                assert_valid_spans(child, &canonical);
4020            }
4021            let _ = render_text_document(&reparsed);
4022        }
4023    }
4024
4025    #[test]
4026    fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
4027        let cases = [
4028            "",
4029            "plain\n",
4030            "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
4031            "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
4032            "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
4033            "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
4034            "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
4035        ];
4036        for source in cases {
4037            let first = serialize_mdi(source);
4038            let second = serialize_mdi(&first);
4039            assert_eq!(
4040                second, first,
4041                "canonical output must stabilize for {source:?}"
4042            );
4043            assert_valid_spans(
4044                &serde_json::to_value(parse_document(&first)).unwrap(),
4045                &first,
4046            );
4047        }
4048    }
4049
4050    #[test]
4051    fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
4052        let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
4053
4054        let epub = render_epub(source).unwrap();
4055        let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
4056        assert_eq!(
4057            epub.by_name("mimetype").unwrap().compression(),
4058            CompressionMethod::Stored,
4059            "EPUB requires its mimetype member to be uncompressed"
4060        );
4061        for path in [
4062            "META-INF/container.xml",
4063            "OEBPS/package.opf",
4064            "OEBPS/nav.xhtml",
4065            "OEBPS/style.css",
4066            "OEBPS/chapter-1.xhtml",
4067            "OEBPS/chapter-2.xhtml",
4068        ] {
4069            assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
4070        }
4071        let mut opf = String::new();
4072        epub.by_name("OEBPS/package.opf")
4073            .unwrap()
4074            .read_to_string(&mut opf)
4075            .unwrap();
4076        assert!(opf.contains("A &amp; &lt; B &quot;quoted&quot;"));
4077        assert!(opf.contains("O'Brien &amp; Co."));
4078
4079        let docx = render_docx(source).unwrap();
4080        let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
4081        for path in [
4082            "[Content_Types].xml",
4083            "_rels/.rels",
4084            "docProps/core.xml",
4085            "word/document.xml",
4086        ] {
4087            assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
4088        }
4089        let mut core = String::new();
4090        docx.by_name("docProps/core.xml")
4091            .unwrap()
4092            .read_to_string(&mut core)
4093            .unwrap();
4094        assert!(core.contains("A &amp; &lt; B &quot;quoted&quot;"));
4095        let mut document = String::new();
4096        docx.by_name("word/document.xml")
4097            .unwrap()
4098            .read_to_string(&mut document)
4099            .unwrap();
4100        assert!(document.contains("&lt;unsafe&gt;&amp;"));
4101    }
4102
4103    #[test]
4104    fn classifies_every_legacy_block_macro_shape() {
4105        let amount = |value| match classify_block_macro(value) {
4106            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
4107            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
4108        };
4109        assert_eq!(amount(" [[indent:12]] "), Some(12));
4110        assert_eq!(amount("[[bottom]]"), Some(0));
4111        assert_eq!(amount("[[bottom:3]]"), Some(3));
4112        assert_eq!(amount("literal"), None);
4113        assert!(matches!(
4114            classify_block_macro("[[pagebreak]]"),
4115            BlockMacroClass::Pagebreak(None)
4116        ));
4117        assert!(matches!(
4118            classify_block_macro("[[pagebreak:left]]"),
4119            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
4120        ));
4121        assert!(matches!(
4122            classify_block_macro("[[pagebreak:right]]"),
4123            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
4124        ));
4125        for literal in [
4126            "text",
4127            "[[unknown:1]]",
4128            "[[indent:]]",
4129            "[[indent:0]]",
4130            "[[indent:01]]",
4131            "[[indent:x]]",
4132            "[[indent:999999999999999999999999999999]]",
4133        ] {
4134            assert!(matches!(
4135                classify_block_macro(literal),
4136                BlockMacroClass::Literal
4137            ));
4138        }
4139    }
4140
4141    #[test]
4142    fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
4143        let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
4144        assert_eq!(newer.diagnostics.len(), 1);
4145        assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
4146        assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
4147        assert_eq!(
4148            newer.diagnostics[0].span,
4149            newer.document.frontmatter.map(|f| f.span)
4150        );
4151
4152        for source in [
4153            "---\nmdi: '2.0'\n---\n",
4154            "---\nmdi: 3\n---\n",
4155            "---\n- sequence\n- values\n---\n",
4156            "---\n[malformed\n---\n",
4157        ] {
4158            let output = parse_output(source);
4159            assert!(output.diagnostics.is_empty());
4160            assert!(output.document.frontmatter.is_some());
4161        }
4162    }
4163
4164    #[test]
4165    fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
4166        let missing =
4167            std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
4168        let error = render_pdf(
4169            "text",
4170            &PdfOptions {
4171                chromium_path: Some(missing),
4172            },
4173        )
4174        .unwrap_err();
4175        assert!(error.contains("failed to start Chromium"));
4176
4177        let current_exe = std::env::current_exe().unwrap();
4178        let error = render_pdf(
4179            "text",
4180            &PdfOptions {
4181                chromium_path: Some(current_exe),
4182            },
4183        )
4184        .unwrap_err();
4185        assert!(error.contains("Chromium PDF rendering failed"));
4186    }
4187
4188    #[test]
4189    fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
4190        let mut scalar = serde_json::json!(null);
4191        lower_markdown_inside_mdi(&mut scalar, "");
4192        shift_spans(&mut scalar, 10);
4193        annotate_and_lower(&mut scalar, "", false);
4194        inject_block_markers(&mut scalar, &[]);
4195        assert_valid_spans(&scalar, "");
4196
4197        let mut paragraph_with_invalid_span = serde_json::json!({
4198            "type": "paragraph",
4199            "span": {"startByte": 2, "endByte": 3}
4200        });
4201        lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
4202
4203        let mut text_without_value = serde_json::json!({
4204            "type": "text",
4205            "position": {"start": {"offset": 0}, "end": {"offset": 0}}
4206        });
4207        annotate_and_lower(&mut text_without_value, "", false);
4208        let mut text_without_position = serde_json::json!({
4209            "type": "text",
4210            "value": "^12^"
4211        });
4212        annotate_and_lower(&mut text_without_position, "^12^", false);
4213        assert_eq!(text_without_position["children"][0]["type"], "tcy");
4214        let mut text_with_partial_span = serde_json::json!({
4215            "type": "text",
4216            "value": "^12^",
4217            "span": {}
4218        });
4219        annotate_and_lower(&mut text_with_partial_span, "^12^", false);
4220        assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
4221
4222        assert!(markdown_macro_children("not-a-macro", 0).is_none());
4223        assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
4224
4225        let span = SourceSpan {
4226            start_byte: 0,
4227            end_byte: 10,
4228        };
4229        for (is_indent, amount, expected) in [
4230            (true, 2, "[[indent:2]]"),
4231            (false, 0, "[[bottom]]"),
4232            (false, 3, "[[bottom:3]]"),
4233        ] {
4234            let mut output = Vec::new();
4235            let mut pending = Some((span, is_indent, amount));
4236            flush_pending_literal(&mut output, &mut pending);
4237            assert_eq!(output[0]["children"][0]["value"], expected);
4238        }
4239
4240        assert!(matches!(
4241            paragraph(
4242                "text",
4243                Some(PendingBlock::Bottom {
4244                    amount: 2,
4245                    source: "[[bottom:2]]".to_owned()
4246                })
4247            ),
4248            MdiBlock::Paragraph {
4249                bottom: Some(2),
4250                ..
4251            }
4252        ));
4253        assert!(pending_block("[[unknown:2]]").is_none());
4254        assert!(boten("《《》》").is_none());
4255        assert!(!valid_kern("1.em"));
4256        assert!(!valid_kern("1.2.3em"));
4257        assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
4258
4259        let mut writer = FailAfterWrites {
4260            inner: Cursor::new(Vec::new()),
4261            remaining: 1,
4262        };
4263        writer.flush().unwrap();
4264
4265        let mut formatted = Vec::new();
4266        text_format_block(
4267            &serde_json::json!({"type": "blank"}),
4268            TextFormat::Plain,
4269            "",
4270            &[],
4271            &[],
4272            &mut formatted,
4273        );
4274        text_format_block(
4275            &serde_json::json!({"type": "unknown"}),
4276            TextFormat::Plain,
4277            "",
4278            &[],
4279            &[],
4280            &mut formatted,
4281        );
4282        assert_eq!(formatted, vec![String::new()]);
4283
4284        assert_eq!(
4285            text_format_inline(
4286                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
4287                TextFormat::Ruby,
4288                &[]
4289            ),
4290            "{字|じ}"
4291        );
4292        assert_eq!(
4293            text_format_inline(
4294                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
4295                TextFormat::Plain,
4296                &[]
4297            ),
4298            "字"
4299        );
4300        assert_eq!(
4301            text_format_inline(
4302                &serde_json::json!({"type":"image", "alt":""}),
4303                TextFormat::Plain,
4304                &[]
4305            ),
4306            "[画像]"
4307        );
4308
4309        let mut html = String::new();
4310        render_html_node(&serde_json::json!({}), &mut html);
4311        render_html_node(
4312            &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
4313            &mut html,
4314        );
4315        render_html_children(&serde_json::json!({"type":"root"}), &mut html);
4316        assert_eq!(html, "ok");
4317
4318        let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
4319        assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
4320        let heading = parse_document("[[indent:2]]\n# heading");
4321        assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
4322        let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
4323        assert_eq!(trailing.children.len(), 2);
4324
4325        let partial_document = Document {
4326            span: SourceSpan::default(),
4327            frontmatter: None,
4328            children: vec![serde_json::json!({
4329                "type": "root",
4330                "children": [{"type":"text", "value":"partial"}]
4331            })],
4332        };
4333        assert!(render_docx_document(&partial_document).is_ok());
4334    }
4335
4336    #[test]
4337    fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
4338        let empty = render_epub("").unwrap();
4339        let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
4340        assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
4341
4342        let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
4343        let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
4344        assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
4345    }
4346
4347    #[test]
4348    fn archive_writers_propagate_failures_from_every_write_stage() {
4349        let document = parse_document(
4350            "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
4351        );
4352
4353        let mut epub_errors = 0;
4354        let mut epub_success = false;
4355        for remaining in 0..256 {
4356            let writer = FailAfterWrites {
4357                inner: Cursor::new(Vec::new()),
4358                remaining,
4359            };
4360            let mut zip = ZipWriter::new(writer);
4361            let result = write_epub_document(&document, &mut zip);
4362            if result.is_err() {
4363                epub_errors += 1;
4364            } else {
4365                epub_success = true;
4366            }
4367            // A deliberately broken writer can leave `ZipWriter` midway
4368            // through a local header, where its Drop finalizer cannot safely
4369            // recover. The writer owns only this test's in-memory buffer.
4370            std::mem::forget(zip);
4371            if epub_success {
4372                break;
4373            }
4374        }
4375        assert!(epub_errors > 0);
4376        assert!(epub_success);
4377
4378        let mut docx_errors = 0;
4379        let mut docx_success = false;
4380        for remaining in 0..256 {
4381            let writer = FailAfterWrites {
4382                inner: Cursor::new(Vec::new()),
4383                remaining,
4384            };
4385            let mut zip = ZipWriter::new(writer);
4386            let result = write_docx_document(&document, &mut zip);
4387            if result.is_err() {
4388                docx_errors += 1;
4389            } else {
4390                docx_success = true;
4391            }
4392            std::mem::forget(zip);
4393            if docx_success {
4394                break;
4395            }
4396        }
4397        assert!(docx_errors > 0);
4398        assert!(docx_success);
4399    }
4400
4401    #[allow(unsafe_code)]
4402    fn ffi_bytes(result: ffi::MdiFfiResult) -> Result<Vec<u8>, String> {
4403        let value = if result.value.len == 0 {
4404            Vec::new()
4405        } else {
4406            unsafe { std::slice::from_raw_parts(result.value.data, result.value.len).to_vec() }
4407        };
4408        let error = if result.error.len == 0 {
4409            None
4410        } else {
4411            Some(unsafe {
4412                std::str::from_utf8(std::slice::from_raw_parts(
4413                    result.error.data,
4414                    result.error.len,
4415                ))
4416                .unwrap()
4417                .to_owned()
4418            })
4419        };
4420        unsafe {
4421            ffi::mdi_free_buffer(result.value);
4422            ffi::mdi_free_buffer(result.error);
4423        }
4424        error.map_or(Ok(value), Err)
4425    }
4426
4427    #[test]
4428    #[allow(unsafe_code)]
4429    fn c_abi_returns_owned_versioned_wire_data_for_every_export() {
4430        let source = "{東京|とうきょう} ^12^";
4431        let json = String::from_utf8(
4432            ffi_bytes(ffi::mdi_parse_json(source.as_ptr(), source.len())).unwrap(),
4433        )
4434        .unwrap();
4435        assert!(json.contains("\"irVersion\":\"1.0\""));
4436        assert!(json.contains("\"type\":\"ruby\""));
4437
4438        let html = String::from_utf8(
4439            ffi_bytes(ffi::mdi_render_html(source.as_ptr(), source.len())).unwrap(),
4440        )
4441        .unwrap();
4442        assert!(html.contains("<ruby class=\"mdi-ruby\">東京"));
4443        assert_eq!(
4444            String::from_utf8(
4445                ffi_bytes(ffi::mdi_serialize_mdi(source.as_ptr(), source.len())).unwrap()
4446            )
4447            .unwrap(),
4448            "{東京|とうきょう} ^12^\n"
4449        );
4450        assert_eq!(
4451            String::from_utf8(
4452                ffi_bytes(ffi::mdi_render_text(source.as_ptr(), source.len())).unwrap()
4453            )
4454            .unwrap(),
4455            "東京 12\n"
4456        );
4457        assert!(
4458            ffi_bytes(ffi::mdi_render_epub(source.as_ptr(), source.len()))
4459                .unwrap()
4460                .starts_with(b"PK")
4461        );
4462        assert!(
4463            ffi_bytes(ffi::mdi_render_docx(source.as_ptr(), source.len()))
4464                .unwrap()
4465                .starts_with(b"PK")
4466        );
4467
4468        assert!(
4469            ffi_bytes(ffi::mdi_render_text(std::ptr::null(), 0))
4470                .unwrap()
4471                .is_empty()
4472        );
4473        let invalid_utf8 = [0xff];
4474        assert_eq!(
4475            ffi_bytes(ffi::mdi_render_html(
4476                invalid_utf8.as_ptr(),
4477                invalid_utf8.len()
4478            ))
4479            .unwrap_err(),
4480            "MDI source must be valid UTF-8"
4481        );
4482
4483        assert_eq!(
4484            ffi_bytes(ffi::mdi_parse_json(std::ptr::null(), 1)).unwrap_err(),
4485            "MDI source pointer is null"
4486        );
4487        assert_eq!(
4488            ffi_bytes(ffi::mdi_render_epub(std::ptr::null(), 1)).unwrap_err(),
4489            "MDI source pointer is null"
4490        );
4491    }
4492}