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};
13#[cfg(not(feature = "wasm"))]
14use time::{OffsetDateTime, format_description::well_known::Rfc3339};
15use unicode_segmentation::UnicodeSegmentation;
16use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
17
18mod docx;
19mod publication_profile;
20pub use publication_profile::{
21    ChromiumPrintPage, ChromiumPrintPageNumbers, ChromiumPrintProfile, Margins, PageNumbers,
22    PageSizeDimensions, ResolvedEpub, ResolvedExportProfile, ResolvedLayout, ResolvedPagination,
23    ResolvedText, ResolvedTypesetting, apply_pdf_profile, apply_pdf_profile_json, page_dimensions,
24    page_size_catalog_json, prepare_chromium_print_profile, prepare_chromium_print_profile_json,
25    prepare_chromium_print_profile_resolved, resolve_export_profile, resolve_export_profile_json,
26};
27
28/// MDI syntax version implemented by this crate.
29pub const MDI_SPEC_VERSION: &str = "2.0";
30
31/// Version of the language-neutral wire format returned by the bindings.
32///
33/// This version changes only for incompatible wire-schema changes.
34pub const MDI_IR_VERSION: &str = "1.0";
35
36/// A binding-friendly parse envelope.
37///
38/// Capabilities let a binding verify the features represented in this parse
39/// result before it adapts the portable tree to a host-language API.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct ParseOutput {
43    pub ir_version: &'static str,
44    pub syntax_version: &'static str,
45    pub capabilities: ParserCapabilities,
46    pub document: Document,
47    pub diagnostics: Vec<Diagnostic>,
48}
49
50/// Parser features represented in a [`ParseOutput`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ParserCapabilities {
54    pub mdi: bool,
55    pub common_mark: bool,
56    pub gfm: bool,
57    pub front_matter: bool,
58    pub source_spans: bool,
59}
60
61/// A recoverable parser message returned as part of the stable wire contract.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct Diagnostic {
65    pub severity: DiagnosticSeverity,
66    pub code: String,
67    pub message: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub span: Option<SourceSpan>,
70}
71
72/// Severity of a parser diagnostic.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub enum DiagnosticSeverity {
76    Warning,
77    Error,
78}
79
80/// Half-open UTF-8 byte range in the original source.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct SourceSpan {
84    pub start_byte: u32,
85    pub end_byte: u32,
86}
87
88/// The complete language-neutral document tree.  `children` uses mdast's
89/// stable tagged JSON shape (with MDI nodes injected by this crate); every
90/// source-backed node is annotated with a UTF-8 byte `span`.
91#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct Document {
94    pub span: SourceSpan,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub frontmatter: Option<Frontmatter>,
97    pub children: Vec<serde_json::Value>,
98}
99
100/// Ordered YAML front matter, retaining both recognized and unknown fields.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(rename_all = "camelCase")]
103pub struct Frontmatter {
104    pub span: SourceSpan,
105    pub raw: String,
106    pub entries: Vec<FrontmatterEntry>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "camelCase")]
111pub struct FrontmatterEntry {
112    pub key: String,
113    pub value: serde_json::Value,
114}
115
116/// Transitional MDI-only document used by the compatibility helper
117/// [`parse_mdi_syntax`].  New callers must use [`parse_document`].
118#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct MdiSyntaxDocument {
121    pub blocks: Vec<MdiBlock>,
122}
123
124/// A block-level MDI construct or ordinary source line in the compatibility
125/// helper.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
127#[serde(tag = "type", rename_all = "camelCase")]
128pub enum MdiBlock {
129    Paragraph {
130        inlines: Vec<Inline>,
131        indent: Option<u32>,
132        bottom: Option<u32>,
133    },
134    Blank,
135    Pagebreak {
136        variant: Option<PagebreakVariant>,
137    },
138}
139
140/// A page-break side.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub enum PagebreakVariant {
144    Left,
145    Right,
146}
147
148/// A language-neutral MDI inline AST.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum Inline {
151    Text(String),
152    Ruby {
153        base: String,
154        ruby: RubyReading,
155    },
156    Tcy(String),
157    Break,
158    Em {
159        mark: String,
160        children: Vec<Inline>,
161    },
162    NoBreak(Vec<Inline>),
163    Warichu(Vec<Inline>),
164    Kern {
165        amount: String,
166        children: Vec<Inline>,
167    },
168}
169
170/// Keep the public wire nodes flat (`{ type, value }`, `{ type, children }`,
171/// and so on) without forcing the Rust implementation to use struct variants
172/// internally. This shape maps naturally to discriminated unions in every
173/// host language.
174impl Serialize for Inline {
175    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
176    where
177        S: Serializer,
178    {
179        match self {
180            Self::Text(value) => {
181                let mut node = serializer.serialize_struct("Inline", 2)?;
182                node.serialize_field("type", "text")?;
183                node.serialize_field("value", value)?;
184                node.end()
185            }
186            Self::Ruby { base, ruby } => {
187                let mut node = serializer.serialize_struct("Inline", 3)?;
188                node.serialize_field("type", "ruby")?;
189                node.serialize_field("base", base)?;
190                node.serialize_field("ruby", ruby)?;
191                node.end()
192            }
193            Self::Tcy(value) => {
194                let mut node = serializer.serialize_struct("Inline", 2)?;
195                node.serialize_field("type", "tcy")?;
196                node.serialize_field("value", value)?;
197                node.end()
198            }
199            Self::Break => {
200                let mut node = serializer.serialize_struct("Inline", 1)?;
201                node.serialize_field("type", "break")?;
202                node.end()
203            }
204            Self::Em { mark, children } => {
205                let mut node = serializer.serialize_struct("Inline", 3)?;
206                node.serialize_field("type", "em")?;
207                node.serialize_field("mark", mark)?;
208                node.serialize_field("children", children)?;
209                node.end()
210            }
211            Self::NoBreak(children) => {
212                let mut node = serializer.serialize_struct("Inline", 2)?;
213                node.serialize_field("type", "noBreak")?;
214                node.serialize_field("children", children)?;
215                node.end()
216            }
217            Self::Warichu(children) => {
218                let mut node = serializer.serialize_struct("Inline", 2)?;
219                node.serialize_field("type", "warichu")?;
220                node.serialize_field("children", children)?;
221                node.end()
222            }
223            Self::Kern { amount, children } => {
224                let mut node = serializer.serialize_struct("Inline", 3)?;
225                node.serialize_field("type", "kern")?;
226                node.serialize_field("amount", amount)?;
227                node.serialize_field("children", children)?;
228                node.end()
229            }
230        }
231    }
232}
233
234/// The reading attached to a ruby base.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
236#[serde(tag = "type", content = "value", rename_all = "camelCase")]
237pub enum RubyReading {
238    Group(String),
239    Split(Vec<String>),
240}
241
242/// Parse only MDI syntax for legacy callers.  This is intentionally not the
243/// public whole-document entry point; use [`parse_document`] instead.
244pub fn parse_mdi_syntax(source: &str) -> MdiSyntaxDocument {
245    let mut blocks = Vec::new();
246    let mut pending: Option<PendingBlock> = None;
247
248    for line in source.lines() {
249        if is_blank_marker(line) {
250            flush_pending(&mut blocks, &mut pending);
251            blocks.push(MdiBlock::Blank);
252            continue;
253        }
254        if let Some(pagebreak) = pagebreak(line) {
255            flush_pending(&mut blocks, &mut pending);
256            blocks.push(MdiBlock::Pagebreak { variant: pagebreak });
257            continue;
258        }
259        if let Some(marker) = pending_block(line) {
260            if pending.is_some() {
261                flush_pending(&mut blocks, &mut pending);
262                blocks.push(paragraph(line, None));
263            } else {
264                pending = Some(marker);
265            }
266            continue;
267        }
268
269        blocks.push(paragraph(line, pending.take()));
270    }
271    flush_pending(&mut blocks, &mut pending);
272    MdiSyntaxDocument { blocks }
273}
274
275/// Parse a complete `.mdi` document.  CommonMark and all GFM constructs are
276/// parsed by `markdown-rs`; MDI is then lowered into the same tagged tree in
277/// Rust.  The host never tokenizes Markdown or MDI.
278pub fn parse_document(source: &str) -> Document {
279    let prepared = prepare_block_markers(source);
280    let mut constructs = markdown::Constructs::gfm();
281    constructs.frontmatter = true;
282    let options = markdown::ParseOptions {
283        constructs,
284        ..markdown::ParseOptions::default()
285    };
286    let tree = markdown::to_mdast(&prepared.markdown, &options)
287        .expect("MDI does not enable MDX, so Markdown parsing cannot fail");
288    let mut root = serde_json::to_value(tree).expect("markdown AST is serializable");
289    let frontmatter = extract_frontmatter(&root, source);
290    annotate_and_lower(&mut root, source, false);
291    lower_markdown_inside_mdi(&mut root, source);
292    inject_block_markers(&mut root, &prepared.markers);
293    let children = root
294        .get_mut("children")
295        .and_then(serde_json::Value::as_array_mut)
296        .map(|children| {
297            children
298                .drain(..)
299                .filter(|child| {
300                    child.get("type").and_then(serde_json::Value::as_str) != Some("yaml")
301                })
302                .collect()
303        })
304        .unwrap_or_default();
305    Document {
306        span: SourceSpan {
307            start_byte: 0,
308            end_byte: source.len() as u32,
309        },
310        frontmatter,
311        children,
312    }
313}
314
315/// Restore a bracket macro which Markdown would otherwise split at emphasis,
316/// links, or other inline delimiters.  The macro owns its balanced boundary;
317/// its payload is then parsed as Markdown and recursively lowered by Rust.
318fn lower_markdown_inside_mdi(node: &mut serde_json::Value, source: &str) {
319    let Some(object) = node.as_object_mut() else {
320        return;
321    };
322    if object.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
323        let span = object.get("span").cloned();
324        if let Some(raw) = span
325            .as_ref()
326            .and_then(|span| source_from_span(span, source))
327        {
328            let raw = raw.trim_end_matches(['\r', '\n']);
329            if let Some(children) =
330                markdown_paragraph_children(raw, span.as_ref().expect("span exists"))
331            {
332                object.insert("children".to_owned(), serde_json::Value::Array(children));
333                return;
334            }
335        }
336    }
337    if let Some(children) = object
338        .get_mut("children")
339        .and_then(serde_json::Value::as_array_mut)
340    {
341        for child in children {
342            lower_markdown_inside_mdi(child, source);
343        }
344    }
345}
346
347fn markdown_paragraph_children(
348    raw: &str,
349    span: &serde_json::Value,
350) -> Option<Vec<serde_json::Value>> {
351    let paragraph_start = span.get("startByte")?.as_u64()? as usize;
352    let mut output = Vec::new();
353    let mut index = 0;
354    let mut plain_start = 0;
355    let mut found = false;
356    while index < raw.len() {
357        let rest = &raw[index..];
358        if rest.starts_with("[[")
359            && let Some(end) = close_macro(rest)
360            && let Some(mut macro_node) =
361                markdown_macro_children(&rest[..end + 2], paragraph_start + index)
362        {
363            output.append(&mut markdown_fragment_children(
364                &raw[plain_start..index],
365                paragraph_start + plain_start,
366            ));
367            output.append(&mut macro_node);
368            index += end + 2;
369            plain_start = index;
370            found = true;
371            continue;
372        }
373        index += rest.chars().next()?.len_utf8();
374    }
375    if !found {
376        return None;
377    }
378    output.append(&mut markdown_fragment_children(
379        &raw[plain_start..],
380        paragraph_start + plain_start,
381    ));
382    Some(output)
383}
384
385fn markdown_fragment_children(source: &str, start_byte: usize) -> Vec<serde_json::Value> {
386    if source.is_empty() {
387        return Vec::new();
388    }
389    if let Some((text, identifier)) = trailing_footnote_reference(source) {
390        let mut children = markdown_fragment_children(text, start_byte);
391        children.push(serde_json::json!({
392            "type": "footnoteReference",
393            "identifier": identifier,
394            "label": identifier,
395            "span": SourceSpan { start_byte: (start_byte + text.len()) as u32, end_byte: (start_byte + source.len()) as u32 },
396        }));
397        return children;
398    }
399    let leading = source
400        .chars()
401        .take_while(|character| character.is_whitespace())
402        .collect::<String>();
403    let trailing = source
404        .chars()
405        .rev()
406        .take_while(|character| character.is_whitespace())
407        .collect::<String>()
408        .chars()
409        .rev()
410        .collect::<String>();
411    let tree = markdown::to_mdast(source, &markdown_options())
412        .expect("MDI fragment parsing cannot fail when MDX is disabled");
413    let mut tree = serde_json::to_value(tree).expect("Markdown fragment AST is serializable");
414    annotate_and_lower(&mut tree, source, false);
415    shift_spans(&mut tree, start_byte);
416    let mut children = tree
417        .get_mut("children")
418        .and_then(serde_json::Value::as_array_mut)
419        .and_then(|children| children.first_mut())
420        .and_then(|paragraph| paragraph.get("children"))
421        .and_then(serde_json::Value::as_array)
422        .cloned()
423        .unwrap_or_default();
424    if !leading.is_empty()
425        && !children
426            .first()
427            .and_then(|child| child.get("value"))
428            .and_then(serde_json::Value::as_str)
429            .is_some_and(|value| value.starts_with(&leading))
430    {
431        children.insert(
432            0,
433            serde_json::json!({ "type": "text", "value": leading, "span": SourceSpan { start_byte: start_byte as u32, end_byte: (start_byte + leading.len()) as u32 } }),
434        );
435    }
436    if !trailing.is_empty()
437        && !children
438            .last()
439            .and_then(|child| child.get("value"))
440            .and_then(serde_json::Value::as_str)
441            .is_some_and(|value| value.ends_with(&trailing))
442    {
443        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 } }));
444    }
445    children
446}
447
448fn trailing_footnote_reference(source: &str) -> Option<(&str, &str)> {
449    let (text, suffix) = source.rsplit_once("[^")?;
450    let identifier = suffix.strip_suffix(']')?;
451    (!identifier.is_empty() && !identifier.chars().any(char::is_whitespace))
452        .then_some((text, identifier))
453}
454
455fn markdown_options() -> markdown::ParseOptions {
456    let mut constructs = markdown::Constructs::gfm();
457    constructs.frontmatter = false;
458    markdown::ParseOptions {
459        constructs,
460        ..markdown::ParseOptions::default()
461    }
462}
463
464fn source_from_span<'a>(span: &serde_json::Value, source: &'a str) -> Option<&'a str> {
465    source.get(span.get("startByte")?.as_u64()? as usize..span.get("endByte")?.as_u64()? as usize)
466}
467
468/// Map byte boundaries in Markdown's decoded text value back to the raw text
469/// range. Markdown commonly removes a backslash before punctuation; accepting
470/// only that loss keeps this mapping deliberately conservative.
471fn decoded_byte_offsets(decoded: &str, raw: &str) -> Option<Vec<(usize, usize)>> {
472    let mut offsets = vec![(0, 0)];
473    let mut raw_index = 0;
474    for (decoded_index, character) in decoded.char_indices() {
475        let expected_end = decoded_index + character.len_utf8();
476        if raw[raw_index..].starts_with('\\') {
477            let after_slash = raw_index + '\\'.len_utf8();
478            if raw[after_slash..].starts_with(character) {
479                raw_index = after_slash;
480            }
481        }
482        if !raw[raw_index..].starts_with(character) {
483            return None;
484        }
485        raw_index += character.len_utf8();
486        offsets.push((expected_end, raw_index));
487    }
488    (raw_index == raw.len()).then_some(offsets)
489}
490
491fn source_offset(offsets: &[(usize, usize)], decoded_offset: usize) -> Option<usize> {
492    offsets
493        .binary_search_by_key(&decoded_offset, |(decoded, _)| *decoded)
494        .ok()
495        .map(|index| offsets[index].1)
496}
497
498/// Shift a tree parsed from a source fragment back into the parent document.
499/// `markdown-rs` reports fragment-local UTF-8 byte offsets.
500fn shift_spans(node: &mut serde_json::Value, amount: usize) {
501    let Some(object) = node.as_object_mut() else {
502        return;
503    };
504    if let Some(span) = object
505        .get_mut("span")
506        .and_then(serde_json::Value::as_object_mut)
507    {
508        for key in ["startByte", "endByte"] {
509            if let Some(offset) = span.get(key).and_then(serde_json::Value::as_u64) {
510                span.insert(key.to_owned(), serde_json::json!(offset + amount as u64));
511            }
512        }
513    }
514    if let Some(children) = object
515        .get_mut("children")
516        .and_then(serde_json::Value::as_array_mut)
517    {
518        for child in children {
519            shift_spans(child, amount);
520        }
521    }
522}
523
524fn markdown_macro_children(raw: &str, start_byte: usize) -> Option<Vec<serde_json::Value>> {
525    if !raw.starts_with("[[") {
526        return None;
527    }
528    let end = close_macro(raw)?;
529    if end + 2 != raw.len() {
530        return None;
531    }
532    let body = &raw[2..end];
533    let (name, payload) = body.split_once(':')?;
534    let (type_name, extra, content, content_offset) = match name {
535        "no-break" if !payload.is_empty() => (
536            "noBreak",
537            serde_json::Map::new(),
538            payload,
539            2 + name.len() + 1,
540        ),
541        "warichu" => (
542            "warichu",
543            serde_json::Map::new(),
544            payload,
545            2 + name.len() + 1,
546        ),
547        "kern" => {
548            let (amount, content) = payload.split_once(':')?;
549            if !valid_kern(amount) {
550                return None;
551            }
552            let mut extra = serde_json::Map::new();
553            extra.insert("amount".to_owned(), serde_json::json!(unescape_mdi(amount)));
554            (
555                "kern",
556                extra,
557                content,
558                2 + name.len() + 1 + amount.len() + 1,
559            )
560        }
561        "em" => {
562            let (mark, content) = bare_index(payload, ':')
563                .and_then(|index| {
564                    let mark = unescape_mdi(&payload[..index]);
565                    (mark.graphemes(true).count() == 1
566                        && !mark.chars().any(|c| c.is_whitespace() || c.is_control()))
567                    .then_some((mark, &payload[index + 1..]))
568                })
569                .unwrap_or_else(|| ("﹅".to_owned(), payload));
570            let mut extra = serde_json::Map::new();
571            extra.insert("mark".to_owned(), serde_json::json!(mark));
572            let content_offset = raw.len() - 2 - content.len();
573            ("em", extra, content, content_offset)
574        }
575        _ => return None,
576    };
577    let mut constructs = markdown::Constructs::gfm();
578    let options = markdown::ParseOptions {
579        constructs: {
580            constructs.frontmatter = false;
581            constructs
582        },
583        ..markdown::ParseOptions::default()
584    };
585    let tree = markdown::to_mdast(content, &options).ok()?;
586    let mut value = serde_json::to_value(tree).ok()?;
587    annotate_and_lower(&mut value, content, false);
588    shift_spans(&mut value, start_byte + content_offset);
589    let children = value
590        .get_mut("children")?
591        .as_array_mut()?
592        .first_mut()?
593        .get_mut("children")?
594        .as_array()?
595        .clone();
596    let mut node = extra;
597    node.insert("type".to_owned(), serde_json::json!(type_name));
598    node.insert("children".to_owned(), serde_json::Value::Array(children));
599    node.insert(
600        "span".to_owned(),
601        serde_json::json!(SourceSpan {
602            start_byte: start_byte as u32,
603            end_byte: (start_byte + raw.len()) as u32,
604        }),
605    );
606    Some(vec![serde_json::Value::Object(node)])
607}
608
609#[derive(Clone)]
610enum PreparedBlockMarker {
611    Blank(SourceSpan),
612    Pagebreak(SourceSpan, Option<PagebreakVariant>),
613    Indent(SourceSpan, bool, u32),
614}
615
616struct PreparedSource {
617    markdown: String,
618    markers: Vec<PreparedBlockMarker>,
619}
620
621/// Hide standalone MDI flow markers from the Markdown tokenizer without
622/// moving a single byte.  This lets Markdown form the surrounding paragraphs
623/// correctly and preserves every AST offset.  Fenced code and blockquotes are
624/// deliberately excluded by the normative literal-context rule.
625fn prepare_block_markers(source: &str) -> PreparedSource {
626    let mut markdown = String::with_capacity(source.len());
627    let mut markers = Vec::new();
628    let mut offset = 0;
629    let mut fenced = false;
630    for line in source.split_inclusive('\n') {
631        let without_lf = line.strip_suffix('\n').unwrap_or(line);
632        let content = without_lf.strip_suffix('\r').unwrap_or(without_lf);
633        let trimmed_start = content.trim_start();
634        let is_fence = trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~");
635        if is_fence {
636            fenced = !fenced;
637        }
638        let span = SourceSpan {
639            start_byte: offset as u32,
640            end_byte: (offset + content.len()) as u32,
641        };
642        let marker = if !fenced && !trimmed_start.starts_with('>') && content == trimmed_start {
643            if is_blank_marker(content) {
644                Some(PreparedBlockMarker::Blank(span))
645            } else if let Some(variant) = pagebreak(content) {
646                Some(PreparedBlockMarker::Pagebreak(span, variant))
647            } else {
648                pending_block(content).map(|marker| match marker {
649                    PendingBlock::Indent { amount, .. } => {
650                        PreparedBlockMarker::Indent(span, true, amount)
651                    }
652                    PendingBlock::Bottom { amount, .. } => {
653                        PreparedBlockMarker::Indent(span, false, amount)
654                    }
655                })
656            }
657        } else {
658            None
659        };
660        if let Some(marker) = marker {
661            markers.push(marker);
662            markdown.push_str(&" ".repeat(content.len()));
663            markdown.push_str(&line[content.len()..]);
664        } else {
665            markdown.push_str(line);
666        }
667        offset += line.len();
668    }
669    PreparedSource { markdown, markers }
670}
671
672fn inject_block_markers(root: &mut serde_json::Value, markers: &[PreparedBlockMarker]) {
673    let Some(children) = root
674        .get_mut("children")
675        .and_then(serde_json::Value::as_array_mut)
676    else {
677        return;
678    };
679    let mut output = Vec::with_capacity(children.len() + markers.len());
680    let mut marker_index = 0;
681    let mut pending: Option<(SourceSpan, bool, u32)> = None;
682    for mut child in children.drain(..) {
683        let start = child
684            .pointer("/span/startByte")
685            .and_then(serde_json::Value::as_u64)
686            .unwrap_or(u64::MAX) as u32;
687        while let Some(marker) = markers.get(marker_index) {
688            if marker_span(marker).end_byte > start {
689                break;
690            }
691            marker_index += 1;
692            match marker.clone() {
693                PreparedBlockMarker::Blank(span) => {
694                    flush_pending_literal(&mut output, &mut pending);
695                    output.push(serde_json::json!({ "type": "blank", "span": span }));
696                }
697                PreparedBlockMarker::Pagebreak(span, variant) => {
698                    flush_pending_literal(&mut output, &mut pending);
699                    output.push(serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }));
700                }
701                PreparedBlockMarker::Indent(span, is_indent, amount) => {
702                    if pending.is_some() {
703                        flush_pending_literal(&mut output, &mut pending);
704                    }
705                    pending = Some((span, is_indent, amount));
706                }
707            }
708        }
709        if let Some((_span, is_indent, amount)) = pending.take() {
710            if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
711                child.as_object_mut().expect("node is object").insert(
712                    if is_indent { "indent" } else { "bottom" }.to_owned(),
713                    serde_json::json!(amount),
714                );
715            } else {
716                flush_pending_literal(&mut output, &mut Some((_span, is_indent, amount)));
717            }
718        }
719        output.push(child);
720    }
721    while let Some(marker) = markers.get(marker_index) {
722        marker_index += 1;
723        match marker.clone() {
724            PreparedBlockMarker::Blank(span) => {
725                flush_pending_literal(&mut output, &mut pending);
726                output.push(serde_json::json!({ "type": "blank", "span": span }));
727            }
728            PreparedBlockMarker::Pagebreak(span, variant) => {
729                flush_pending_literal(&mut output, &mut pending);
730                output.push(
731                    serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }),
732                );
733            }
734            PreparedBlockMarker::Indent(span, is_indent, amount) => {
735                if pending.is_some() {
736                    flush_pending_literal(&mut output, &mut pending);
737                }
738                pending = Some((span, is_indent, amount));
739            }
740        }
741    }
742    flush_pending_literal(&mut output, &mut pending);
743    *children = output;
744}
745
746fn marker_span(marker: &PreparedBlockMarker) -> SourceSpan {
747    match marker {
748        PreparedBlockMarker::Blank(span)
749        | PreparedBlockMarker::Pagebreak(span, _)
750        | PreparedBlockMarker::Indent(span, _, _) => *span,
751    }
752}
753
754fn flush_pending_literal(
755    output: &mut Vec<serde_json::Value>,
756    pending: &mut Option<(SourceSpan, bool, u32)>,
757) {
758    let Some((span, is_indent, amount)) = pending.take() else {
759        return;
760    };
761    let value = if is_indent {
762        format!("[[indent:{amount}]]")
763    } else if amount == 0 {
764        "[[bottom]]".to_owned()
765    } else {
766        format!("[[bottom:{amount}]]")
767    };
768    output.push(serde_json::json!({ "type": "paragraph", "children": [{ "type": "text", "value": value, "span": span }], "span": span }));
769}
770
771fn annotate_and_lower(node: &mut serde_json::Value, source: &str, protected: bool) {
772    let Some(object) = node.as_object_mut() else {
773        return;
774    };
775    let node_type = object
776        .get("type")
777        .and_then(serde_json::Value::as_str)
778        .unwrap_or("")
779        .to_owned();
780    // Code and raw nodes have no `text` children in mdast, but are also
781    // protected here for future markdown-rs node changes.  Block quotes are
782    // deliberately *not* protected: their ordinary inline MDI remains valid;
783    // only flow markers are excluded in `prepare_block_markers`.
784    let protected = protected || matches!(node_type.as_str(), "code" | "inlineCode" | "html");
785    let span = object
786        .remove("position")
787        .as_ref()
788        .and_then(|position| span_from_position(position, source));
789    if let Some(span) = span {
790        object.insert("span".to_owned(), serde_json::json!(span));
791    }
792    if let Some(children) = object
793        .get_mut("children")
794        .and_then(serde_json::Value::as_array_mut)
795    {
796        for child in children.iter_mut() {
797            annotate_and_lower(child, source, protected);
798        }
799        let mut flattened = Vec::with_capacity(children.len());
800        for child in children.drain(..) {
801            if child.get("type").and_then(serde_json::Value::as_str) == Some("mdiFragment")
802                && let Some(mut fragment) = child
803                    .get("children")
804                    .and_then(serde_json::Value::as_array)
805                    .cloned()
806            {
807                flattened.append(&mut fragment);
808                continue;
809            }
810            flattened.push(child);
811        }
812        *children = flattened;
813    }
814    if protected || node_type != "text" {
815        return;
816    }
817    let Some(rendered_value) = object.get("value").and_then(serde_json::Value::as_str) else {
818        return;
819    };
820    // Markdown may consume a backslash while decoding a text node.  MDI must
821    // still recognize the decoded spelling (notably `\|` inside a GFM table
822    // cell), but its spans must refer to the original bytes.  Keep a mapping
823    // from decoded byte boundaries back to the source range for that case.
824    let source_offsets = span
825        .as_ref()
826        .and_then(|span| source.get(span.start_byte as usize..span.end_byte as usize))
827        .and_then(|raw| decoded_byte_offsets(rendered_value, raw));
828    if !looks_like_mdi(rendered_value) {
829        return;
830    }
831    let span = object.get("span").cloned();
832    let parsed = parse_inline_parts(rendered_value);
833    if let Some((Inline::Text(value), _, _)) = parsed.first()
834        && parsed.len() == 1
835        && value == rendered_value
836    {
837        return;
838    }
839    let replacement: Vec<serde_json::Value> = parsed
840        .into_iter()
841        .map(|(inline, start, end)| {
842            let mut value = serde_json::to_value(inline).expect("MDI inline is serializable");
843            if let (Some(token_span), Some(object)) = (&span, value.as_object_mut()) {
844                let start_byte = token_span
845                    .get("startByte")
846                    .and_then(serde_json::Value::as_u64);
847                if let Some(start_byte) = start_byte {
848                    let start = source_offsets
849                        .as_ref()
850                        .and_then(|offsets| source_offset(offsets, start))
851                        .unwrap_or(start);
852                    let end = source_offsets
853                        .as_ref()
854                        .and_then(|offsets| source_offset(offsets, end))
855                        .unwrap_or(end);
856                    object.insert(
857                        "span".to_owned(),
858                        serde_json::json!(SourceSpan {
859                            start_byte: (start_byte as usize + start) as u32,
860                            end_byte: (start_byte as usize + end) as u32,
861                        }),
862                    );
863                }
864            }
865            value
866        })
867        .collect();
868    *node = serde_json::json!({ "type": "mdiFragment", "children": replacement, "span": span });
869}
870
871fn looks_like_mdi(value: &str) -> bool {
872    value.contains(['{', '^', '《', '[', '\\'])
873}
874
875fn span_from_position(value: &serde_json::Value, source: &str) -> Option<SourceSpan> {
876    let start = value.pointer("/start/offset")?.as_u64()? as usize;
877    let end = value.pointer("/end/offset")?.as_u64()? as usize;
878    Some(SourceSpan {
879        start_byte: character_offset_to_byte(source, start) as u32,
880        end_byte: character_offset_to_byte(source, end) as u32,
881    })
882}
883
884fn character_offset_to_byte(source: &str, offset: usize) -> usize {
885    // `markdown-rs` stores this field as a UTF-8 byte offset.
886    offset.min(source.len())
887}
888
889fn extract_frontmatter(root: &serde_json::Value, source: &str) -> Option<Frontmatter> {
890    let yaml = root.get("children")?.as_array()?.first()?;
891    if yaml.get("type")?.as_str()? != "yaml" {
892        return None;
893    }
894    let raw = yaml.get("value")?.as_str()?.to_owned();
895    let span = yaml
896        .get("position")
897        .and_then(|value| span_from_position(value, source))?;
898    let entries = match serde_yaml::from_str::<serde_yaml::Value>(&raw) {
899        Ok(serde_yaml::Value::Mapping(mapping)) => mapping
900            .into_iter()
901            .filter_map(|(key, value)| {
902                let key = key.as_str()?.to_owned();
903                let value = serde_json::to_value(value).ok()?;
904                Some(FrontmatterEntry { key, value })
905            })
906            .collect(),
907        _ => Vec::new(),
908    };
909    Some(Frontmatter { span, raw, entries })
910}
911
912fn diagnostics(document: &Document) -> Vec<Diagnostic> {
913    let Some(frontmatter) = document.frontmatter.as_ref() else {
914        return Vec::new();
915    };
916    let declared = frontmatter.entries.iter().find(|entry| entry.key == "mdi");
917    let Some(declared) = declared.and_then(|entry| entry.value.as_str()) else {
918        return Vec::new();
919    };
920    if declared > MDI_SPEC_VERSION {
921        vec![Diagnostic {
922            severity: DiagnosticSeverity::Warning,
923            code: "mdi.version.unsupported".to_owned(),
924            message: format!("MDI {declared} is newer than the supported {MDI_SPEC_VERSION}"),
925            span: Some(frontmatter.span),
926        }]
927    } else {
928        Vec::new()
929    }
930}
931
932/// Parse the complete CommonMark, GFM, front-matter, and MDI document and
933/// return the versioned wire envelope used by language bindings.
934pub fn parse_output(source: &str) -> ParseOutput {
935    let document = parse_document(source);
936    ParseOutput {
937        ir_version: MDI_IR_VERSION,
938        syntax_version: MDI_SPEC_VERSION,
939        capabilities: ParserCapabilities {
940            mdi: true,
941            common_mark: true,
942            gfm: true,
943            front_matter: true,
944            source_spans: true,
945        },
946        diagnostics: diagnostics(&document),
947        document,
948    }
949}
950
951/// Serialize [`parse_output`] for FFI boundaries.
952///
953/// JSON is the first portable contract because it behaves identically through
954/// WebAssembly, N-API, PyO3, and a C ABI. Native bindings may later provide
955/// zero-copy views without changing this wire representation.
956pub fn parse_json(source: &str) -> String {
957    serde_json::to_string(&parse_output(source))
958        .expect("serializing the MDI parse output cannot fail")
959}
960
961/// Stable C ABI used by native language bindings.
962///
963/// Every operation accepts UTF-8 bytes and returns owned bytes. Callers must
964/// release both buffers in an [`MdiFfiResult`] with [`mdi_free_buffer`]. The
965/// JSON returned by `mdi_parse_json` is the same versioned wire contract used
966/// by the JavaScript and future Python bindings.
967#[allow(unsafe_code)]
968pub mod ffi {
969    use super::{
970        TextFormat, parse_json, render_docx, render_epub, render_html, render_text,
971        render_text_format, serialize_mdi,
972    };
973    use std::slice;
974
975    #[repr(C)]
976    #[derive(Debug, Clone, Copy)]
977    pub struct MdiFfiBuffer {
978        pub data: *mut u8,
979        pub len: usize,
980    }
981
982    #[repr(C)]
983    #[derive(Debug, Clone, Copy)]
984    pub struct MdiFfiResult {
985        pub value: MdiFfiBuffer,
986        pub error: MdiFfiBuffer,
987    }
988
989    fn empty_buffer() -> MdiFfiBuffer {
990        MdiFfiBuffer {
991            data: std::ptr::null_mut(),
992            len: 0,
993        }
994    }
995
996    fn buffer(value: Vec<u8>) -> MdiFfiBuffer {
997        if value.is_empty() {
998            return empty_buffer();
999        }
1000        let mut value = value.into_boxed_slice();
1001        let result = MdiFfiBuffer {
1002            data: value.as_mut_ptr(),
1003            len: value.len(),
1004        };
1005        std::mem::forget(value);
1006        result
1007    }
1008
1009    fn success(value: Vec<u8>) -> MdiFfiResult {
1010        MdiFfiResult {
1011            value: buffer(value),
1012            error: empty_buffer(),
1013        }
1014    }
1015
1016    fn failure(message: impl Into<String>) -> MdiFfiResult {
1017        MdiFfiResult {
1018            value: empty_buffer(),
1019            error: buffer(message.into().into_bytes()),
1020        }
1021    }
1022
1023    fn utf8_argument<'a>(data: *const u8, len: usize, name: &str) -> Result<&'a str, String> {
1024        if data.is_null() && len != 0 {
1025            return Err(format!("{name} pointer is null"));
1026        }
1027        let bytes = if len == 0 {
1028            &[]
1029        } else {
1030            unsafe { slice::from_raw_parts(data, len) }
1031        };
1032        std::str::from_utf8(bytes).map_err(|_| format!("{name} must be valid UTF-8"))
1033    }
1034
1035    fn source<'a>(data: *const u8, len: usize) -> Result<&'a str, String> {
1036        utf8_argument(data, len, "MDI source")
1037    }
1038
1039    fn string_result(
1040        data: *const u8,
1041        len: usize,
1042        operation: impl FnOnce(&str) -> String,
1043    ) -> MdiFfiResult {
1044        match source(data, len) {
1045            Ok(source) => success(operation(source).into_bytes()),
1046            Err(error) => failure(error),
1047        }
1048    }
1049
1050    #[unsafe(no_mangle)]
1051    pub extern "C" fn mdi_parse_json(data: *const u8, len: usize) -> MdiFfiResult {
1052        string_result(data, len, parse_json)
1053    }
1054    #[unsafe(no_mangle)]
1055    pub extern "C" fn mdi_render_html(data: *const u8, len: usize) -> MdiFfiResult {
1056        string_result(data, len, render_html)
1057    }
1058    #[unsafe(no_mangle)]
1059    pub extern "C" fn mdi_serialize_mdi(data: *const u8, len: usize) -> MdiFfiResult {
1060        string_result(data, len, serialize_mdi)
1061    }
1062    #[unsafe(no_mangle)]
1063    pub extern "C" fn mdi_render_text(data: *const u8, len: usize) -> MdiFfiResult {
1064        string_result(data, len, render_text)
1065    }
1066    #[unsafe(no_mangle)]
1067    pub extern "C" fn mdi_render_text_format(
1068        data: *const u8,
1069        len: usize,
1070        format_data: *const u8,
1071        format_len: usize,
1072        indent_data: *const u8,
1073        indent_len: usize,
1074    ) -> MdiFfiResult {
1075        let result = source(data, len).and_then(|source| {
1076            let format = utf8_argument(format_data, format_len, "MDI text format")?;
1077            let indent_prefix = utf8_argument(indent_data, indent_len, "MDI text indent prefix")?;
1078            let format = TextFormat::parse(format)
1079                .ok_or_else(|| format!("Unsupported text format: {format}"))?;
1080            Ok(render_text_format(source, format, indent_prefix))
1081        });
1082        match result {
1083            Ok(value) => success(value.into_bytes()),
1084            Err(error) => failure(error),
1085        }
1086    }
1087
1088    fn binary_result(
1089        data: *const u8,
1090        len: usize,
1091        operation: impl FnOnce(&str) -> Result<Vec<u8>, String>,
1092    ) -> MdiFfiResult {
1093        match source(data, len).and_then(operation) {
1094            Ok(value) => success(value),
1095            Err(error) => failure(error),
1096        }
1097    }
1098
1099    #[unsafe(no_mangle)]
1100    pub extern "C" fn mdi_render_epub(data: *const u8, len: usize) -> MdiFfiResult {
1101        binary_result(data, len, render_epub)
1102    }
1103    #[unsafe(no_mangle)]
1104    pub extern "C" fn mdi_render_docx(data: *const u8, len: usize) -> MdiFfiResult {
1105        binary_result(data, len, render_docx)
1106    }
1107
1108    /// Releases a buffer returned by this module.
1109    ///
1110    /// # Safety
1111    ///
1112    /// `buffer` must have been returned by one of this module's functions and
1113    /// each non-empty buffer must be passed here at most once.
1114    #[unsafe(no_mangle)]
1115    pub unsafe extern "C" fn mdi_free_buffer(buffer: MdiFfiBuffer) {
1116        if !buffer.data.is_null() && buffer.len != 0 {
1117            unsafe {
1118                drop(Vec::from_raw_parts(buffer.data, buffer.len, buffer.len));
1119            }
1120        }
1121    }
1122}
1123
1124/// Render a complete source document to a standalone HTML document directly
1125/// from the Rust-owned IR.  This is intentionally source-oriented for FFI:
1126/// bindings pass UTF-8 source once and never reconstruct MDI syntax in a host
1127/// renderer.
1128pub fn render_html(source: &str) -> String {
1129    render_html_document(&parse_document(source))
1130}
1131
1132/// Render a previously parsed document to standalone HTML.
1133pub fn render_html_document(document: &Document) -> String {
1134    let frontmatter = document.frontmatter.as_ref();
1135    let field = |key: &str| {
1136        frontmatter
1137            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1138            .and_then(|entry| entry.value.as_str())
1139    };
1140    let lang = field("lang").unwrap_or("ja");
1141    let title = field("title")
1142        .map(|title| format!("<title>{}</title>", escape_html(title)))
1143        .unwrap_or_default();
1144    let vertical = matches!(field("writing-mode"), Some("vertical"));
1145    let writing_mode = if vertical {
1146        " style=\"writing-mode: vertical-rl;\""
1147    } else {
1148        ""
1149    };
1150    let mut body = String::new();
1151    let mut footnotes = Vec::new();
1152    for child in &document.children {
1153        if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
1154            footnotes.push(child);
1155        } else {
1156            render_html_node(child, &mut body);
1157        }
1158    }
1159    if !footnotes.is_empty() {
1160        body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\" id=\"footnote-label\">Footnotes</h2><ol>");
1161        for (index, footnote) in footnotes.into_iter().enumerate() {
1162            let identifier = footnote
1163                .get("identifier")
1164                .and_then(serde_json::Value::as_str)
1165                .map(str::to_owned)
1166                .unwrap_or_else(|| format!("{}", index + 1));
1167            body.push_str("<li id=\"user-content-fn-");
1168            body.push_str(&escape_html(&identifier));
1169            body.push_str("\">");
1170            render_html_children(footnote, &mut body);
1171            body.push_str(" <a href=\"#user-content-fnref-");
1172            body.push_str(&escape_html(&identifier));
1173            body.push_str("\" data-footnote-backref=\"\" aria-label=\"Back to reference\" class=\"data-footnote-backref\">↩</a>");
1174            body.push_str("</li>");
1175        }
1176        body.push_str("</ol></section>");
1177    }
1178    format!(
1179        "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
1180        escape_html(lang),
1181        writing_mode,
1182        title,
1183        MDI_STYLESHEET,
1184        body
1185    )
1186}
1187
1188/// Parse and serialize source to canonical MDI/Markdown spelling in Rust.
1189pub fn serialize_mdi(source: &str) -> String {
1190    serialize_mdi_document(&parse_document(source))
1191}
1192
1193/// Serialize a parsed document without invoking a host Markdown serializer.
1194pub fn serialize_mdi_document(document: &Document) -> String {
1195    let mut output = String::new();
1196    if let Some(frontmatter) = &document.frontmatter {
1197        output.push_str("---\n");
1198        output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1199        output.push_str("\n---\n\n");
1200    }
1201    for (index, node) in document.children.iter().enumerate() {
1202        if index > 0 && !output.ends_with("\n\n") {
1203            output.push('\n');
1204        }
1205        serialize_block(node, &mut output, "");
1206    }
1207    output
1208}
1209
1210/// Render source to deterministic plain text from the Rust IR. Typography
1211/// annotations are represented by their readable base text; page and blank
1212/// paragraph boundaries remain visible as newlines.
1213pub fn render_text(source: &str) -> String {
1214    render_text_document(&parse_document(source))
1215}
1216
1217/// Render a parsed document to plain text.
1218pub fn render_text_document(document: &Document) -> String {
1219    let mut output = String::new();
1220    for node in &document.children {
1221        render_text_node(node, &mut output);
1222        if !output.ends_with('\n') {
1223            output.push('\n');
1224        }
1225    }
1226    output
1227}
1228
1229/// Text export conventions supported by the portable core.
1230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1231pub enum TextFormat {
1232    Plain,
1233    Ruby,
1234    Narou,
1235    Kakuyomu,
1236    Aozora,
1237    Note,
1238}
1239
1240impl TextFormat {
1241    /// Parse the stable binding names used by the CLI and JavaScript API.
1242    pub fn parse(value: &str) -> Option<Self> {
1243        match value {
1244            "txt" => Some(Self::Plain),
1245            "txt-ruby" => Some(Self::Ruby),
1246            "narou" => Some(Self::Narou),
1247            "kakuyomu" => Some(Self::Kakuyomu),
1248            "aozora" => Some(Self::Aozora),
1249            "note" => Some(Self::Note),
1250            _ => None,
1251        }
1252    }
1253}
1254
1255/// Render a publication-platform text variant from Rust IR. `indent_prefix`
1256/// is supplied by the host's already-resolved export profile.
1257pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1258    let document = parse_document(source);
1259    if matches!(format, TextFormat::Note) {
1260        return render_note_document(&document, indent_prefix);
1261    }
1262    let mut heading_depths = document
1263        .children
1264        .iter()
1265        .filter(|node| node.get("type").and_then(serde_json::Value::as_str) == Some("heading"))
1266        .filter_map(|node| node.get("depth").and_then(serde_json::Value::as_u64))
1267        .collect::<Vec<_>>();
1268    heading_depths.sort_unstable();
1269    heading_depths.dedup();
1270    let definitions: Vec<&serde_json::Value> = document
1271        .children
1272        .iter()
1273        .filter(|node| {
1274            node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1275        })
1276        .collect();
1277    let mut blocks = Vec::new();
1278    for node in &document.children {
1279        text_format_block(
1280            node,
1281            format,
1282            indent_prefix,
1283            &definitions,
1284            &heading_depths,
1285            &mut blocks,
1286        );
1287    }
1288    if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1289        blocks.push(String::new());
1290        blocks.push("Footnotes".to_owned());
1291        for (index, definition) in definitions.iter().enumerate() {
1292            let text = children(definition)
1293                .iter()
1294                .filter(|child| {
1295                    child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1296                })
1297                .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1298                .collect::<Vec<_>>()
1299                .join(" ");
1300            blocks.push(format!("{}. {text}", index + 1));
1301        }
1302    }
1303    if matches!(format, TextFormat::Aozora) && heading_depths.len() > 3 {
1304        blocks.push(String::new());
1305        blocks.push("※小見出しよりもさらに下位の見出しには、注記しませんでした。".to_owned());
1306    }
1307    let output = blocks.join("\n");
1308    if matches!(format, TextFormat::Aozora) {
1309        output.replace('\n', "\r\n")
1310    } else {
1311        output
1312    }
1313}
1314
1315fn render_note_document(document: &Document, indent_prefix: &str) -> String {
1316    let definitions: Vec<&serde_json::Value> = document
1317        .children
1318        .iter()
1319        .filter(|node| {
1320            node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1321        })
1322        .collect();
1323    let mut blocks = document
1324        .children
1325        .iter()
1326        .filter_map(|node| {
1327            note_format_block(node, indent_prefix, &definitions, NoteInlineContext::Body)
1328        })
1329        .collect::<Vec<_>>();
1330    if !definitions.is_empty() {
1331        let mut footnotes = vec!["注".to_owned()];
1332        for (index, definition) in definitions.iter().enumerate() {
1333            let value = children(definition)
1334                .iter()
1335                .filter_map(|child| {
1336                    note_format_block(child, "", &definitions, NoteInlineContext::Body)
1337                })
1338                .collect::<Vec<_>>()
1339                .join(" ");
1340            footnotes.push(format!("{}. {value}", index + 1));
1341        }
1342        blocks.push("---".to_owned());
1343        blocks.push(footnotes.join("\n"));
1344    }
1345    blocks.join("\n\n")
1346}
1347
1348#[derive(Clone, Copy, PartialEq, Eq)]
1349enum NoteInlineContext {
1350    Body,
1351    Heading,
1352    Quote,
1353}
1354
1355fn note_format_block(
1356    node: &serde_json::Value,
1357    indent_prefix: &str,
1358    definitions: &[&serde_json::Value],
1359    context: NoteInlineContext,
1360) -> Option<String> {
1361    let kind = node
1362        .get("type")
1363        .and_then(serde_json::Value::as_str)
1364        .unwrap_or_default();
1365    match kind {
1366        "footnoteDefinition" | "definition" => None,
1367        "paragraph" => {
1368            let indent = node
1369                .get("indent")
1370                .and_then(serde_json::Value::as_u64)
1371                .map(|amount| " ".repeat(amount as usize))
1372                .unwrap_or_default();
1373            Some(format!(
1374                "{indent_prefix}{indent}{}",
1375                note_inline_children(node, definitions, context)
1376            ))
1377        }
1378        "heading" => {
1379            let marker = if node
1380                .get("depth")
1381                .and_then(serde_json::Value::as_u64)
1382                .unwrap_or(1)
1383                == 1
1384            {
1385                "##"
1386            } else {
1387                "###"
1388            };
1389            Some(format!(
1390                "{marker} {}",
1391                note_inline_children(node, definitions, NoteInlineContext::Heading)
1392            ))
1393        }
1394        "list" => Some(note_format_list(node, 0, definitions, context)),
1395        "blockquote" => {
1396            let value = children(node)
1397                .iter()
1398                .filter_map(|child| {
1399                    note_format_block(child, "", definitions, NoteInlineContext::Quote)
1400                })
1401                .collect::<Vec<_>>()
1402                .join("\n\n");
1403            Some(
1404                value
1405                    .lines()
1406                    .map(|line| format!("> {line}"))
1407                    .collect::<Vec<_>>()
1408                    .join("\n"),
1409            )
1410        }
1411        "code" => Some(note_code_block(
1412            node.get("value")
1413                .and_then(serde_json::Value::as_str)
1414                .unwrap_or_default(),
1415            node.get("lang").and_then(serde_json::Value::as_str),
1416        )),
1417        "math" => Some(format!(
1418            "$$\n{}\n$$",
1419            node.get("value")
1420                .and_then(serde_json::Value::as_str)
1421                .unwrap_or_default()
1422        )),
1423        "table" => Some(
1424            children(node)
1425                .iter()
1426                .map(|row| {
1427                    children(row)
1428                        .iter()
1429                        .map(|cell| note_inline_children(cell, definitions, context))
1430                        .collect::<Vec<_>>()
1431                        .join("\t")
1432                })
1433                .collect::<Vec<_>>()
1434                .join("\n"),
1435        ),
1436        "thematicBreak" => Some("---".to_owned()),
1437        // note has no pagination paste syntax.  A visual divider preserves the
1438        // source boundary but does not claim to retain pagination semantics.
1439        "pagebreak" => Some("---".to_owned()),
1440        "blank" => Some(String::new()),
1441        "html" => Some(note_code_block(
1442            node.get("value")
1443                .and_then(serde_json::Value::as_str)
1444                .unwrap_or_default(),
1445            Some("html"),
1446        )),
1447        _ if !children(node).is_empty() => Some(note_inline_children(node, definitions, context)),
1448        _ => None,
1449    }
1450}
1451
1452fn note_format_list(
1453    node: &serde_json::Value,
1454    depth: usize,
1455    definitions: &[&serde_json::Value],
1456    context: NoteInlineContext,
1457) -> String {
1458    // note's editor supports five list levels, but its documented hierarchy
1459    // controls are Tab/Shift+Tab (or their shortcuts), not space indentation.
1460    // Indentation here is therefore a readable visual fallback, clamped at
1461    // five levels rather than a claim that paste will create nested list nodes.
1462    let indentation = "  ".repeat(depth.min(4));
1463    let continuation = "  ".repeat((depth + 1).min(5));
1464    let ordered = node
1465        .get("ordered")
1466        .and_then(serde_json::Value::as_bool)
1467        .unwrap_or(false);
1468    let start = node
1469        .get("start")
1470        .and_then(serde_json::Value::as_u64)
1471        .unwrap_or(1);
1472    let mut lines = Vec::new();
1473    for (index, item) in children(node).iter().enumerate() {
1474        let marker = if ordered {
1475            format!("{}.", start + index as u64)
1476        } else {
1477            "-".to_owned()
1478        };
1479        let checked = match item.get("checked").and_then(serde_json::Value::as_bool) {
1480            Some(true) => "[x] ",
1481            Some(false) => "[ ] ",
1482            None => "",
1483        };
1484        let mut item_started = false;
1485        for child in children(item) {
1486            if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1487                && !item_started
1488            {
1489                lines.push(format!(
1490                    "{indentation}{marker} {checked}{}",
1491                    note_inline_children(child, definitions, context)
1492                ));
1493                item_started = true;
1494                continue;
1495            }
1496            if child.get("type").and_then(serde_json::Value::as_str) == Some("list") {
1497                if !item_started {
1498                    lines.push(format!("{indentation}{marker} {checked}"));
1499                    item_started = true;
1500                }
1501                lines.push(note_format_list(child, depth + 1, definitions, context));
1502                continue;
1503            }
1504            if let Some(value) = note_format_block(child, "", definitions, context) {
1505                if !item_started {
1506                    lines.push(format!("{indentation}{marker} {checked}"));
1507                    item_started = true;
1508                }
1509                lines.extend(value.lines().map(|line| format!("{continuation}{line}")));
1510            }
1511        }
1512        if !item_started {
1513            lines.push(format!("{indentation}{marker} {checked}"));
1514        }
1515    }
1516    lines.join("\n")
1517}
1518
1519fn note_inline_children(
1520    node: &serde_json::Value,
1521    definitions: &[&serde_json::Value],
1522    context: NoteInlineContext,
1523) -> String {
1524    children(node)
1525        .iter()
1526        .map(|child| note_inline(child, definitions, context))
1527        .collect()
1528}
1529
1530fn note_inline(
1531    node: &serde_json::Value,
1532    definitions: &[&serde_json::Value],
1533    context: NoteInlineContext,
1534) -> String {
1535    let kind = node
1536        .get("type")
1537        .and_then(serde_json::Value::as_str)
1538        .unwrap_or_default();
1539    match kind {
1540        "text" => note_text_literal(
1541            node.get("value")
1542                .and_then(serde_json::Value::as_str)
1543                .unwrap_or_default(),
1544        ),
1545        // note documents fenced code blocks, not inline-code Markdown.  Keep
1546        // the code readable without emitting a marker the editor may not own.
1547        "inlineCode" => note_text_literal(
1548            node.get("value")
1549                .and_then(serde_json::Value::as_str)
1550                .unwrap_or_default(),
1551        ),
1552        "inlineMath" => {
1553            let value = node
1554                .get("value")
1555                .and_then(serde_json::Value::as_str)
1556                .unwrap_or_default();
1557            if matches!(context, NoteInlineContext::Body) {
1558                format!("$${{{value}}}$$")
1559            } else {
1560                note_text_literal(value)
1561            }
1562        }
1563        "tcy" => note_text_literal(
1564            node.get("value")
1565                .and_then(serde_json::Value::as_str)
1566                .unwrap_or_default(),
1567        ),
1568        "break" => "\n".to_owned(),
1569        "ruby" => {
1570            let base = node
1571                .get("base")
1572                .and_then(serde_json::Value::as_str)
1573                .unwrap_or_default();
1574            let reading = node
1575                .pointer("/ruby/value")
1576                .map(|value| match value {
1577                    serde_json::Value::Array(parts) => parts
1578                        .iter()
1579                        .filter_map(serde_json::Value::as_str)
1580                        .collect::<String>(),
1581                    serde_json::Value::String(value) => value.to_owned(),
1582                    _ => String::new(),
1583                })
1584                .unwrap_or_default();
1585            text_format_platform_ruby(base, &reading, TextFormat::Note)
1586        }
1587        "strong" => {
1588            let value = note_inline_children(node, definitions, context);
1589            if matches!(context, NoteInlineContext::Heading) {
1590                value
1591            } else {
1592                // note activates this input shortcut after a following
1593                // half-width space is entered.
1594                format!("**{value}** ")
1595            }
1596        }
1597        "delete" => {
1598            let value = note_inline_children(node, definitions, context);
1599            if matches!(context, NoteInlineContext::Heading) {
1600                value
1601            } else {
1602                // note activates this input shortcut after a following
1603                // half-width space is entered.
1604                format!("~~{value}~~ ")
1605            }
1606        }
1607        "link" => {
1608            let label = note_inline_children(node, definitions, context);
1609            let url = node
1610                .get("url")
1611                .and_then(serde_json::Value::as_str)
1612                .unwrap_or_default();
1613            let title_value = node
1614                .get("title")
1615                .and_then(serde_json::Value::as_str)
1616                .filter(|title| !title.is_empty() && !title.contains(['\r', '\n']));
1617            if title_value.is_none() && label == note_text_literal(url) {
1618                return note_text_literal(url);
1619            }
1620            let title = title_value
1621                .map(|title| format!(" — {title}"))
1622                .unwrap_or_default();
1623            if url.is_empty() {
1624                format!("{label}{title}")
1625            } else {
1626                format!("{label} ({}){title}", note_text_literal(url))
1627            }
1628        }
1629        "image" => {
1630            let alt = note_text_literal(
1631                node.get("alt")
1632                    .and_then(serde_json::Value::as_str)
1633                    .unwrap_or_default(),
1634            );
1635            let url = node
1636                .get("url")
1637                .and_then(serde_json::Value::as_str)
1638                .unwrap_or_default();
1639            if url.is_empty() {
1640                format!("画像: {alt}")
1641            } else {
1642                format!("画像: {alt} ({})", note_text_literal(url))
1643            }
1644        }
1645        "html" => note_text_literal(
1646            node.get("value")
1647                .and_then(serde_json::Value::as_str)
1648                .unwrap_or_default(),
1649        ),
1650        "footnoteReference" => {
1651            let identifier = node
1652                .get("identifier")
1653                .and_then(serde_json::Value::as_str)
1654                .unwrap_or_default();
1655            let index = definitions
1656                .iter()
1657                .position(|definition| {
1658                    definition
1659                        .get("identifier")
1660                        .and_then(serde_json::Value::as_str)
1661                        == Some(identifier)
1662                })
1663                .map(|index| index + 1)
1664                .unwrap_or(0);
1665            format!("[注{index}]")
1666        }
1667        // note has no paste syntax for these MDI presentation annotations or
1668        // Markdown emphasis, so retain their readable content.
1669        "emphasis" | "em" | "warichu" | "kern" | "noBreak" => {
1670            note_inline_children(node, definitions, context)
1671        }
1672        _ => note_inline_children(node, definitions, context),
1673    }
1674}
1675
1676fn note_code_block(value: &str, language: Option<&str>) -> String {
1677    let longest_run = longest_backtick_run(value);
1678    let fence = "`".repeat(longest_run.saturating_add(1).max(3));
1679    let language = language
1680        .filter(|language| !language.is_empty() && !language.contains(['`', '\r', '\n', ' ', '\t']))
1681        // note documents Mermaid only with an exact triple-backtick fence.
1682        // If the body forces a longer fence, keep the code readable without
1683        // falsely labelling it as a Mermaid contract.
1684        .filter(|language| *language != "mermaid" || longest_run < 3)
1685        .unwrap_or_default();
1686    let trailing_newline = if value.ends_with('\n') { "" } else { "\n" };
1687    format!("{fence}{language}\n{value}{trailing_newline}{fence}")
1688}
1689
1690fn longest_backtick_run(value: &str) -> usize {
1691    value
1692        .split(|character| character != '`')
1693        .map(str::len)
1694        .max()
1695        .unwrap_or(0)
1696}
1697
1698fn note_text_literal(value: &str) -> String {
1699    // note does not document a backslash escape for editor shortcuts or ruby.
1700    // Inventing one would visibly corrupt ordinary punctuation.  Preserve
1701    // literal text and document that delimiter collisions cannot be represented
1702    // losslessly by this plain-text profile.
1703    value.to_owned()
1704}
1705
1706fn text_format_block(
1707    node: &serde_json::Value,
1708    format: TextFormat,
1709    prefix: &str,
1710    definitions: &[&serde_json::Value],
1711    heading_depths: &[u64],
1712    output: &mut Vec<String>,
1713) {
1714    let kind = node
1715        .get("type")
1716        .and_then(serde_json::Value::as_str)
1717        .unwrap_or_default();
1718    match kind {
1719        "footnoteDefinition" | "definition" => {}
1720        "paragraph" => {
1721            let value = text_format_inline_children(node, format, definitions);
1722            let block_prefix = text_format_block_prefix(node, format);
1723            output.push(format!("{prefix}{block_prefix}{value}"));
1724        }
1725        "heading" => {
1726            let value = text_format_inline_children(node, format, definitions);
1727            if matches!(format, TextFormat::Aozora) {
1728                let depth = node
1729                    .get("depth")
1730                    .and_then(serde_json::Value::as_u64)
1731                    .unwrap_or(3);
1732                if let Some(size) = aozora_heading_size(depth, heading_depths) {
1733                    let reference = text_format_plain_inline_children(node);
1734                    if aozora_needs_range_annotation(node) {
1735                        output.push(format!("[#{size}見出し]{value}[#{size}見出し終わり]"));
1736                    } else {
1737                        output.push(format!("{value}[#「{reference}」は{size}見出し]"));
1738                    }
1739                } else {
1740                    output.push(value);
1741                }
1742            } else {
1743                output.push(value);
1744            }
1745        }
1746        "list" => {
1747            for (index, item) in children(node).iter().enumerate() {
1748                for child in children(item) {
1749                    if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1750                        let bullet = if node
1751                            .get("ordered")
1752                            .and_then(serde_json::Value::as_bool)
1753                            .unwrap_or(false)
1754                        {
1755                            format!("{}. ", index + 1)
1756                        } else {
1757                            "- ".to_owned()
1758                        };
1759                        output.push(format!(
1760                            "{prefix}{bullet}{}",
1761                            text_format_inline_children(child, format, definitions)
1762                        ));
1763                    } else {
1764                        text_format_block(
1765                            child,
1766                            format,
1767                            prefix,
1768                            definitions,
1769                            heading_depths,
1770                            output,
1771                        );
1772                    }
1773                }
1774            }
1775        }
1776        "blockquote" => {
1777            for child in children(node) {
1778                text_format_block(child, format, prefix, definitions, heading_depths, output);
1779            }
1780        }
1781        "code" => output.extend(
1782            node.get("value")
1783                .and_then(serde_json::Value::as_str)
1784                .unwrap_or_default()
1785                .lines()
1786                .map(|line| text_format_literal(line, format)),
1787        ),
1788        "table" => {
1789            for row in children(node) {
1790                output.push(
1791                    children(row)
1792                        .iter()
1793                        .map(|cell| text_format_inline_children(cell, format, definitions))
1794                        .collect::<Vec<_>>()
1795                        .join("\t"),
1796                );
1797            }
1798        }
1799        "thematicBreak" => output.push("――――――".to_owned()),
1800        "blank" => output.push(String::new()),
1801        "pagebreak" => {
1802            if matches!(format, TextFormat::Aozora) {
1803                let annotation = match node.get("variant").and_then(serde_json::Value::as_str) {
1804                    Some("left") => "[#改丁]",
1805                    Some("right") => "[#改見開き]",
1806                    _ => "[#改ページ]",
1807                };
1808                output.push(annotation.to_owned());
1809            } else {
1810                output.push(String::new());
1811            }
1812        }
1813        _ => {}
1814    }
1815}
1816
1817fn aozora_heading_size(depth: u64, heading_depths: &[u64]) -> Option<&'static str> {
1818    let index = heading_depths
1819        .iter()
1820        .position(|candidate| *candidate == depth)?;
1821    match heading_depths.len() {
1822        1 => Some("中"),
1823        2 => ["大", "中"].get(index).copied(),
1824        _ => ["大", "中", "小"].get(index).copied(),
1825    }
1826}
1827
1828fn text_format_block_prefix(node: &serde_json::Value, format: TextFormat) -> String {
1829    let indent = node
1830        .get("indent")
1831        .and_then(serde_json::Value::as_u64)
1832        .filter(|amount| *amount > 0);
1833    let bottom = node.get("bottom").and_then(serde_json::Value::as_u64);
1834    if matches!(format, TextFormat::Aozora) {
1835        if let Some(amount) = bottom {
1836            return if amount == 0 {
1837                "[#地付き]".to_owned()
1838            } else {
1839                format!("[#地から{}字上げ]", fullwidth_digits(amount))
1840            };
1841        }
1842        return indent
1843            .map(|amount| format!("[#{}字下げ]", fullwidth_digits(amount)))
1844            .unwrap_or_default();
1845    }
1846    indent
1847        .map(|amount| " ".repeat(amount as usize))
1848        .unwrap_or_default()
1849}
1850
1851fn fullwidth_digits(value: u64) -> String {
1852    value
1853        .to_string()
1854        .replace('0', "0")
1855        .replace('1', "1")
1856        .replace('2', "2")
1857        .replace('3', "3")
1858        .replace('4', "4")
1859        .replace('5', "5")
1860        .replace('6', "6")
1861        .replace('7', "7")
1862        .replace('8', "8")
1863        .replace('9', "9")
1864}
1865
1866fn text_format_inline_children(
1867    node: &serde_json::Value,
1868    format: TextFormat,
1869    definitions: &[&serde_json::Value],
1870) -> String {
1871    children(node)
1872        .iter()
1873        .map(|node| text_format_inline(node, format, definitions))
1874        .collect()
1875}
1876fn text_format_inline(
1877    node: &serde_json::Value,
1878    format: TextFormat,
1879    definitions: &[&serde_json::Value],
1880) -> String {
1881    match node
1882        .get("type")
1883        .and_then(serde_json::Value::as_str)
1884        .unwrap_or_default()
1885    {
1886        "text" | "inlineCode" => text_format_literal(
1887            node.get("value")
1888                .and_then(serde_json::Value::as_str)
1889                .unwrap_or_default(),
1890            format,
1891        ),
1892        "tcy" => {
1893            let value = text_format_literal(
1894                node.get("value")
1895                    .and_then(serde_json::Value::as_str)
1896                    .unwrap_or_default(),
1897                format,
1898            );
1899            if matches!(format, TextFormat::Aozora) {
1900                format!("{value}[#「{value}」は縦中横]")
1901            } else {
1902                value
1903            }
1904        }
1905        "break" => "\n".to_owned(),
1906        "ruby" => {
1907            let base = node
1908                .get("base")
1909                .and_then(serde_json::Value::as_str)
1910                .unwrap_or_default();
1911            let reading = node
1912                .pointer("/ruby/value")
1913                .map(|value| match value {
1914                    serde_json::Value::Array(parts) => parts
1915                        .iter()
1916                        .filter_map(serde_json::Value::as_str)
1917                        .collect::<Vec<_>>()
1918                        .join(if matches!(format, TextFormat::Ruby) {
1919                            "."
1920                        } else {
1921                            ""
1922                        }),
1923                    serde_json::Value::String(value) => value.to_owned(),
1924                    _ => String::new(),
1925                })
1926                .unwrap_or_default();
1927            match format {
1928                TextFormat::Plain => base.to_owned(),
1929                TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1930                _ => text_format_platform_ruby(base, &reading, format),
1931            }
1932        }
1933        "em" => {
1934            let value = text_format_inline_children(node, format, definitions);
1935            match format {
1936                TextFormat::Aozora if !value.is_empty() && !value.contains('\n') => {
1937                    let name = aozora_boten_name(
1938                        node.get("mark")
1939                            .and_then(serde_json::Value::as_str)
1940                            .unwrap_or("﹅"),
1941                    );
1942                    format!("[#{name}]{value}[#{name}終わり]")
1943                }
1944                TextFormat::Kakuyomu
1945                    if !value.is_empty()
1946                        && !value.contains('\n')
1947                        && !value.contains('《')
1948                        && !node_contains_type(node, "ruby") =>
1949                {
1950                    format!("《《{value}》》")
1951                }
1952                TextFormat::Narou
1953                    if !value.is_empty()
1954                        && !value.contains('\n')
1955                        && !node_contains_type(node, "ruby") =>
1956                {
1957                    value
1958                        .graphemes(true)
1959                        .map(|character| {
1960                            text_format_platform_ruby(character, "・", TextFormat::Narou)
1961                        })
1962                        .collect()
1963                }
1964                _ => value,
1965            }
1966        }
1967        "image" => node
1968            .get("alt")
1969            .and_then(serde_json::Value::as_str)
1970            .filter(|alt| !alt.is_empty())
1971            .map(|alt| format!("[画像: {}]", text_format_literal(alt, format)))
1972            .unwrap_or_else(|| "[画像]".to_owned()),
1973        "warichu" => {
1974            let value = text_format_inline_children(node, format, definitions);
1975            if matches!(format, TextFormat::Aozora) && !value.is_empty() && !value.contains('\n') {
1976                format!("[#割り注]{value}[#割り注終わり]")
1977            } else {
1978                value
1979            }
1980        }
1981        "footnoteReference" => {
1982            if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1983                String::new()
1984            } else {
1985                let identifier = node
1986                    .get("identifier")
1987                    .and_then(serde_json::Value::as_str)
1988                    .unwrap_or_default();
1989                let index = definitions
1990                    .iter()
1991                    .position(|definition| {
1992                        definition
1993                            .get("identifier")
1994                            .and_then(serde_json::Value::as_str)
1995                            == Some(identifier)
1996                    })
1997                    .map(|index| index + 1)
1998                    .unwrap_or(0);
1999                if matches!(format, TextFormat::Aozora) {
2000                    format!("(注{index})")
2001                } else {
2002                    format!("[注{index}]")
2003                }
2004            }
2005        }
2006        _ => text_format_inline_children(node, format, definitions),
2007    }
2008}
2009
2010fn text_format_platform_ruby(base: &str, reading: &str, format: TextFormat) -> String {
2011    let valid = match format {
2012        TextFormat::Narou => {
2013            (1..=10).contains(&base.graphemes(true).count())
2014                && (1..=10).contains(&reading.graphemes(true).count())
2015                && !base.chars().any(narou_ruby_problem_character)
2016                && !reading.chars().any(narou_ruby_problem_character)
2017        }
2018        TextFormat::Kakuyomu => {
2019            (1..=20).contains(&base.graphemes(true).count())
2020                && (1..=50).contains(&reading.graphemes(true).count())
2021                && !base.contains(['\r', '\n'])
2022                && !reading.contains(['\r', '\n'])
2023                && !base.contains(['《', '》'])
2024                && !reading.contains(['《', '》'])
2025        }
2026        TextFormat::Aozora => {
2027            !base.is_empty()
2028                && !reading.is_empty()
2029                && !base.contains(['\r', '\n'])
2030                && !reading.contains(['\r', '\n'])
2031                && !base.chars().any(aozora_reserved_character)
2032                && !reading.chars().any(aozora_reserved_character)
2033        }
2034        TextFormat::Note => {
2035            !base.is_empty()
2036                && !reading.is_empty()
2037                && !base.contains(['\r', '\n', '《', '》', '|', '|'])
2038                && !reading.contains(['\r', '\n', '《', '》'])
2039        }
2040        TextFormat::Plain | TextFormat::Ruby => false,
2041    };
2042    if valid {
2043        format!("|{base}《{reading}》")
2044    } else if matches!(format, TextFormat::Note) {
2045        note_text_literal(base)
2046    } else {
2047        text_format_literal(base, format)
2048    }
2049}
2050
2051fn narou_ruby_problem_character(character: char) -> bool {
2052    matches!(character, '&' | '"' | '<' | '>')
2053}
2054
2055fn aozora_boten_name(mark: &str) -> &'static str {
2056    match mark {
2057        "﹆" => "白ゴマ傍点",
2058        "●" => "丸傍点",
2059        "○" => "白丸傍点",
2060        "▲" => "黒三角傍点",
2061        "△" => "白三角傍点",
2062        "◎" => "二重丸傍点",
2063        "×" => "ばつ傍点",
2064        _ => "傍点",
2065    }
2066}
2067
2068fn node_contains_type(node: &serde_json::Value, expected: &str) -> bool {
2069    node.get("type").and_then(serde_json::Value::as_str) == Some(expected)
2070        || children(node)
2071            .iter()
2072            .any(|child| node_contains_type(child, expected))
2073}
2074
2075fn text_format_plain_inline_children(node: &serde_json::Value) -> String {
2076    children(node)
2077        .iter()
2078        .map(text_format_plain_inline)
2079        .collect()
2080}
2081
2082fn text_format_plain_inline(node: &serde_json::Value) -> String {
2083    match node
2084        .get("type")
2085        .and_then(serde_json::Value::as_str)
2086        .unwrap_or_default()
2087    {
2088        "text" | "inlineCode" | "tcy" => node
2089            .get("value")
2090            .and_then(serde_json::Value::as_str)
2091            .unwrap_or_default()
2092            .to_owned(),
2093        "ruby" => node
2094            .get("base")
2095            .and_then(serde_json::Value::as_str)
2096            .unwrap_or_default()
2097            .to_owned(),
2098        "break" => "\n".to_owned(),
2099        "image" => node
2100            .get("alt")
2101            .and_then(serde_json::Value::as_str)
2102            .unwrap_or_default()
2103            .to_owned(),
2104        _ => text_format_plain_inline_children(node),
2105    }
2106}
2107
2108fn aozora_needs_range_annotation(node: &serde_json::Value) -> bool {
2109    node_contains_type(node, "em")
2110        || text_format_plain_inline_children(node)
2111            .chars()
2112            .any(aozora_reserved_character)
2113}
2114
2115fn aozora_reserved_character(character: char) -> bool {
2116    matches!(
2117        character,
2118        '《' | '》' | '[' | ']' | '〔' | '〕' | '|' | '#' | '※'
2119    )
2120}
2121
2122fn text_format_literal(value: &str, format: TextFormat) -> String {
2123    match format {
2124        TextFormat::Kakuyomu => value.replace('《', "|《"),
2125        TextFormat::Narou => value.replace('(', "|(").replace('(', "|("),
2126        TextFormat::Aozora => value
2127            .chars()
2128            .map(|character| match character {
2129                '《' => "※[#始め二重山括弧、1-1-52]".to_owned(),
2130                '》' => "※[#終わり二重山括弧、1-1-53]".to_owned(),
2131                '[' => "※[#始め角括弧、1-1-46]".to_owned(),
2132                ']' => "※[#終わり角括弧、1-1-47]".to_owned(),
2133                '〔' => "※[#始めきっこう(亀甲)括弧、1-1-44]".to_owned(),
2134                '〕' => "※[#終わりきっこう(亀甲)括弧、1-1-45]".to_owned(),
2135                '|' => "※[#縦線、1-1-35]".to_owned(),
2136                '#' => "※[#井げた、1-1-84]".to_owned(),
2137                '※' => "※[#米印、1-2-8]".to_owned(),
2138                _ => character.to_string(),
2139            })
2140            .collect(),
2141        TextFormat::Plain | TextFormat::Ruby | TextFormat::Note => value.to_owned(),
2142    }
2143}
2144
2145fn render_text_node(node: &serde_json::Value, out: &mut String) {
2146    match node
2147        .get("type")
2148        .and_then(serde_json::Value::as_str)
2149        .unwrap_or_default()
2150    {
2151        "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
2152            node.get("value")
2153                .and_then(serde_json::Value::as_str)
2154                .unwrap_or_default(),
2155        ),
2156        "ruby" => out.push_str(
2157            node.get("base")
2158                .and_then(serde_json::Value::as_str)
2159                .unwrap_or_default(),
2160        ),
2161        "image" => out.push_str(
2162            node.get("alt")
2163                .and_then(serde_json::Value::as_str)
2164                .unwrap_or_default(),
2165        ),
2166        "break" => out.push('\n'),
2167        "blank" => out.push('\n'),
2168        "pagebreak" => out.push_str("\n\x0C\n"),
2169        "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
2170            render_text_children(node, out);
2171            out.push('\n');
2172        }
2173        "tableCell" => {
2174            render_text_children(node, out);
2175            out.push('\t');
2176        }
2177        _ => render_text_children(node, out),
2178    }
2179}
2180
2181fn render_text_children(node: &serde_json::Value, out: &mut String) {
2182    for child in children(node) {
2183        render_text_node(child, out);
2184    }
2185}
2186
2187fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
2188    let kind = node
2189        .get("type")
2190        .and_then(serde_json::Value::as_str)
2191        .unwrap_or_default();
2192    match kind {
2193        "paragraph" => {
2194            if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
2195                out.push_str(prefix);
2196                out.push_str(&format!("[[indent:{amount}]]\n"));
2197            }
2198            if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2199                out.push_str(prefix);
2200                if amount == 0 {
2201                    out.push_str("[[bottom]]\n");
2202                } else {
2203                    out.push_str(&format!("[[bottom:{amount}]]\n"));
2204                }
2205            }
2206            out.push_str(prefix);
2207            serialize_inline_children(node, out);
2208            out.push('\n');
2209        }
2210        "heading" => {
2211            out.push_str(prefix);
2212            out.push_str(
2213                &"#".repeat(
2214                    node.get("depth")
2215                        .and_then(serde_json::Value::as_u64)
2216                        .unwrap_or(1) as usize,
2217                ),
2218            );
2219            out.push(' ');
2220            serialize_inline_children(node, out);
2221            out.push('\n');
2222        }
2223        "blockquote" => {
2224            let mut content = String::new();
2225            for child in children(node) {
2226                serialize_block(child, &mut content, "");
2227            }
2228            for line in content.trim_end_matches('\n').lines() {
2229                out.push_str(prefix);
2230                out.push_str("> ");
2231                out.push_str(line);
2232                out.push('\n');
2233            }
2234        }
2235        "list" => {
2236            let ordered = node
2237                .get("ordered")
2238                .and_then(serde_json::Value::as_bool)
2239                .unwrap_or(false);
2240            let start = node
2241                .get("start")
2242                .and_then(serde_json::Value::as_u64)
2243                .unwrap_or(1);
2244            for (index, item) in children(node).iter().enumerate() {
2245                out.push_str(prefix);
2246                if ordered {
2247                    out.push_str(&format!("{}.", start + index as u64));
2248                } else {
2249                    out.push('-');
2250                }
2251                out.push(' ');
2252                if let Some(first) = children(item).first() {
2253                    serialize_block(first, out, "");
2254                }
2255                for child in children(item).iter().skip(1) {
2256                    serialize_block(child, out, "  ");
2257                }
2258            }
2259        }
2260        "code" => {
2261            out.push_str(prefix);
2262            out.push_str("```");
2263            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2264                out.push_str(lang);
2265            }
2266            out.push('\n');
2267            out.push_str(
2268                node.get("value")
2269                    .and_then(serde_json::Value::as_str)
2270                    .unwrap_or_default(),
2271            );
2272            out.push_str("\n```\n");
2273        }
2274        "thematicBreak" => out.push_str("---\n"),
2275        "blank" => out.push_str("\\\n"),
2276        "pagebreak" => {
2277            out.push_str("[[pagebreak");
2278            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2279                out.push(':');
2280                out.push_str(variant);
2281            }
2282            out.push_str("]]\n");
2283        }
2284        "table" => serialize_table(node, out),
2285        "html" => {
2286            out.push_str(
2287                node.get("value")
2288                    .and_then(serde_json::Value::as_str)
2289                    .unwrap_or_default(),
2290            );
2291            out.push('\n');
2292        }
2293        _ => {
2294            serialize_inline(node, out);
2295            out.push('\n');
2296        }
2297    }
2298}
2299
2300fn serialize_table(node: &serde_json::Value, out: &mut String) {
2301    for (row_index, row) in children(node).iter().enumerate() {
2302        out.push('|');
2303        for cell in children(row) {
2304            out.push(' ');
2305            serialize_inline_children(cell, out);
2306            out.push_str(" |");
2307        }
2308        out.push('\n');
2309        if row_index == 0 {
2310            out.push('|');
2311            for _ in children(row) {
2312                out.push_str(" --- |");
2313            }
2314            out.push('\n');
2315        }
2316    }
2317}
2318
2319fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
2320    for child in children(node) {
2321        serialize_inline(child, out);
2322    }
2323}
2324
2325fn serialize_inline(node: &serde_json::Value, out: &mut String) {
2326    let kind = node
2327        .get("type")
2328        .and_then(serde_json::Value::as_str)
2329        .unwrap_or_default();
2330    match kind {
2331        "text" | "html" => out.push_str(
2332            node.get("value")
2333                .and_then(serde_json::Value::as_str)
2334                .unwrap_or_default(),
2335        ),
2336        "emphasis" => {
2337            out.push('*');
2338            serialize_inline_children(node, out);
2339            out.push('*');
2340        }
2341        "strong" => {
2342            out.push_str("**");
2343            serialize_inline_children(node, out);
2344            out.push_str("**");
2345        }
2346        "delete" => {
2347            out.push_str("~~");
2348            serialize_inline_children(node, out);
2349            out.push_str("~~");
2350        }
2351        "inlineCode" => {
2352            out.push('`');
2353            out.push_str(
2354                node.get("value")
2355                    .and_then(serde_json::Value::as_str)
2356                    .unwrap_or_default(),
2357            );
2358            out.push('`');
2359        }
2360        "link" => {
2361            out.push('[');
2362            serialize_inline_children(node, out);
2363            out.push_str("](");
2364            out.push_str(
2365                node.get("url")
2366                    .and_then(serde_json::Value::as_str)
2367                    .unwrap_or_default(),
2368            );
2369            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2370                out.push_str(" \\");
2371                out.push_str(title);
2372                out.push('\"');
2373            }
2374            out.push(')');
2375        }
2376        "image" => {
2377            out.push_str("![");
2378            out.push_str(
2379                node.get("alt")
2380                    .and_then(serde_json::Value::as_str)
2381                    .unwrap_or_default(),
2382            );
2383            out.push_str("](");
2384            out.push_str(
2385                node.get("url")
2386                    .and_then(serde_json::Value::as_str)
2387                    .unwrap_or_default(),
2388            );
2389            out.push(')');
2390        }
2391        "ruby" => {
2392            out.push('{');
2393            out.push_str(
2394                node.get("base")
2395                    .and_then(serde_json::Value::as_str)
2396                    .unwrap_or_default(),
2397            );
2398            out.push('|');
2399            if let Some(values) = node
2400                .pointer("/ruby/value")
2401                .and_then(serde_json::Value::as_array)
2402            {
2403                for (index, value) in values.iter().enumerate() {
2404                    if index > 0 {
2405                        out.push('.');
2406                    }
2407                    out.push_str(value.as_str().unwrap_or_default());
2408                }
2409            } else {
2410                out.push_str(
2411                    node.pointer("/ruby/value")
2412                        .and_then(serde_json::Value::as_str)
2413                        .unwrap_or_default(),
2414                );
2415            }
2416            out.push('}');
2417        }
2418        "tcy" => {
2419            out.push('^');
2420            out.push_str(
2421                node.get("value")
2422                    .and_then(serde_json::Value::as_str)
2423                    .unwrap_or_default(),
2424            );
2425            out.push('^');
2426        }
2427        // Markdown hard breaks and MDI `[[br]]` have the same IR node; the
2428        // canonical serializer chooses the explicit MDI spelling.
2429        "break" => out.push_str("[[br]]"),
2430        "em" => {
2431            out.push_str("[[em:");
2432            let mark = node
2433                .get("mark")
2434                .and_then(serde_json::Value::as_str)
2435                .unwrap_or("﹅");
2436            if mark != "﹅" {
2437                out.push_str(mark);
2438                out.push(':');
2439            }
2440            serialize_inline_children(node, out);
2441            out.push_str("]]");
2442        }
2443        "noBreak" => {
2444            out.push_str("[[no-break:");
2445            serialize_inline_children(node, out);
2446            out.push_str("]]");
2447        }
2448        "warichu" => {
2449            out.push_str("[[warichu:");
2450            serialize_inline_children(node, out);
2451            out.push_str("]]");
2452        }
2453        "kern" => {
2454            out.push_str("[[kern:");
2455            out.push_str(
2456                node.get("amount")
2457                    .and_then(serde_json::Value::as_str)
2458                    .unwrap_or_default(),
2459            );
2460            out.push(':');
2461            serialize_inline_children(node, out);
2462            out.push_str("]]");
2463        }
2464        "footnoteReference" => {
2465            out.push_str("[^");
2466            out.push_str(
2467                node.get("identifier")
2468                    .and_then(serde_json::Value::as_str)
2469                    .unwrap_or_default(),
2470            );
2471            out.push(']');
2472        }
2473        _ => serialize_inline_children(node, out),
2474    }
2475}
2476
2477pub(crate) fn children(node: &serde_json::Value) -> &[serde_json::Value] {
2478    node.get("children")
2479        .and_then(serde_json::Value::as_array)
2480        .map(Vec::as_slice)
2481        .unwrap_or(&[])
2482}
2483
2484fn document_frontmatter_field<'a>(document: &'a Document, key: &str) -> Option<&'a str> {
2485    document
2486        .frontmatter
2487        .as_ref()
2488        .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2489        .and_then(|entry| entry.value.as_str())
2490}
2491
2492fn default_profile_for_document(document: &Document) -> Result<ResolvedExportProfile, String> {
2493    resolve_export_profile(
2494        &serde_json::Map::new(),
2495        document_frontmatter_field(document, "writing-mode"),
2496    )
2497}
2498
2499fn resolved_profile_for_document(
2500    document: &Document,
2501    profile_json: &str,
2502    require_layout: bool,
2503) -> Result<ResolvedExportProfile, String> {
2504    let value: serde_json::Value = serde_json::from_str(profile_json)
2505        .map_err(|_| "Export profile must be valid JSON".to_owned())?;
2506    let profile = value
2507        .as_object()
2508        .ok_or_else(|| "Export profile must be a JSON object".to_owned())?;
2509    if require_layout
2510        && profile
2511            .get("layout")
2512            .and_then(serde_json::Value::as_object)
2513            .and_then(|layout| layout.get("system"))
2514            .is_none()
2515    {
2516        return Err(
2517            "Configured exports require layout.system: japanese-publisher or word".to_owned(),
2518        );
2519    }
2520    resolve_export_profile(
2521        profile,
2522        document_frontmatter_field(document, "writing-mode"),
2523    )
2524}
2525
2526fn css_value(value: &str) -> String {
2527    let safe = value
2528        .chars()
2529        .filter(|character| !matches!(character, '{' | '}' | '<' | '>' | ';'))
2530        .collect::<String>();
2531    if safe.trim().is_empty() {
2532        "serif".to_owned()
2533    } else {
2534        safe
2535    }
2536}
2537
2538/// The base stylesheet is intentionally shipped by the core alongside the
2539/// semantic HTML. Hosts may add presentation CSS, but not reinterpret nodes.
2540pub 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}";
2541
2542#[derive(Debug, Clone)]
2543pub struct EpubCover {
2544    pub data: Vec<u8>,
2545    pub media_type: String,
2546}
2547
2548/// Build a reflowable EPUB 3 archive entirely from Rust's document IR.
2549pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
2550    render_epub_document(&parse_document(source))
2551}
2552
2553/// Build an EPUB with the canonical configured-export profile.
2554pub fn render_epub_with_profile(
2555    source: &str,
2556    profile_json: &str,
2557    cover: Option<&EpubCover>,
2558) -> Result<Vec<u8>, String> {
2559    let document = parse_document(source);
2560    let profile = resolved_profile_for_document(&document, profile_json, false)?;
2561    render_epub_document_with_profile(&document, &profile, cover)
2562}
2563
2564/// Build a reflowable EPUB 3 archive from a parsed document.
2565pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
2566    let profile = default_profile_for_document(document)?;
2567    render_epub_document_with_profile(document, &profile, None)
2568}
2569
2570pub fn render_epub_document_with_profile(
2571    document: &Document,
2572    profile: &ResolvedExportProfile,
2573    cover: Option<&EpubCover>,
2574) -> Result<Vec<u8>, String> {
2575    let cursor = Cursor::new(Vec::new());
2576    let mut zip = ZipWriter::new(cursor);
2577    write_epub_document_with_profile(document, &mut zip, profile, cover)?;
2578    zip.finish()
2579        .map(|cursor| cursor.into_inner())
2580        .map_err(|error| error.to_string())
2581}
2582
2583#[cfg(test)]
2584fn write_epub_document<W: Write + Seek>(
2585    document: &Document,
2586    zip: &mut ZipWriter<W>,
2587) -> Result<(), String> {
2588    let profile = default_profile_for_document(document)?;
2589    write_epub_document_with_profile(document, zip, &profile, None)
2590}
2591
2592fn write_epub_document_with_profile<W: Write + Seek>(
2593    document: &Document,
2594    zip: &mut ZipWriter<W>,
2595    profile: &ResolvedExportProfile,
2596    cover: Option<&EpubCover>,
2597) -> Result<(), String> {
2598    let field = |key: &str| {
2599        document
2600            .frontmatter
2601            .as_ref()
2602            .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2603            .and_then(|entry| entry.value.as_str())
2604    };
2605    let metadata = |key: &str| {
2606        profile
2607            .metadata
2608            .get(key)
2609            .and_then(serde_json::Value::as_str)
2610    };
2611    let title = metadata("title")
2612        .or_else(|| field("title"))
2613        .unwrap_or("Untitled");
2614    let author = metadata("author").or_else(|| field("author"));
2615    let publisher = metadata("publisher").or_else(|| field("publisher"));
2616    let date = metadata("date").or_else(|| field("date"));
2617    let language = metadata("language")
2618        .or_else(|| field("lang"))
2619        .unwrap_or("ja");
2620    let identifier = metadata("identifier")
2621        .or_else(|| field("identifier"))
2622        .unwrap_or("urn:mdi:document");
2623    let vertical = profile.typesetting.writing_mode == "vertical";
2624    let modified = epub_modified_timestamp()?;
2625    let chapters = epub_chapters(document, &profile.epub.chapter_split_level);
2626    let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2627    epub_file(zip, "mimetype", "application/epub+zip", stored)?;
2628    let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2629    epub_file(
2630        zip,
2631        "META-INF/container.xml",
2632        "<?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>",
2633        compressed,
2634    )?;
2635    let writing = if vertical {
2636        "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
2637    } else {
2638        ""
2639    };
2640    let line_spacing = profile.typesetting.line_spacing.unwrap_or(1.8);
2641    let fullwidth_indent = if profile.typesetting.fullwidth_space_indent {
2642        "--mdi-fullwidth-space-indent:1;"
2643    } else {
2644        ""
2645    };
2646    epub_file(
2647        zip,
2648        "OEBPS/style.css",
2649        &format!(
2650            "body{{font-family:{};font-size:{}pt;{writing}line-height:{line_spacing};margin:1em}}p{{{fullwidth_indent}text-indent:{}em;margin:.3em 0}}{MDI_STYLESHEET}",
2651            css_value(&profile.typesetting.font_family),
2652            profile.typesetting.font_size,
2653            profile.typesetting.text_indent_em,
2654        ),
2655        compressed,
2656    )?;
2657    let nav_items = chapters
2658        .iter()
2659        .enumerate()
2660        .map(|(index, chapter)| {
2661            let chapter_title = if chapter.title.trim().is_empty() {
2662                format!("Chapter {}", index + 1)
2663            } else {
2664                chapter.title.clone()
2665            };
2666            format!(
2667                "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
2668                index + 1,
2669                escape_html(&chapter_title)
2670            )
2671        })
2672        .collect::<String>();
2673    epub_file(
2674        zip,
2675        "OEBPS/nav.xhtml",
2676        &epub_xhtml(
2677            "Contents",
2678            language,
2679            &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
2680        ),
2681        compressed,
2682    )?;
2683    let cover_extension = cover
2684        .map(|cover| match cover.media_type.as_str() {
2685            "image/png" => Ok("png"),
2686            "image/jpeg" => Ok("jpg"),
2687            _ => Err("EPUB cover must be image/png or image/jpeg".to_owned()),
2688        })
2689        .transpose()?;
2690    if let (Some(cover), Some(extension)) = (cover, cover_extension) {
2691        zip.start_file(format!("OEBPS/cover.{extension}"), compressed)
2692            .map_err(|error| error.to_string())?;
2693        zip.write_all(&cover.data)
2694            .map_err(|error| error.to_string())?;
2695        epub_file(
2696            zip,
2697            "OEBPS/cover.xhtml",
2698            &epub_xhtml(
2699                title,
2700                language,
2701                &format!(
2702                    "<img src=\"cover.{extension}\" alt=\"{}\"/>",
2703                    escape_html(title)
2704                ),
2705            ),
2706            compressed,
2707        )?;
2708    }
2709    for (index, chapter) in chapters.iter().enumerate() {
2710        epub_file(
2711            zip,
2712            &format!("OEBPS/chapter-{}.xhtml", index + 1),
2713            &epub_chapter_xhtml(
2714                if chapter.title.is_empty() {
2715                    title
2716                } else {
2717                    &chapter.title
2718                },
2719                language,
2720                &chapter.html,
2721            ),
2722            compressed,
2723        )?;
2724    }
2725    let cover_manifest = match (cover, cover_extension) {
2726        (Some(cover), Some(extension)) => format!(
2727            "<item id=\"cover-image\" href=\"cover.{extension}\" media-type=\"{}\" properties=\"cover-image\"/><item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>",
2728            cover.media_type
2729        ),
2730        _ => String::new(),
2731    };
2732    let chapter_manifest = chapters
2733        .iter()
2734        .enumerate()
2735        .map(|(index, _)| {
2736            format!(
2737                "<item id=\"chapter-{}\" href=\"chapter-{}.xhtml\" media-type=\"application/xhtml+xml\"/>",
2738                index + 1,
2739                index + 1
2740            )
2741        })
2742        .collect::<String>();
2743    let manifest = format!(
2744        "<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/><item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>{cover_manifest}{chapter_manifest}"
2745    );
2746    let chapter_spine = chapters
2747        .iter()
2748        .enumerate()
2749        .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
2750        .collect::<String>();
2751    let spine = format!(
2752        "{}{chapter_spine}",
2753        if cover.is_some() {
2754            "<itemref idref=\"cover\"/>"
2755        } else {
2756            ""
2757        }
2758    );
2759    let creator = author
2760        .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
2761        .unwrap_or_default();
2762    let publisher = publisher
2763        .map(|publisher| format!("<dc:publisher>{}</dc:publisher>", escape_html(publisher)))
2764        .unwrap_or_default();
2765    let date = date
2766        .map(|date| format!("<dc:date>{}</dc:date>", escape_html(date)))
2767        .unwrap_or_default();
2768    let progression = if vertical {
2769        " page-progression-direction=\"rtl\""
2770    } else {
2771        ""
2772    };
2773    epub_file(
2774        zip,
2775        "OEBPS/package.opf",
2776        &format!(
2777            "<?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}{publisher}{date}<meta property=\"dcterms:modified\">{modified}</meta></metadata><manifest>{manifest}</manifest><spine{progression}>{spine}</spine></package>",
2778            escape_html(identifier),
2779            escape_html(title),
2780            escape_html(language)
2781        ),
2782        compressed,
2783    )?;
2784    Ok(())
2785}
2786
2787/// Build a baseline DOCX archive directly from Rust IR. It deliberately uses
2788/// WordprocessingML rather than a host document library, so source syntax and
2789/// document meaning remain inside the core.
2790pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
2791    render_docx_document(&parse_document(source))
2792}
2793
2794/// Build a configured DOCX through the canonical Rust OOXML writer.
2795pub fn render_docx_with_profile(source: &str, profile_json: &str) -> Result<Vec<u8>, String> {
2796    let document = parse_document(source);
2797    let profile = resolved_profile_for_document(&document, profile_json, false)?;
2798    render_docx_document_with_profile(&document, &profile)
2799}
2800
2801/// Build a baseline DOCX archive from a parsed document.
2802pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
2803    let profile = default_profile_for_document(document)?;
2804    render_docx_document_with_profile(document, &profile)
2805}
2806
2807pub fn render_docx_document_with_profile(
2808    document: &Document,
2809    profile: &ResolvedExportProfile,
2810) -> Result<Vec<u8>, String> {
2811    let cursor = Cursor::new(Vec::new());
2812    let mut zip = ZipWriter::new(cursor);
2813    docx::write(document, profile, &mut zip)?;
2814    zip.finish()
2815        .map(|cursor| cursor.into_inner())
2816        .map_err(|error| error.to_string())
2817}
2818
2819#[cfg(test)]
2820fn write_docx_document<W: Write + Seek>(
2821    document: &Document,
2822    zip: &mut ZipWriter<W>,
2823) -> Result<(), String> {
2824    let profile = default_profile_for_document(document)?;
2825    docx::write(document, &profile, zip)
2826}
2827
2828/// Native configuration for Chromium PDF layout. WebAssembly deliberately
2829/// cannot expose this operation because it cannot launch a local process.
2830#[derive(Debug, Clone, Default)]
2831pub struct PdfOptions {
2832    pub chromium_path: Option<PathBuf>,
2833}
2834
2835/// Render PDF by asking a locally installed Chromium-compatible browser to
2836/// lay out Rust-generated HTML. Chromium never receives MDI source.
2837pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
2838    let chromium = options
2839        .chromium_path
2840        .clone()
2841        .or_else(find_chromium)
2842        .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
2843    let nonce = SystemTime::now()
2844        .duration_since(UNIX_EPOCH)
2845        .map_err(|error| error.to_string())?
2846        .as_nanos();
2847    let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
2848    fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
2849    let html_path = directory.join("document.html");
2850    let pdf_path = directory.join("document.pdf");
2851    let result = (|| {
2852        fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
2853        let output = Command::new(&chromium)
2854            .arg("--headless=new")
2855            .arg("--disable-gpu")
2856            .arg("--no-pdf-header-footer")
2857            .arg(format!("--print-to-pdf={}", pdf_path.display()))
2858            .arg(format!("file://{}", html_path.display()))
2859            .output()
2860            .map_err(|error| {
2861                format!(
2862                    "failed to start Chromium at {}: {error}",
2863                    chromium.display()
2864                )
2865            })?;
2866        if !output.status.success() {
2867            return Err(format!(
2868                "Chromium PDF rendering failed: {}",
2869                String::from_utf8_lossy(&output.stderr)
2870            ));
2871        }
2872        fs::read(&pdf_path).map_err(|error| error.to_string())
2873    })();
2874    let _ = fs::remove_dir_all(&directory);
2875    result
2876}
2877
2878/// Locate common native Chromium installations. Production callers should
2879/// prefer the explicit `PdfOptions.chromium_path` for deterministic deploys.
2880pub fn find_chromium() -> Option<PathBuf> {
2881    let candidates = [
2882        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
2883        "/Applications/Chromium.app/Contents/MacOS/Chromium",
2884        "/usr/bin/google-chrome",
2885        "/usr/bin/chromium",
2886        "/usr/bin/chromium-browser",
2887    ];
2888    candidates
2889        .iter()
2890        .map(Path::new)
2891        .find(|path| path.is_file())
2892        .map(Path::to_path_buf)
2893}
2894
2895pub(crate) fn escape_xml(value: &str) -> String {
2896    value
2897        .replace('&', "&amp;")
2898        .replace('<', "&lt;")
2899        .replace('>', "&gt;")
2900        .replace('"', "&quot;")
2901        .replace('\'', "&apos;")
2902}
2903
2904struct EpubChapter {
2905    title: String,
2906    html: String,
2907    footnote_ids: Vec<String>,
2908}
2909fn epub_chapters(document: &Document, split_level: &str) -> Vec<EpubChapter> {
2910    let split_depth = match split_level {
2911        "h1" => Some(1),
2912        "h2" => Some(2),
2913        "h3" => Some(3),
2914        "none" => None,
2915        _ => Some(1),
2916    };
2917    let mut chapters = vec![EpubChapter {
2918        title: String::new(),
2919        html: String::new(),
2920        footnote_ids: Vec::new(),
2921    }];
2922    let footnote_definitions = document
2923        .children
2924        .iter()
2925        .filter_map(|node| {
2926            if node.get("type").and_then(serde_json::Value::as_str) != Some("footnoteDefinition") {
2927                return None;
2928            }
2929            node.get("identifier")
2930                .and_then(serde_json::Value::as_str)
2931                .map(|identifier| (identifier, node))
2932        })
2933        .collect::<std::collections::HashMap<_, _>>();
2934    for node in &document.children {
2935        if node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
2936            continue;
2937        }
2938        if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2939            if split_depth.is_some()
2940                && !chapters
2941                    .last()
2942                    .is_some_and(|chapter| chapter.html.is_empty())
2943            {
2944                chapters.push(EpubChapter {
2945                    title: String::new(),
2946                    html: String::new(),
2947                    footnote_ids: Vec::new(),
2948                });
2949            }
2950            continue;
2951        }
2952        if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2953            && node.get("depth").and_then(serde_json::Value::as_u64) == split_depth
2954            && !chapters
2955                .last()
2956                .is_some_and(|chapter| chapter.html.is_empty())
2957        {
2958            chapters.push(EpubChapter {
2959                title: String::new(),
2960                html: String::new(),
2961                footnote_ids: Vec::new(),
2962            });
2963        }
2964        let chapter = chapters.last_mut().expect("one chapter exists");
2965        if chapter.title.is_empty()
2966            && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2967        {
2968            chapter.title = plain_node_text(node);
2969        }
2970        collect_footnote_references(node, &mut chapter.footnote_ids);
2971        render_html_node(node, &mut chapter.html);
2972    }
2973    let mut chapters: Vec<_> = chapters
2974        .into_iter()
2975        .filter(|chapter| !chapter.html.is_empty())
2976        .collect();
2977    for chapter in &mut chapters {
2978        chapter.footnote_ids.sort();
2979        chapter.footnote_ids.dedup();
2980        if chapter.footnote_ids.is_empty() {
2981            continue;
2982        }
2983        chapter.html.push_str(
2984            "<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\" id=\"footnote-label\">Footnotes</h2><ol>",
2985        );
2986        for identifier in &chapter.footnote_ids {
2987            let Some(definition) = footnote_definitions.get(identifier.as_str()) else {
2988                continue;
2989            };
2990            chapter.html.push_str("<li id=\"user-content-fn-");
2991            chapter.html.push_str(&escape_html(identifier));
2992            chapter.html.push_str("\">");
2993            render_html_children(definition, &mut chapter.html);
2994            chapter.html.push_str(" <a href=\"#user-content-fnref-");
2995            chapter.html.push_str(&escape_html(identifier));
2996            chapter.html.push_str("\" data-footnote-backref=\"\" aria-label=\"Back to reference\" class=\"data-footnote-backref\">↩</a></li>");
2997        }
2998        chapter.html.push_str("</ol></section>");
2999    }
3000    if chapters.is_empty() {
3001        vec![EpubChapter {
3002            title: String::new(),
3003            html: String::new(),
3004            footnote_ids: Vec::new(),
3005        }]
3006    } else {
3007        chapters
3008    }
3009}
3010
3011fn collect_footnote_references(node: &serde_json::Value, identifiers: &mut Vec<String>) {
3012    if node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteReference")
3013        && let Some(identifier) = node.get("identifier").and_then(serde_json::Value::as_str)
3014    {
3015        identifiers.push(identifier.to_owned());
3016    }
3017    for child in children(node) {
3018        collect_footnote_references(child, identifiers);
3019    }
3020}
3021fn plain_node_text(node: &serde_json::Value) -> String {
3022    let mut text = String::new();
3023    render_text_children(node, &mut text);
3024    text
3025}
3026fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
3027    format!(
3028        "<?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>",
3029        escape_html(language),
3030        escape_html(language),
3031        escape_html(title),
3032        body
3033    )
3034}
3035
3036fn epub_chapter_xhtml(title: &str, language: &str, body: &str) -> String {
3037    epub_xhtml(title, language, &epub_chapter_body(body))
3038}
3039
3040fn epub_chapter_body(body: &str) -> String {
3041    let mut output = body.replace("<br>", "<br/>").replace("<hr>", "<hr/>");
3042    let mut search_from = 0;
3043    while let Some(relative_start) = output[search_from..].find("<img src=\"") {
3044        let start = search_from + relative_start;
3045        let Some(relative_end) = output[start..].find('>') else {
3046            break;
3047        };
3048        let end = start + relative_end;
3049        let image = &output[start..=end];
3050        let Some(attributes) = image.strip_prefix("<img src=\"") else {
3051            search_from = end + 1;
3052            continue;
3053        };
3054        let Some((source, alt)) = attributes.split_once("\" alt=\"") else {
3055            search_from = end + 1;
3056            continue;
3057        };
3058        let Some(alt) = alt.strip_suffix("\">") else {
3059            search_from = end + 1;
3060            continue;
3061        };
3062        let replacement =
3063            format!("<span class=\"mdi-image-fallback\">Image: {alt} ({source})</span>");
3064        output.replace_range(start..=end, &replacement);
3065        search_from = start + replacement.len();
3066    }
3067    output
3068}
3069
3070#[cfg(not(feature = "wasm"))]
3071fn epub_modified_timestamp() -> Result<String, String> {
3072    OffsetDateTime::now_utc()
3073        .replace_nanosecond(0)
3074        .map_err(|error| error.to_string())?
3075        .format(&Rfc3339)
3076        .map_err(|error| error.to_string())
3077}
3078
3079#[cfg(feature = "wasm")]
3080fn epub_modified_timestamp() -> Result<String, String> {
3081    let value = js_sys::Date::new_0()
3082        .to_iso_string()
3083        .as_string()
3084        .ok_or_else(|| "JavaScript Date did not return an ISO timestamp".to_owned())?;
3085    Ok(value
3086        .find('.')
3087        .map_or(value.clone(), |fraction| format!("{}Z", &value[..fraction])))
3088}
3089fn epub_file<W: Write + Seek>(
3090    zip: &mut ZipWriter<W>,
3091    path: &str,
3092    content: &str,
3093    options: SimpleFileOptions,
3094) -> Result<(), String> {
3095    zip.start_file(path, options)
3096        .map_err(|error| error.to_string())?;
3097    zip.write_all(content.as_bytes())
3098        .map_err(|error| error.to_string())
3099}
3100
3101fn render_html_node(node: &serde_json::Value, out: &mut String) {
3102    let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
3103        return;
3104    };
3105    let children = |out: &mut String| render_html_children(node, out);
3106    match kind {
3107        "text" => out.push_str(&escape_html(
3108            node.get("value")
3109                .and_then(serde_json::Value::as_str)
3110                .unwrap_or_default(),
3111        )),
3112        "root" => children(out),
3113        "paragraph" => {
3114            let class = if node.get("indent").is_some() {
3115                " class=\"mdi-indent\""
3116            } else if node.get("bottom").is_some() {
3117                " class=\"mdi-bottom\""
3118            } else {
3119                ""
3120            };
3121            let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
3122            {
3123                format!(" style=\"--mdi-indent:{amount};\"")
3124            } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
3125                format!(" style=\"--mdi-shift:{amount};\"")
3126            } else {
3127                String::new()
3128            };
3129            out.push_str("<p");
3130            out.push_str(class);
3131            out.push_str(&style);
3132            out.push('>');
3133            children(out);
3134            out.push_str("</p>");
3135        }
3136        "heading" => {
3137            let depth = node
3138                .get("depth")
3139                .and_then(serde_json::Value::as_u64)
3140                .filter(|depth| (1..=6).contains(depth))
3141                .unwrap_or(1);
3142            out.push_str(&format!("<h{depth}>"));
3143            children(out);
3144            out.push_str(&format!("</h{depth}>"));
3145        }
3146        "emphasis" => wrapped(out, "em", children),
3147        "strong" => wrapped(out, "strong", children),
3148        "delete" => wrapped(out, "del", children),
3149        "blockquote" => wrapped(out, "blockquote", children),
3150        "list" => {
3151            let ordered = node
3152                .get("ordered")
3153                .and_then(serde_json::Value::as_bool)
3154                .unwrap_or(false);
3155            let tag = if ordered { "ol" } else { "ul" };
3156            let start = node
3157                .get("start")
3158                .and_then(serde_json::Value::as_u64)
3159                .filter(|start| *start != 1)
3160                .map(|start| format!(" start=\"{start}\""))
3161                .unwrap_or_default();
3162            out.push('<');
3163            out.push_str(tag);
3164            out.push_str(&start);
3165            out.push('>');
3166            children(out);
3167            out.push_str("</");
3168            out.push_str(tag);
3169            out.push('>');
3170        }
3171        "listItem" => wrapped(out, "li", children),
3172        "thematicBreak" => out.push_str("<hr>"),
3173        "break" => out.push_str("<br class=\"mdi-break\"/>"),
3174        "inlineCode" => {
3175            out.push_str("<code>");
3176            out.push_str(&escape_html(
3177                node.get("value")
3178                    .and_then(serde_json::Value::as_str)
3179                    .unwrap_or_default(),
3180            ));
3181            out.push_str("</code>");
3182        }
3183        "code" => {
3184            out.push_str("<pre><code");
3185            if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
3186                out.push_str(" class=\"language-");
3187                out.push_str(&escape_html(lang));
3188                out.push('"');
3189            }
3190            out.push('>');
3191            out.push_str(&escape_html(
3192                node.get("value")
3193                    .and_then(serde_json::Value::as_str)
3194                    .unwrap_or_default(),
3195            ));
3196            out.push_str("</code></pre>");
3197        }
3198        "link" => {
3199            out.push_str("<a href=\"");
3200            out.push_str(&escape_html(
3201                node.get("url")
3202                    .and_then(serde_json::Value::as_str)
3203                    .unwrap_or_default(),
3204            ));
3205            out.push('"');
3206            if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
3207                out.push_str(" title=\"");
3208                out.push_str(&escape_html(title));
3209                out.push('"');
3210            }
3211            out.push('>');
3212            children(out);
3213            out.push_str("</a>");
3214        }
3215        "image" => {
3216            out.push_str("<img src=\"");
3217            out.push_str(&escape_html(
3218                node.get("url")
3219                    .and_then(serde_json::Value::as_str)
3220                    .unwrap_or_default(),
3221            ));
3222            out.push_str("\" alt=\"");
3223            out.push_str(&escape_html(
3224                node.get("alt")
3225                    .and_then(serde_json::Value::as_str)
3226                    .unwrap_or_default(),
3227            ));
3228            out.push_str("\">");
3229        }
3230        "table" => wrapped(out, "table", children),
3231        "tableRow" => wrapped(out, "tr", children),
3232        "tableCell" => wrapped(out, "td", children),
3233        "footnoteReference" => {
3234            let identifier = node
3235                .get("identifier")
3236                .and_then(serde_json::Value::as_str)
3237                .unwrap_or_default();
3238            let label = node
3239                .get("label")
3240                .and_then(serde_json::Value::as_str)
3241                .unwrap_or_default();
3242            out.push_str("<sup class=\"footnote-ref\"><a href=\"#user-content-fn-");
3243            out.push_str(&escape_html(identifier));
3244            out.push_str("\" id=\"user-content-fnref-");
3245            out.push_str(&escape_html(identifier));
3246            out.push_str("\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">");
3247            out.push_str(&escape_html(label));
3248            out.push_str("</a></sup>");
3249        }
3250        "footnoteDefinition" | "definition" => {}
3251        "html" => out.push_str(&escape_html(
3252            node.get("value")
3253                .and_then(serde_json::Value::as_str)
3254                .unwrap_or_default(),
3255        )),
3256        "ruby" => render_ruby(node, out),
3257        "tcy" => {
3258            out.push_str("<span class=\"mdi-tcy\">");
3259            out.push_str(&escape_html(
3260                node.get("value")
3261                    .and_then(serde_json::Value::as_str)
3262                    .unwrap_or_default(),
3263            ));
3264            out.push_str("</span>");
3265        }
3266        "em" => {
3267            let mark = node
3268                .get("mark")
3269                .and_then(serde_json::Value::as_str)
3270                .unwrap_or("﹅");
3271            out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:&quot;");
3272            out.push_str(&escape_css_string(mark));
3273            out.push_str("&quot;;\">");
3274            children(out);
3275            out.push_str("</span>");
3276        }
3277        "noBreak" => {
3278            out.push_str("<span class=\"mdi-nobr\">");
3279            children(out);
3280            out.push_str("</span>");
3281        }
3282        "warichu" => {
3283            out.push_str("<span class=\"mdi-warichu\">");
3284            children(out);
3285            out.push_str("</span>");
3286        }
3287        "kern" => {
3288            out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
3289            out.push_str(&escape_html(
3290                node.get("amount")
3291                    .and_then(serde_json::Value::as_str)
3292                    .unwrap_or_default(),
3293            ));
3294            out.push_str(";\">");
3295            children(out);
3296            out.push_str("</span>");
3297        }
3298        "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
3299        "pagebreak" => {
3300            out.push_str("<div class=\"mdi-pagebreak");
3301            if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
3302                out.push_str(" mdi-pagebreak-");
3303                out.push_str(&escape_html(variant));
3304            }
3305            out.push_str("\" role=\"presentation\"></div>");
3306        }
3307        _ => children(out),
3308    }
3309}
3310
3311fn render_html_children(node: &serde_json::Value, out: &mut String) {
3312    if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
3313        for child in children {
3314            render_html_node(child, out);
3315        }
3316    }
3317}
3318
3319fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
3320    out.push('<');
3321    out.push_str(tag);
3322    out.push('>');
3323    children(out);
3324    out.push_str("</");
3325    out.push_str(tag);
3326    out.push('>');
3327}
3328
3329fn render_ruby(node: &serde_json::Value, out: &mut String) {
3330    let base = node
3331        .get("base")
3332        .and_then(serde_json::Value::as_str)
3333        .unwrap_or_default();
3334    let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
3335    out.push_str("<ruby class=\"mdi-ruby\">");
3336    if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
3337        for (base, reading) in base.graphemes(true).zip(parts) {
3338            out.push_str(&escape_html(base));
3339            render_ruby_reading(reading.as_str().unwrap_or_default(), out);
3340        }
3341    } else {
3342        out.push_str(&escape_html(base));
3343        render_ruby_reading(
3344            reading
3345                .and_then(serde_json::Value::as_str)
3346                .unwrap_or_default(),
3347            out,
3348        );
3349    }
3350    out.push_str("</ruby>");
3351}
3352
3353fn render_ruby_reading(reading: &str, out: &mut String) {
3354    out.push_str("<rp>(</rp><rt>");
3355    out.push_str(&escape_html(reading));
3356    out.push_str("</rt><rp>)</rp>");
3357}
3358
3359fn escape_html(value: &str) -> String {
3360    value
3361        .replace('&', "&amp;")
3362        .replace('<', "&lt;")
3363        .replace('>', "&gt;")
3364        .replace('"', "&quot;")
3365}
3366fn escape_css_string(value: &str) -> String {
3367    value.replace('\\', "\\\\").replace('"', "\\\"")
3368}
3369
3370/// Parse MDI inline syntax while keeping all other text literal.
3371pub fn parse_inlines(source: &str) -> Vec<Inline> {
3372    parse_inline_parts(source)
3373        .into_iter()
3374        .map(|(inline, _, _)| inline)
3375        .collect()
3376}
3377
3378/// Parse MDI inline syntax and retain each node's raw byte range relative to
3379/// `source`.  Escaped text deliberately retains the range of its spelling in
3380/// the source even when its rendered value is shorter.
3381fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
3382    let mut out = Vec::new();
3383    let mut text = String::new();
3384    let mut text_start = 0;
3385    let mut index = 0;
3386
3387    while index < source.len() {
3388        let rest = &source[index..];
3389        if rest.starts_with('\\') {
3390            let mut chars = rest.chars();
3391            let slash = chars.next().expect("prefix was checked");
3392            let Some(next) = chars.next() else {
3393                text.push(slash);
3394                index += slash.len_utf8();
3395                continue;
3396            };
3397            if is_escapable(next) {
3398                text.push(next);
3399            } else {
3400                text.push(slash);
3401                text.push(next);
3402            }
3403            index += slash.len_utf8() + next.len_utf8();
3404            continue;
3405        }
3406        if let Some((inline, consumed)) = ruby(rest) {
3407            push_inline_text(&mut out, &mut text, text_start, index);
3408            out.push((inline, index, index + consumed));
3409            index += consumed;
3410            text_start = index;
3411            continue;
3412        }
3413        if let Some((inline, consumed)) = tcy(rest) {
3414            push_inline_text(&mut out, &mut text, text_start, index);
3415            out.push((inline, index, index + consumed));
3416            index += consumed;
3417            text_start = index;
3418            continue;
3419        }
3420        if let Some((inline, consumed)) = boten(rest) {
3421            push_inline_text(&mut out, &mut text, text_start, index);
3422            out.push((inline, index, index + consumed));
3423            index += consumed;
3424            text_start = index;
3425            continue;
3426        }
3427        if let Some((inline, consumed)) = bracket_macro(rest) {
3428            push_inline_text(&mut out, &mut text, text_start, index);
3429            out.push((inline, index, index + consumed));
3430            index += consumed;
3431            text_start = index;
3432            continue;
3433        }
3434        let character = rest.chars().next().expect("index is in bounds");
3435        text.push(character);
3436        index += character.len_utf8();
3437    }
3438    push_inline_text(&mut out, &mut text, text_start, index);
3439    out
3440}
3441
3442#[derive(Clone)]
3443enum PendingBlock {
3444    Indent { amount: u32, source: String },
3445    Bottom { amount: u32, source: String },
3446}
3447
3448fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
3449    let (indent, bottom) = match pending {
3450        Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
3451        Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
3452        None => (None, None),
3453    };
3454    MdiBlock::Paragraph {
3455        inlines: parse_inlines(line),
3456        indent,
3457        bottom,
3458    }
3459}
3460
3461fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
3462    if let Some(marker) = pending.take() {
3463        // As in the micromark adapter, an alignment macro without a following
3464        // eligible block remains literal source.
3465        let source = match marker {
3466            PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
3467        };
3468        blocks.push(paragraph(&source, None));
3469    }
3470}
3471
3472fn is_blank_marker(line: &str) -> bool {
3473    let value = line.trim_end_matches([' ', '\t']);
3474    value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
3475}
3476
3477fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
3478    match line.trim() {
3479        "[[pagebreak]]" => Some(None),
3480        "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
3481        "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
3482        _ => None,
3483    }
3484}
3485
3486fn pending_block(line: &str) -> Option<PendingBlock> {
3487    let value = line.trim();
3488    if value == "[[bottom]]" {
3489        return Some(PendingBlock::Bottom {
3490            amount: 0,
3491            source: value.to_owned(),
3492        });
3493    }
3494    let (kind, amount) = value
3495        .strip_prefix("[[")?
3496        .strip_suffix("]]")?
3497        .split_once(':')?;
3498    if amount.is_empty()
3499        || amount.starts_with('0')
3500        || !amount.bytes().all(|byte| byte.is_ascii_digit())
3501    {
3502        return None;
3503    }
3504    let amount = amount.parse().ok()?;
3505    match kind {
3506        "indent" => Some(PendingBlock::Indent {
3507            amount,
3508            source: value.to_owned(),
3509        }),
3510        "bottom" => Some(PendingBlock::Bottom {
3511            amount,
3512            source: value.to_owned(),
3513        }),
3514        _ => None,
3515    }
3516}
3517
3518fn ruby(value: &str) -> Option<(Inline, usize)> {
3519    if !value.starts_with('{') {
3520        return None;
3521    }
3522    let end = close_unescaped(value, 1, '}')?;
3523    let body = &value[1..end];
3524    let separator = bare_index(body, '|')?;
3525    let base = unescape_ruby(&body[..separator]);
3526    let raw_ruby = &body[separator + 1..];
3527    let ruby = split_ruby(&base, raw_ruby);
3528    Some((Inline::Ruby { base, ruby }, end + 1))
3529}
3530
3531fn split_ruby(base: &str, raw: &str) -> RubyReading {
3532    let segments = split_unescaped(raw, '.');
3533    if segments.len() == 1 {
3534        return RubyReading::Group(unescape_ruby(raw));
3535    }
3536    let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
3537    if segments.len() == base.graphemes(true).count()
3538        && segments.iter().all(|part| !part.is_empty())
3539    {
3540        RubyReading::Split(segments)
3541    } else {
3542        RubyReading::Group(segments.concat())
3543    }
3544}
3545
3546fn tcy(value: &str) -> Option<(Inline, usize)> {
3547    if !value.starts_with('^') {
3548        return None;
3549    }
3550    let closing = value[1..].find('^')? + 1;
3551    let body = &value[1..closing];
3552    if body.is_empty()
3553        || body.chars().count() > 6
3554        || !body
3555            .chars()
3556            .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
3557    {
3558        return None;
3559    }
3560    Some((Inline::Tcy(body.to_owned()), closing + 1))
3561}
3562
3563fn boten(value: &str) -> Option<(Inline, usize)> {
3564    let prefix = "《《";
3565    if !value.starts_with(prefix) {
3566        return None;
3567    }
3568    let end = close_boten_alias(value)?;
3569    let body = &value[prefix.len()..end];
3570    if body.is_empty()
3571        || body.contains('\n')
3572        || contains_unescaped(body, '《')
3573        || contains_unescaped(body, '》')
3574    {
3575        return None;
3576    }
3577    Some((
3578        Inline::Em {
3579            mark: "﹅".to_owned(),
3580            children: vec![Inline::Text(unescape_mdi(body))],
3581        },
3582        end + "》》".len(),
3583    ))
3584}
3585
3586/// Find the closing `》》` of the supported boten alias.  As with every other
3587/// MDI delimiter, an escaped `》` is content and cannot close the alias.
3588fn close_boten_alias(value: &str) -> Option<usize> {
3589    let mut index = "《《".len();
3590    while index < value.len() {
3591        let rest = &value[index..];
3592        if rest.starts_with('\\') {
3593            let next = rest.chars().nth(1)?;
3594            index += 1 + next.len_utf8();
3595        } else if rest.starts_with("》》") {
3596            return Some(index);
3597        } else {
3598            index += rest.chars().next()?.len_utf8();
3599        }
3600    }
3601    None
3602}
3603
3604fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
3605    if !value.starts_with("[[") {
3606        return None;
3607    }
3608    if value.starts_with("[[br]]") {
3609        return Some((Inline::Break, "[[br]]".len()));
3610    }
3611    let end = close_macro(value)?;
3612    let body = &value[2..end];
3613    let (name, payload) = body.split_once(':')?;
3614    let children = |content: &str| parse_inlines(content);
3615    let inline = match name {
3616        "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
3617        "warichu" => Inline::Warichu(children(payload)),
3618        "kern" => {
3619            let (amount, content) = payload.split_once(':')?;
3620            if !valid_kern(amount) {
3621                return None;
3622            }
3623            Inline::Kern {
3624                amount: unescape_mdi(amount),
3625                children: children(content),
3626            }
3627        }
3628        "em" => {
3629            let (mark, content) = match bare_index(payload, ':') {
3630                Some(index) => {
3631                    let candidate = unescape_mdi(&payload[..index]);
3632                    if candidate.graphemes(true).count() == 1
3633                        && !candidate
3634                            .chars()
3635                            .any(|c| c.is_whitespace() || c.is_control())
3636                    {
3637                        (candidate, &payload[index + 1..])
3638                    } else {
3639                        ("﹅".to_owned(), payload)
3640                    }
3641                }
3642                None => ("﹅".to_owned(), payload),
3643            };
3644            Inline::Em {
3645                mark,
3646                children: children(content),
3647            }
3648        }
3649        _ => return None,
3650    };
3651    Some((inline, end + 2))
3652}
3653
3654fn close_macro(value: &str) -> Option<usize> {
3655    let mut index = 2;
3656    let mut depth = 1;
3657    while index < value.len() {
3658        let rest = &value[index..];
3659        if rest.starts_with('\\') {
3660            index += rest.chars().nth(1)?.len_utf8() + 1;
3661        } else if rest.starts_with("[[") {
3662            depth += 1;
3663            index += 2;
3664        } else if rest.starts_with("]]") {
3665            depth -= 1;
3666            if depth == 0 {
3667                return Some(index);
3668            }
3669            index += 2;
3670        } else {
3671            index += rest.chars().next()?.len_utf8();
3672        }
3673    }
3674    None
3675}
3676
3677fn valid_kern(value: &str) -> bool {
3678    let value = value.strip_suffix("em").unwrap_or("");
3679    let value = value.strip_prefix(['+', '-']).unwrap_or(value);
3680    let mut parts = value.split('.');
3681    let whole = parts.next().unwrap_or("");
3682    let fraction = parts.next();
3683    parts.next().is_none()
3684        && !whole.is_empty()
3685        && whole.bytes().all(|b| b.is_ascii_digit())
3686        && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
3687}
3688
3689fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
3690    let mut escaped = false;
3691    for (index, character) in value[start..].char_indices() {
3692        if escaped {
3693            escaped = false;
3694            continue;
3695        }
3696        if character == '\\' {
3697            escaped = true;
3698            continue;
3699        }
3700        if character == needle {
3701            return Some(start + index);
3702        }
3703        if character == '\n' {
3704            return None;
3705        }
3706    }
3707    None
3708}
3709
3710fn bare_index(value: &str, needle: char) -> Option<usize> {
3711    let mut escaped = false;
3712    for (index, character) in value.char_indices() {
3713        if escaped {
3714            escaped = false;
3715            continue;
3716        }
3717        if character == '\\' {
3718            escaped = true;
3719            continue;
3720        }
3721        if character == needle {
3722            return Some(index);
3723        }
3724    }
3725    None
3726}
3727
3728fn contains_unescaped(value: &str, needle: char) -> bool {
3729    bare_index(value, needle).is_some()
3730}
3731
3732fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
3733    let mut parts = Vec::new();
3734    let mut start = 0;
3735    let mut escaped = false;
3736    for (index, character) in value.char_indices() {
3737        if escaped {
3738            escaped = false;
3739            continue;
3740        }
3741        if character == '\\' {
3742            escaped = true;
3743            continue;
3744        }
3745        if character == separator {
3746            parts.push(&value[start..index]);
3747            start = index + character.len_utf8();
3748        }
3749    }
3750    parts.push(&value[start..]);
3751    parts
3752}
3753
3754/// SYNTAX.md §13's delimiter set, plus `\\` itself — CommonMark treats
3755/// backslash as ordinary escapable ASCII punctuation, and JS's
3756/// `unescapeMdi`/`unescapeRubyText` follow suit (`\\` -> `\`), so the Rust
3757/// core must too or `\\` is left as two literal characters instead of one.
3758const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
3759const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
3760
3761fn unescape_mdi(value: &str) -> String {
3762    unescape(value, ESCAPABLE_MDI)
3763}
3764
3765fn unescape_ruby(value: &str) -> String {
3766    unescape(value, ESCAPABLE_RUBY)
3767}
3768
3769fn unescape(value: &str, allowed: &str) -> String {
3770    let mut out = String::new();
3771    let mut chars = value.chars();
3772    while let Some(character) = chars.next() {
3773        if character == '\\' {
3774            if let Some(next) = chars.next() {
3775                if allowed.contains(next) {
3776                    out.push(next);
3777                } else {
3778                    out.push(character);
3779                    out.push(next);
3780                }
3781            } else {
3782                out.push(character);
3783            }
3784        } else {
3785            out.push(character);
3786        }
3787    }
3788    out
3789}
3790
3791fn is_escapable(character: char) -> bool {
3792    ESCAPABLE_MDI.contains(character)
3793}
3794
3795fn push_inline_text(
3796    out: &mut Vec<(Inline, usize, usize)>,
3797    text: &mut String,
3798    start: usize,
3799    end: usize,
3800) {
3801    if !text.is_empty() {
3802        out.push((Inline::Text(std::mem::take(text)), start, end));
3803    }
3804}
3805
3806/// A single `[[indent:N]]` / `[[bottom]]` / `[[pagebreak…]]` token's source,
3807/// classified the same way as `mdast-util-mdi`'s `parseBlockMacro` (from
3808/// which this was ported): trims the token source once, then matches the
3809/// fixed pagebreak/bottom spellings before falling back to the generic
3810/// `name:digits` form. Kept separate from `pending_block`/`pagebreak`
3811/// above, which classify whole *lines* mid-scan rather than one already
3812/// isolated token.
3813#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3814enum BlockMacroClass {
3815    Indent(u32),
3816    Bottom(u32),
3817    Pagebreak(Option<PagebreakVariant>),
3818    Literal,
3819}
3820
3821#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3822fn classify_block_macro(source: &str) -> BlockMacroClass {
3823    let value = source.trim();
3824    match value {
3825        "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
3826        "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
3827        "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
3828        "[[bottom]]" => return BlockMacroClass::Bottom(0),
3829        _ => {}
3830    }
3831    if let Some((kind, amount)) = value
3832        .strip_prefix("[[")
3833        .and_then(|rest| rest.strip_suffix("]]"))
3834        .and_then(|inner| inner.split_once(':'))
3835    {
3836        let valid_amount = !amount.is_empty()
3837            && !amount.starts_with('0')
3838            && amount.bytes().all(|b| b.is_ascii_digit());
3839        if valid_amount && let Ok(amount) = amount.parse::<u32>() {
3840            match kind {
3841                "indent" => return BlockMacroClass::Indent(amount),
3842                "bottom" => return BlockMacroClass::Bottom(amount),
3843                _ => {}
3844            }
3845        }
3846    }
3847    BlockMacroClass::Literal
3848}
3849
3850/// wasm-bindgen bindings for the complete JavaScript interface and legacy
3851/// semantic helpers retained for compatibility tests.
3852#[cfg(feature = "wasm")]
3853mod wasm {
3854    use super::{
3855        BlockMacroClass, EpubCover, PagebreakVariant, RubyReading, TextFormat,
3856        apply_pdf_profile_json, classify_block_macro, page_size_catalog_json, parse_json,
3857        prepare_chromium_print_profile_json, render_docx, render_docx_with_profile, render_epub,
3858        render_epub_with_profile, render_html, render_text, render_text_format,
3859        resolve_export_profile_json, serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
3860    };
3861    use wasm_bindgen::prelude::*;
3862
3863    /// Parse with Rust and return the versioned MDI IR as JSON.
3864    ///
3865    /// The JavaScript package parses this string and performs no syntax work.
3866    #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
3867    pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
3868        parse_json(source)
3869    }
3870
3871    /// Render source through the Rust parser and Rust HTML renderer.
3872    #[wasm_bindgen(js_name = renderHtml)]
3873    pub fn wasm_render_html(source: &str) -> String {
3874        render_html(source)
3875    }
3876
3877    /// Validate and resolve the language-neutral configured-export profile.
3878    #[wasm_bindgen(js_name = resolveExportProfileJson)]
3879    pub fn wasm_resolve_export_profile_json(
3880        profile_json: &str,
3881        source_writing_mode: Option<String>,
3882        require_layout: bool,
3883    ) -> Result<String, JsValue> {
3884        resolve_export_profile_json(profile_json, source_writing_mode.as_deref(), require_layout)
3885            .map_err(|message| JsValue::from_str(&message))
3886    }
3887
3888    #[wasm_bindgen(js_name = pageSizeCatalogJson)]
3889    pub fn wasm_page_size_catalog_json() -> Result<String, JsValue> {
3890        page_size_catalog_json().map_err(|message| JsValue::from_str(&message))
3891    }
3892
3893    /// Apply canonical Rust-owned print CSS to HTML using a resolved profile.
3894    #[wasm_bindgen(js_name = applyPdfProfileJson)]
3895    pub fn wasm_apply_pdf_profile_json(html: &str, profile_json: &str) -> Result<String, JsValue> {
3896        apply_pdf_profile_json(html, profile_json).map_err(|message| JsValue::from_str(&message))
3897    }
3898
3899    /// Resolve and prepare all browser-independent Chromium print data.
3900    #[wasm_bindgen(js_name = prepareChromiumPrintProfileJson)]
3901    pub fn wasm_prepare_chromium_print_profile_json(
3902        html: &str,
3903        profile_json: &str,
3904        source_writing_mode: Option<String>,
3905    ) -> Result<String, JsValue> {
3906        prepare_chromium_print_profile_json(html, profile_json, source_writing_mode.as_deref())
3907            .map_err(|message| JsValue::from_str(&message))
3908    }
3909
3910    /// Normalize source through the Rust parser and canonical serializer.
3911    #[wasm_bindgen(js_name = serializeMdi)]
3912    pub fn wasm_serialize_mdi(source: &str) -> String {
3913        serialize_mdi(source)
3914    }
3915
3916    /// Render plain text directly from the Rust IR.
3917    #[wasm_bindgen(js_name = renderText)]
3918    pub fn wasm_render_text(source: &str) -> String {
3919        render_text(source)
3920    }
3921
3922    /// Render a named text export convention through the Rust core.
3923    #[wasm_bindgen(js_name = renderTextFormat)]
3924    pub fn wasm_render_text_format(
3925        source: &str,
3926        format: &str,
3927        indent_prefix: &str,
3928    ) -> Result<String, JsValue> {
3929        let format = TextFormat::parse(format)
3930            .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
3931        Ok(render_text_format(source, format, indent_prefix))
3932    }
3933
3934    /// Build a baseline EPUB 3 archive entirely in Rust.
3935    #[wasm_bindgen(js_name = renderEpub)]
3936    pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
3937        render_epub(source)
3938            .map(Vec::into_boxed_slice)
3939            .map_err(|message| JsValue::from_str(&message))
3940    }
3941
3942    /// Build a configured EPUB through the same Rust path used by native bindings.
3943    #[wasm_bindgen(js_name = renderEpubWithProfile)]
3944    pub fn wasm_render_epub_with_profile(
3945        source: &str,
3946        profile_json: &str,
3947        cover_data: &[u8],
3948        cover_media_type: Option<String>,
3949    ) -> Result<Box<[u8]>, JsValue> {
3950        let cover = cover_media_type.map(|media_type| EpubCover {
3951            data: cover_data.to_vec(),
3952            media_type,
3953        });
3954        render_epub_with_profile(source, profile_json, cover.as_ref())
3955            .map(Vec::into_boxed_slice)
3956            .map_err(|message| JsValue::from_str(&message))
3957    }
3958
3959    /// Build a baseline DOCX archive entirely in Rust.
3960    #[wasm_bindgen(js_name = renderDocx)]
3961    pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
3962        render_docx(source)
3963            .map(Vec::into_boxed_slice)
3964            .map_err(|message| JsValue::from_str(&message))
3965    }
3966
3967    /// Build a configured DOCX through the canonical Rust OOXML writer.
3968    #[wasm_bindgen(js_name = renderDocxWithProfile)]
3969    pub fn wasm_render_docx_with_profile(
3970        source: &str,
3971        profile_json: &str,
3972    ) -> Result<Box<[u8]>, JsValue> {
3973        render_docx_with_profile(source, profile_json)
3974            .map(Vec::into_boxed_slice)
3975            .map_err(|message| JsValue::from_str(&message))
3976    }
3977
3978    #[wasm_bindgen(js_name = unescapeMdi)]
3979    pub fn wasm_unescape_mdi(value: &str) -> String {
3980        unescape_mdi(value)
3981    }
3982
3983    #[wasm_bindgen(js_name = unescapeRubyText)]
3984    pub fn wasm_unescape_ruby(value: &str) -> String {
3985        unescape_ruby(value)
3986    }
3987
3988    /// Mirrors `resolveRuby(base, rawRuby): string | string[]`.
3989    #[wasm_bindgen(js_name = resolveRuby)]
3990    pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
3991        match split_ruby(base, raw_ruby) {
3992            RubyReading::Group(value) => JsValue::from_str(&value),
3993            RubyReading::Split(parts) => {
3994                let array = js_sys::Array::new();
3995                for part in parts {
3996                    array.push(&JsValue::from_str(&part));
3997                }
3998                array.into()
3999            }
4000        }
4001    }
4002
4003    #[wasm_bindgen(js_name = blockMacroKind)]
4004    pub fn wasm_block_macro_kind(source: &str) -> String {
4005        match classify_block_macro(source) {
4006            BlockMacroClass::Indent(_) => "indent",
4007            BlockMacroClass::Bottom(_) => "bottom",
4008            BlockMacroClass::Pagebreak(_) => "pagebreak",
4009            BlockMacroClass::Literal => "literal",
4010        }
4011        .to_owned()
4012    }
4013
4014    /// -1 when the token has no amount (pagebreak / literal).
4015    #[wasm_bindgen(js_name = blockMacroAmount)]
4016    pub fn wasm_block_macro_amount(source: &str) -> i32 {
4017        match classify_block_macro(source) {
4018            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
4019            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
4020        }
4021    }
4022
4023    /// `"left"` / `"right"` / `""` (no variant, including non-pagebreak kinds).
4024    #[wasm_bindgen(js_name = blockMacroVariant)]
4025    pub fn wasm_block_macro_variant(source: &str) -> String {
4026        match classify_block_macro(source) {
4027            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
4028            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
4029            _ => "",
4030        }
4031        .to_owned()
4032    }
4033}
4034
4035#[cfg(test)]
4036mod tests {
4037    use super::*;
4038    use std::io::{Error, Read, SeekFrom};
4039    use zip::ZipArchive;
4040
4041    struct FailAfterWrites {
4042        inner: Cursor<Vec<u8>>,
4043        remaining: usize,
4044    }
4045
4046    impl Write for FailAfterWrites {
4047        fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
4048            if self.remaining == 0 {
4049                return Err(Error::other("injected archive write failure"));
4050            }
4051            self.remaining -= 1;
4052            self.inner.write(buffer)
4053        }
4054
4055        fn flush(&mut self) -> std::io::Result<()> {
4056            self.inner.flush()
4057        }
4058    }
4059
4060    impl Seek for FailAfterWrites {
4061        fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
4062            self.inner.seek(position)
4063        }
4064    }
4065
4066    fn assert_valid_spans(value: &serde_json::Value, source: &str) {
4067        let Some(object) = value.as_object() else {
4068            return;
4069        };
4070        if let Some(span) = object.get("span") {
4071            let start = span
4072                .get("startByte")
4073                .and_then(serde_json::Value::as_u64)
4074                .expect("span has startByte") as usize;
4075            let end = span
4076                .get("endByte")
4077                .and_then(serde_json::Value::as_u64)
4078                .expect("span has endByte") as usize;
4079            assert!(start <= end, "span start must not exceed end: {span}");
4080            assert!(
4081                end <= source.len(),
4082                "span must be inside its source: {span}"
4083            );
4084            assert!(
4085                source.is_char_boundary(start),
4086                "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
4087            );
4088            let remainder = &source[start..];
4089            assert!(
4090                source.is_char_boundary(end),
4091                "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
4092                remainder
4093            );
4094        }
4095        if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
4096            for child in children {
4097                assert_valid_spans(child, source);
4098            }
4099        }
4100    }
4101
4102    fn generated_source(mut state: u64) -> String {
4103        // A deterministic adversarial corpus: every fragment is individually
4104        // valid UTF-8, while their arbitrary adjacency exercises delimiter,
4105        // Markdown, front-matter, and MDI recovery boundaries.
4106        if state == 0 {
4107            return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
4108        }
4109        const FRAGMENTS: &[&str] = &[
4110            "東京",
4111            "𠮟る",
4112            "👨‍👩‍👧",
4113            " ",
4114            "\\n",
4115            "\\\\",
4116            "{",
4117            "}",
4118            "|",
4119            ".",
4120            "^12^",
4121            "^_^",
4122            "{東京|とう.きょう}",
4123            "[[em:強調]]",
4124            "[[no-break:^12^]]",
4125            "[[kern:wide:x]]",
4126            "[[indent:2]]",
4127            "[[pagebreak:left]]",
4128            "《《傍点》》",
4129            "**strong**",
4130            "`^12^`",
4131            "[^n]",
4132            "\n\n",
4133            "| a | b |\n| - | - |\n| 1 | 2 |\n",
4134            "[link](https://example.test/?a=1&b=2)",
4135            "<script>x</script>",
4136            "\\{",
4137            "\\[",
4138        ];
4139        let mut source = String::new();
4140        for _ in 0..32 {
4141            state ^= state << 13;
4142            state ^= state >> 7;
4143            state ^= state << 17;
4144            source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
4145        }
4146        source
4147    }
4148
4149    #[test]
4150    fn parses_ruby_and_split_ruby() {
4151        assert_eq!(
4152            parse_inlines("{東京|とう.きょう}"),
4153            vec![Inline::Ruby {
4154                base: "東京".into(),
4155                ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
4156            }]
4157        );
4158        assert_eq!(
4159            parse_inlines("{東京|.きょう}"),
4160            vec![Inline::Ruby {
4161                base: "東京".into(),
4162                ruby: RubyReading::Group("きょう".into()),
4163            }]
4164        );
4165    }
4166
4167    #[test]
4168    fn parses_nested_macros_and_escapes() {
4169        assert_eq!(
4170            parse_inlines("[[em:●:a[[no-break:b]]]]"),
4171            vec![Inline::Em {
4172                mark: "●".into(),
4173                children: vec![
4174                    Inline::Text("a".into()),
4175                    Inline::NoBreak(vec![Inline::Text("b".into())])
4176                ],
4177            }]
4178        );
4179    }
4180
4181    #[test]
4182    fn parses_tcy_and_boten() {
4183        assert_eq!(
4184            parse_inlines("第^12^話《《重要》》"),
4185            vec![
4186                Inline::Text("第".into()),
4187                Inline::Tcy("12".into()),
4188                Inline::Text("話".into()),
4189                Inline::Em {
4190                    mark: "﹅".into(),
4191                    children: vec![Inline::Text("重要".into())]
4192                },
4193            ]
4194        );
4195    }
4196
4197    #[test]
4198    fn recognises_block_macros() {
4199        assert_eq!(
4200            parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
4201            MdiSyntaxDocument {
4202                blocks: vec![
4203                    MdiBlock::Paragraph {
4204                        inlines: vec![Inline::Text("本文".into())],
4205                        indent: Some(2),
4206                        bottom: None
4207                    },
4208                    MdiBlock::Pagebreak {
4209                        variant: Some(PagebreakVariant::Left)
4210                    },
4211                    MdiBlock::Blank,
4212                ]
4213            }
4214        );
4215    }
4216
4217    #[test]
4218    fn keeps_invalid_syntax_literal() {
4219        assert_eq!(
4220            parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
4221            vec![Inline::Text(
4222                "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
4223            )]
4224        );
4225    }
4226
4227    #[test]
4228    fn recovers_from_malformed_inline_syntax_at_delimiter_boundaries() {
4229        // A malformed construct must stay literal without preventing a later,
4230        // adjacent construct from being recognized.  These cases cover every
4231        // inline delimiter family, including a delimiter at end of input.
4232        for (source, expected) in [
4233            ("{東京|とうきょう", "{東京|とうきょう"),
4234            ("{東京|とうきょう ^12^", "{東京|とうきょう 12"),
4235            ("^1234567^ ^12^", "^1234567^ 12"),
4236            ("[[no-break:]][[em:強調]]", "[[no-break:]]強調"),
4237            ("[[kern:wide:字]][[warichu:注]]", "[[kern:wide:字]]注"),
4238            ("[[em:未閉", "[[em:未閉"),
4239            ("《《未閉", "《《未閉"),
4240            ("{", "{"),
4241            ("^", "^"),
4242            ("[[", "[["),
4243        ] {
4244            let rendered = render_text(source);
4245            assert_eq!(rendered.trim_end(), expected, "source: {source:?}");
4246            assert!(
4247                parse_output(source).diagnostics.is_empty(),
4248                "source: {source:?}"
4249            );
4250        }
4251    }
4252
4253    #[test]
4254    fn honors_escaped_alias_delimiters_without_consuming_them_as_closers() {
4255        // Escaped delimiters never participate in matching.  In particular,
4256        // the first `》` below is content; the following pair closes the alias.
4257        assert_eq!(
4258            parse_inlines("《《a\\》》》"),
4259            vec![Inline::Em {
4260                mark: "﹅".into(),
4261                children: vec![Inline::Text("a》".into())],
4262            }]
4263        );
4264
4265        // Without a non-escaped closing pair, the complete alias remains
4266        // literal instead of partially consuming its escaped content.
4267        assert_eq!(
4268            parse_inlines("《《a\\》》"),
4269            vec![Inline::Text("《《a》》".into())]
4270        );
4271    }
4272
4273    #[test]
4274    fn applies_mdi_escapes_once_before_recognition() {
4275        assert_eq!(
4276            parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
4277            vec![Inline::Text(
4278                "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
4279            )]
4280        );
4281    }
4282
4283    #[test]
4284    fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
4285        assert_eq!(
4286            parse_inlines(r"\\ \n \a \0 \-"),
4287            vec![Inline::Text(r"\ \n \a \0 \-".into())]
4288        );
4289    }
4290
4291    #[test]
4292    fn treats_boten_alias_content_as_plain_text() {
4293        assert_eq!(
4294            parse_inlines(r"《《a\《b》》"),
4295            vec![Inline::Em {
4296                mark: "﹅".into(),
4297                children: vec![Inline::Text("a《b".into())]
4298            }]
4299        );
4300        assert_eq!(
4301            parse_inlines("《《雪》考》"),
4302            vec![Inline::Text("《《雪》考》".into())]
4303        );
4304    }
4305
4306    #[test]
4307    fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
4308        assert_eq!(
4309            parse_inlines("[[em:ab:cd]]"),
4310            vec![Inline::Em {
4311                mark: "﹅".into(),
4312                children: vec![Inline::Text("ab:cd".into())]
4313            }]
4314        );
4315    }
4316
4317    #[test]
4318    fn counts_extended_graphemes_for_split_ruby() {
4319        assert_eq!(
4320            parse_inlines("{𠮟る|しか.る}"),
4321            vec![Inline::Ruby {
4322                base: "𠮟る".into(),
4323                ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
4324            }]
4325        );
4326        assert_eq!(
4327            parse_inlines("{👨‍👩‍👧|かぞく}"),
4328            vec![Inline::Ruby {
4329                base: "👨‍👩‍👧".into(),
4330                ruby: RubyReading::Group("かぞく".into())
4331            }]
4332        );
4333    }
4334
4335    #[test]
4336    fn leaves_unattached_or_stacked_block_macros_literal() {
4337        assert_eq!(
4338            parse_mdi_syntax("[[indent:2]]"),
4339            MdiSyntaxDocument {
4340                blocks: vec![MdiBlock::Paragraph {
4341                    inlines: vec![Inline::Text("[[indent:2]]".into())],
4342                    indent: None,
4343                    bottom: None
4344                }]
4345            }
4346        );
4347        assert_eq!(
4348            parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
4349            MdiSyntaxDocument {
4350                blocks: vec![
4351                    MdiBlock::Paragraph {
4352                        inlines: vec![Inline::Text("[[indent:2]]".into())],
4353                        indent: None,
4354                        bottom: None
4355                    },
4356                    MdiBlock::Paragraph {
4357                        inlines: vec![Inline::Text("[[bottom]]".into())],
4358                        indent: None,
4359                        bottom: None
4360                    },
4361                    MdiBlock::Paragraph {
4362                        inlines: vec![Inline::Text("本文".into())],
4363                        indent: None,
4364                        bottom: None
4365                    }
4366                ]
4367            }
4368        );
4369    }
4370
4371    #[test]
4372    fn serializes_the_versioned_binding_contract() {
4373        let value: serde_json::Value =
4374            serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
4375                .expect("parse output is valid JSON");
4376
4377        assert_eq!(value["irVersion"], "1.0");
4378        assert_eq!(value["syntaxVersion"], "2.0");
4379        assert_eq!(value["capabilities"]["mdi"], true);
4380        assert_eq!(value["capabilities"]["commonMark"], true);
4381        assert_eq!(value["capabilities"]["gfm"], true);
4382        assert_eq!(value["capabilities"]["frontMatter"], true);
4383        assert_eq!(value["capabilities"]["sourceSpans"], true);
4384        assert_eq!(value["diagnostics"], serde_json::json!([]));
4385        assert_eq!(value["document"]["children"][0]["type"], "paragraph");
4386        assert_eq!(
4387            value["document"]["children"][0]["children"][1]["type"],
4388            "tcy"
4389        );
4390        assert_eq!(value["document"]["span"]["endByte"], 43);
4391    }
4392
4393    #[test]
4394    fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
4395        let document = parse_document(
4396            "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
4397        );
4398        assert_eq!(
4399            document.frontmatter.as_ref().unwrap().entries[0].key,
4400            "title"
4401        );
4402        assert_eq!(document.children[0]["type"], "heading");
4403        assert_eq!(document.children[1]["type"], "list");
4404        assert_eq!(document.children[2]["type"], "table");
4405        assert_eq!(document.children[0]["span"]["startByte"], 43);
4406    }
4407
4408    #[test]
4409    fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
4410        let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
4411        assert_eq!(document.children[0]["type"], "blockquote");
4412        assert!(document.children.iter().any(|node| node["type"] == "code"));
4413    }
4414
4415    #[test]
4416    fn lowers_root_flow_markers_without_a_host_markdown_parser() {
4417        let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
4418        assert_eq!(document.children[0]["type"], "paragraph");
4419        assert_eq!(document.children[0]["indent"], 2);
4420        assert_eq!(document.children[1]["type"], "pagebreak");
4421        assert_eq!(document.children[1]["variant"], "right");
4422        assert_eq!(document.children[2]["type"], "blank");
4423    }
4424
4425    #[test]
4426    fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
4427        let document = parse_document("[[em:**重要**]]");
4428        let em = &document.children[0]["children"][0];
4429        assert_eq!(em["type"], "em");
4430        assert_eq!(em["mark"], "﹅");
4431        assert_eq!(em["children"][0]["type"], "strong");
4432        assert_eq!(em["children"][0]["children"][0]["value"], "重要");
4433    }
4434
4435    #[test]
4436    fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
4437        assert!(
4438            markdown_paragraph_children(
4439                "前 [[em:**重要**]] 後",
4440                &serde_json::json!({"startByte": 0, "endByte": 26})
4441            )
4442            .is_some()
4443        );
4444        let document = parse_document("前 [[em:**重要**]] 後");
4445        let children = &document.children[0]["children"];
4446        assert_eq!(children[0]["value"], "前");
4447        assert_eq!(children[1]["value"], " ");
4448        assert_eq!(children[2]["type"], "em");
4449        assert_eq!(children[2]["children"][0]["type"], "strong");
4450        assert_eq!(children[3]["value"], " ");
4451        assert_eq!(children[4]["value"], "後");
4452        assert_eq!(children[2]["span"]["startByte"], 4);
4453        assert_eq!(children[2]["span"]["endByte"], 21);
4454        assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
4455        assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
4456    }
4457
4458    #[test]
4459    fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
4460        let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
4461        let children = &document.children[0]["children"][0]["children"];
4462        assert_eq!(children[0]["type"], "ruby");
4463        assert_eq!(children[1]["type"], "noBreak");
4464        assert_eq!(children[1]["children"][0]["type"], "tcy");
4465    }
4466
4467    #[test]
4468    fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
4469        let document =
4470            parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
4471        let paragraph = &document.children[1];
4472        assert_eq!(paragraph["span"]["startByte"], 7);
4473        assert_eq!(paragraph["children"][0]["type"], "ruby");
4474        assert_eq!(paragraph["children"][0]["base"], "東京");
4475        assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
4476        assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
4477        assert_eq!(paragraph["children"][2]["type"], "em");
4478        assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
4479        assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
4480        assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
4481        assert_eq!(paragraph["children"][4]["identifier"], "n");
4482    }
4483
4484    #[test]
4485    fn renders_a_standalone_html_document_from_rust_ir() {
4486        let html = render_html(
4487            "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
4488        );
4489        assert!(html.starts_with("<!DOCTYPE html>"));
4490        assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
4491        assert!(html.contains("<title>雪女</title>"));
4492        assert!(html.contains("<h1>題</h1>"));
4493        assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
4494        assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
4495    }
4496
4497    #[test]
4498    fn renders_every_documented_mdi_construct_in_vertical_html() {
4499        let vertical = "---\ntitle: 構文\nlang: ja\nwriting-mode: vertical\n---\n\n";
4500        for (name, source, expected) in [
4501            (
4502                "front matter",
4503                "# 見出し",
4504                "<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">",
4505            ),
4506            (
4507                "group ruby",
4508                "{東京|とうきょう}",
4509                "<ruby class=\"mdi-ruby\">東京",
4510            ),
4511            (
4512                "split ruby",
4513                "{東京|とう.きょう}",
4514                "<ruby class=\"mdi-ruby\"",
4515            ),
4516            ("tate-chu-yoko", "^12^", "<span class=\"mdi-tcy\">12</span>"),
4517            ("default boten", "[[em:傍点]]", "class=\"mdi-em\""),
4518            ("custom boten", "[[em:※:任意]]", "--mdi-em:&quot;※&quot;"),
4519            ("no-break", "[[no-break:改行禁止]]", "class=\"mdi-nobr\""),
4520            (
4521                "explicit line break",
4522                "前[[br]]次",
4523                "<br class=\"mdi-break\"/>",
4524            ),
4525            ("blank backslash", "\\", "<p class=\"mdi-blank\"></p>"),
4526            ("blank br", "<br>", "<p class=\"mdi-blank\"></p>"),
4527            ("blank br slash", "<br />", "<p class=\"mdi-blank\"></p>"),
4528            (
4529                "blank legacy macro",
4530                "[[blank]]",
4531                "<p class=\"mdi-blank\"></p>",
4532            ),
4533            ("warichu", "[[warichu:割注]]", "class=\"mdi-warichu\""),
4534            ("kerning", "[[kern:-0.1em:詰め]]", "--mdi-kern:-0.1em"),
4535            ("indent", "[[indent:2]]\n字下げ", "class=\"mdi-indent\""),
4536            (
4537                "bottom alignment",
4538                "[[bottom]]\n地付き",
4539                "class=\"mdi-bottom\"",
4540            ),
4541            ("bottom shift", "[[bottom:2]]\n地付き", "--mdi-shift:2"),
4542            ("page break", "[[pagebreak]]", "class=\"mdi-pagebreak\""),
4543            (
4544                "recto page break",
4545                "[[pagebreak:right]]",
4546                "class=\"mdi-pagebreak mdi-pagebreak-right\"",
4547            ),
4548            (
4549                "verso page break",
4550                "[[pagebreak:left]]",
4551                "class=\"mdi-pagebreak mdi-pagebreak-left\"",
4552            ),
4553            ("footnote", "脚注[^n]\n\n[^n]: 注", "data-footnotes"),
4554            (
4555                "escaped delimiters",
4556                "\\{ \\} \\| \\^ \\[ \\: \\《 \\》",
4557                "{ } | ^ [ : 《 》",
4558            ),
4559        ] {
4560            let html = render_html(&format!("{vertical}{source}"));
4561            assert!(
4562                html.contains(expected),
4563                "missing {name}: {expected}; rendered {html}"
4564            );
4565        }
4566        assert!(render_html(&format!("{vertical}\\")).contains(".mdi-blank{min-block-size:1lh}"));
4567    }
4568
4569    #[test]
4570    fn renders_footnote_definitions_in_html() {
4571        let html = render_html("本文[^n]\n\n[^n]: 注の本文");
4572        assert!(html.contains("data-footnotes"));
4573        assert!(html.contains("id=\"user-content-fn-n\""));
4574        assert!(html.contains("注の本文"));
4575    }
4576
4577    #[test]
4578    fn escapes_raw_html_in_the_rust_renderer() {
4579        let html = render_html("<script>alert(1)</script>");
4580        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
4581        assert!(!html.contains("<script>alert(1)</script>"));
4582    }
4583
4584    #[test]
4585    fn serializes_mdi_from_rust_ir() {
4586        let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
4587        assert_eq!(
4588            serialize_mdi(source),
4589            "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
4590        );
4591    }
4592
4593    #[test]
4594    fn renders_plain_text_from_rust_ir() {
4595        assert_eq!(
4596            render_text("# 題\n\n{東京|とうきょう} ^12^"),
4597            "題\n東京 12\n"
4598        );
4599    }
4600
4601    #[test]
4602    fn renders_platform_text_formats_from_rust_ir() {
4603        let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
4604        assert_eq!(
4605            render_text_format(source, TextFormat::Plain, ""),
4606            "題\n東京強調。"
4607        );
4608        assert_eq!(
4609            render_text_format(source, TextFormat::Ruby, ""),
4610            "題\n{東京|とう.きょう}強調。"
4611        );
4612        assert_eq!(
4613            render_text_format(source, TextFormat::Kakuyomu, ""),
4614            "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
4615        );
4616        assert!(
4617            render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は中見出し]")
4618        );
4619    }
4620
4621    #[test]
4622    fn note_renderer_defensively_degrades_partial_and_future_ir() {
4623        let document = Document {
4624            span: SourceSpan::default(),
4625            frontmatter: None,
4626            children: vec![
4627                serde_json::json!({"type":"math", "value":"x < y"}),
4628                serde_json::json!({
4629                    "type":"paragraph",
4630                    "children":[
4631                        {"type":"inlineMath", "value":"x < y"},
4632                        {"type":"text", "value":" "},
4633                        {"type":"html", "value":"<i>raw</i>"},
4634                        {"type":"text", "value":" "},
4635                        {"type":"link", "url":"https://example.test/a>b", "children":[{"type":"text", "value":"link"}]},
4636                        {"type":"text", "value":" "},
4637                        {"type":"image", "url":"", "alt":"alt"},
4638                        {"type":"footnoteReference", "identifier":"missing"}
4639                    ]
4640                }),
4641                serde_json::json!({
4642                    "type":"heading",
4643                    "depth":1,
4644                    "children":[
4645                        {"type":"strong", "children":[{"type":"text", "value":"heading"}]},
4646                        {"type":"text", "value":" "},
4647                        {"type":"inlineMath", "value":"x < y"}
4648                    ]
4649                }),
4650                serde_json::json!({
4651                    "type":"blockquote",
4652                    "children":[{
4653                        "type":"paragraph",
4654                        "children":[{"type":"inlineMath", "value":"quoted"}]
4655                    }]
4656                }),
4657                serde_json::json!({
4658                    "type":"list",
4659                    "ordered":false,
4660                    "children":[{"type":"listItem", "checked":true, "children":[]}]
4661                }),
4662                serde_json::json!({
4663                    "type":"unknown",
4664                    "children":[{"type":"text", "value":"readable"}]
4665                }),
4666                serde_json::json!({"type":"unknown"}),
4667                serde_json::json!({"type":"blank"}),
4668            ],
4669        };
4670        let rendered = render_note_document(&document, "");
4671        assert!(rendered.contains("$$\nx < y\n$$"));
4672        assert!(rendered.contains("$${x < y}$$"));
4673        assert!(rendered.contains("<i>raw</i>"));
4674        assert!(rendered.contains("link (https://example.test/a>b)"));
4675        assert!(rendered.contains("画像: alt[注0]"));
4676        assert!(rendered.contains("## heading x < y"));
4677        assert!(rendered.contains("> quoted"));
4678        assert!(rendered.contains("- [x] "));
4679        assert!(rendered.contains("readable"));
4680    }
4681
4682    #[test]
4683    fn renders_and_serializes_every_public_inline_and_block_variant() {
4684        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";
4685
4686        let html = render_html(source);
4687        for expected in [
4688            "<h2>中見出し</h2>",
4689            "<h3>小見出し</h3>",
4690            "<blockquote><p>引用</p>",
4691            "<ol>",
4692            "<ul>",
4693            "<pre><code class=\"language-rust\">",
4694            "<hr>",
4695            "<table>",
4696            "<a href=\"https://example.test\" title=\"題\">リンク</a>",
4697            "<img src=\"image.png\" alt=\"画像\">",
4698            "<del>削除</del>",
4699            "<code>code</code>",
4700            "<br class=\"mdi-break\"/>",
4701            "mdi-warichu",
4702            "mdi-kern",
4703            "--mdi-em:&quot;●&quot;",
4704        ] {
4705            assert!(html.contains(expected), "HTML contains {expected}");
4706        }
4707
4708        let canonical = serialize_mdi(source);
4709        for expected in [
4710            "[[bottom]]",
4711            "## 中見出し",
4712            "> 引用",
4713            "1. 一",
4714            "- 箇条",
4715            "```rust",
4716            "| 見出し | 値 |",
4717            "[リンク](https://example.test \\題\")",
4718            "![画像](image.png)",
4719            "~~削除~~",
4720            "`code`",
4721            "[[br]]",
4722            "[[warichu:割書]]",
4723            "[[kern:1em:字]]",
4724            "[[em:●:傍点]]",
4725        ] {
4726            assert!(
4727                canonical.contains(expected),
4728                "canonical MDI contains {expected}"
4729            );
4730        }
4731
4732        let plain = render_text(source);
4733        assert!(plain.contains("画像"));
4734        assert!(plain.contains("巢狀"));
4735
4736        for (name, format) in [
4737            ("txt", TextFormat::Plain),
4738            ("txt-ruby", TextFormat::Ruby),
4739            ("narou", TextFormat::Narou),
4740            ("kakuyomu", TextFormat::Kakuyomu),
4741            ("aozora", TextFormat::Aozora),
4742            ("note", TextFormat::Note),
4743        ] {
4744            assert_eq!(TextFormat::parse(name), Some(format));
4745            assert!(!render_text_format(source, format, " ").is_empty());
4746        }
4747        assert_eq!(TextFormat::parse("unknown"), None);
4748    }
4749
4750    #[test]
4751    fn packages_an_epub_from_rust_ir() {
4752        let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
4753        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4754        let mut mimetype = String::new();
4755        zip.by_name("mimetype")
4756            .unwrap()
4757            .read_to_string(&mut mimetype)
4758            .unwrap();
4759        assert_eq!(mimetype, "application/epub+zip");
4760        let mut opf = String::new();
4761        zip.by_name("OEBPS/package.opf")
4762            .unwrap()
4763            .read_to_string(&mut opf)
4764            .unwrap();
4765        assert!(opf.contains("<dc:title>Test</dc:title>"));
4766        assert!(opf.contains("<meta property=\"dcterms:modified\">"));
4767        assert!(opf.contains("page-progression-direction=\"rtl\""));
4768        assert!(opf.contains("chapter-2.xhtml"));
4769    }
4770
4771    #[test]
4772    fn packages_epub_xhtml_with_nonempty_navigation_and_readable_image_fallbacks() {
4773        let bytes = render_epub(
4774            "---\ntitle: Test\n---\n\nopening\n\n[[pagebreak]]\n\n![remote](https://example.com/image.png)",
4775        )
4776        .unwrap();
4777        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4778
4779        let mut navigation = String::new();
4780        zip.by_name("OEBPS/nav.xhtml")
4781            .unwrap()
4782            .read_to_string(&mut navigation)
4783            .unwrap();
4784        assert!(navigation.contains(">Chapter 1</a>"));
4785        assert!(navigation.contains(">Chapter 2</a>"));
4786        assert!(!navigation.contains("></a>"));
4787
4788        let mut opf = String::new();
4789        zip.by_name("OEBPS/package.opf")
4790            .unwrap()
4791            .read_to_string(&mut opf)
4792            .unwrap();
4793        assert!(opf.contains(
4794            "id=\"chapter-2\" href=\"chapter-2.xhtml\" media-type=\"application/xhtml+xml\"/"
4795        ));
4796        assert!(!opf.contains("remote-resources"));
4797
4798        let mut chapter = String::new();
4799        zip.by_name("OEBPS/chapter-2.xhtml")
4800            .unwrap()
4801            .read_to_string(&mut chapter)
4802            .unwrap();
4803        assert!(chapter.contains(
4804            "<span class=\"mdi-image-fallback\">Image: remote (https://example.com/image.png)</span>"
4805        ));
4806        assert!(!chapter.contains("<img"));
4807    }
4808
4809    #[test]
4810    fn packages_configured_epub_metadata_cover_chapters_and_local_footnotes() {
4811        let cover = EpubCover {
4812            data: vec![0x89, 0x50, 0x4e, 0x47],
4813            media_type: "image/png".to_owned(),
4814        };
4815        let bytes = render_epub_with_profile(
4816            "# One\n\nnote[^n]\n\n[[pagebreak]]\n\n## Two\n\nmore\n\n[^n]: text",
4817            r#"{
4818                "layout":{"system":"japanese-publisher"},
4819                "metadata":{"title":"Book","author":"Writer","publisher":"Press","identifier":"urn:test","language":"en","date":"2026-07-23"},
4820                "typesetting":{"writingMode":"vertical","fontFamily":"Noto Serif JP","fontSize":11,"lineSpacing":1.5,"textIndentEm":2,"fullwidthSpaceIndent":true},
4821                "pagination":{"gridMode":"typographic"},
4822                "epub":{"chapterSplitLevel":"h2"}
4823            }"#,
4824            Some(&cover),
4825        )
4826        .unwrap();
4827        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4828        let mut opf = String::new();
4829        zip.by_name("OEBPS/package.opf")
4830            .unwrap()
4831            .read_to_string(&mut opf)
4832            .unwrap();
4833        assert!(opf.contains("<dc:title>Book</dc:title>"));
4834        assert!(opf.contains("<dc:creator>Writer</dc:creator>"));
4835        assert!(opf.contains("<dc:publisher>Press</dc:publisher>"));
4836        assert!(opf.contains("<dc:date>2026-07-23</dc:date>"));
4837        assert!(opf.contains("cover-image"));
4838        assert!(opf.contains("page-progression-direction=\"rtl\""));
4839        let mut css = String::new();
4840        zip.by_name("OEBPS/style.css")
4841            .unwrap()
4842            .read_to_string(&mut css)
4843            .unwrap();
4844        assert!(css.contains("font-family:Noto Serif JP"));
4845        assert!(css.contains("font-size:11pt"));
4846        assert!(css.contains("line-height:1.5"));
4847        let mut chapter = String::new();
4848        zip.by_name("OEBPS/chapter-1.xhtml")
4849            .unwrap()
4850            .read_to_string(&mut chapter)
4851            .unwrap();
4852        assert!(chapter.contains("href=\"#user-content-fn-n\""));
4853        assert!(chapter.contains("id=\"user-content-fn-n\""));
4854        assert!(chapter.contains("href=\"#user-content-fnref-n\""));
4855        assert!(zip.by_name("OEBPS/chapter-2.xhtml").is_ok());
4856        assert!(zip.by_name("OEBPS/cover.png").is_ok());
4857    }
4858
4859    #[test]
4860    fn packages_a_docx_from_rust_ir() {
4861        let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
4862        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4863        let mut document = String::new();
4864        zip.by_name("word/document.xml")
4865            .unwrap()
4866            .read_to_string(&mut document)
4867            .unwrap();
4868        assert!(document.contains("題"));
4869        assert!(document.contains("東京"));
4870        let mut core = String::new();
4871        zip.by_name("docProps/core.xml")
4872            .unwrap()
4873            .read_to_string(&mut core)
4874            .unwrap();
4875        assert!(core.contains("<dc:title>Test</dc:title>"));
4876    }
4877
4878    #[test]
4879    fn packages_configured_docx_geometry_typography_content_and_book_settings() {
4880        let bytes = render_docx_with_profile(
4881            "# {第一章|だいいっしょう}\n\n本文[^n]\n\n- 一\n- 二\n\n|項目|値|\n|-|-|\n|契約|有効|\n\n[link](https://example.com) ^12^ [[em:圏点]]\n\n[^n]: 脚注",
4882            r#"{
4883                "layout":{"system":"japanese-publisher","marginMode":"mirror","bindingSide":"right","gutter":3},
4884                "metadata":{"title":"契約","author":"MDI"},
4885                "typesetting":{"writingMode":"vertical","fontFamily":"Yu Mincho","fontSize":10.5,"fullwidthSpaceIndent":true},
4886                "pagination":{"pageSize":"A4","landscape":true,"charactersPerLine":40,"linesPerPage":30,"gridMode":"strict","pageNumbers":{"enabled":true,"format":"fraction","position":"top-right"}}
4887            }"#,
4888        )
4889        .unwrap();
4890        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4891        let mut document = String::new();
4892        zip.by_name("word/document.xml")
4893            .unwrap()
4894            .read_to_string(&mut document)
4895            .unwrap();
4896        assert!(document.contains("<w:ruby>"));
4897        assert!(document.contains("<w:lid w:val=\"ja-JP\"/>"));
4898        assert!(document.contains("<w:eastAsianLayout"));
4899        assert!(document.contains("<w:em w:val=\"dot\"/>"));
4900        assert!(document.contains("<w:tbl>"));
4901        assert!(document.contains("<w:footnoteReference w:id=\"1\"/>"));
4902        assert!(document.contains("<w:textDirection w:val=\"tbRl\"/>"));
4903        assert!(document.contains("<w:docGrid w:type=\"linesAndChars\""));
4904        assert!(document.contains("<w:pgSz w:w=\"16838\" w:h=\"11906\"/>"));
4905        assert!(document.contains("<w:hyperlink r:id=\"rId1\">"));
4906
4907        let mut settings = String::new();
4908        zip.by_name("word/settings.xml")
4909            .unwrap()
4910            .read_to_string(&mut settings)
4911            .unwrap();
4912        assert!(settings.contains("<w:mirrorMargins/>"));
4913        assert!(!settings.contains("rtlGutter"));
4914
4915        let mut header = String::new();
4916        zip.by_name("word/header1.xml")
4917            .unwrap()
4918            .read_to_string(&mut header)
4919            .unwrap();
4920        assert!(header.contains("NUMPAGES"));
4921        assert!(header.contains("<w:jc w:val=\"right\"/>"));
4922        assert!(zip.by_name("word/footnotes.xml").is_ok());
4923    }
4924
4925    #[test]
4926    fn configured_docx_rejects_word_limits_and_uses_typographic_spacing() {
4927        let oversized = render_docx_with_profile(
4928            "text",
4929            r#"{"layout":{"system":"word"},"pagination":{"pageSize":"A0"}}"#,
4930        )
4931        .unwrap_err();
4932        assert!(oversized.contains("22-inch maximum"));
4933        let long_font = render_docx_with_profile(
4934            "text",
4935            r#"{"layout":{"system":"word"},"typesetting":{"fontFamily":"12345678901234567890123456789012"}}"#,
4936        )
4937        .unwrap_err();
4938        assert!(long_font.contains("at most 31 characters"));
4939
4940        let bytes = render_docx_with_profile(
4941            "text",
4942            r#"{"layout":{"system":"word"},"typesetting":{"lineSpacing":1.5},"pagination":{"gridMode":"typographic","pageNumbers":{"enabled":false}}}"#,
4943        )
4944        .unwrap();
4945        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4946        let mut document = String::new();
4947        zip.by_name("word/document.xml")
4948            .unwrap()
4949            .read_to_string(&mut document)
4950            .unwrap();
4951        assert!(!document.contains("<w:docGrid"));
4952        assert!(!document.contains("headerReference"));
4953        assert!(!document.contains("footerReference"));
4954    }
4955
4956    #[test]
4957    fn configured_docx_renders_every_supported_block_and_inline_style() {
4958        let source = r#"# Heading
4959
4960> Quote with *italic*, **bold**, and `inline code`.
4961>
4962> ```text
4963> quoted code
4964> ```
4965
4966```rust
4967let first = 1;
4968let second = 2;
4969```
4970
4971---
4972
4973First line [[br]] second line with ~~strike~~, <span>raw</span>, ![cover](cover.png), ![](empty.png),
4974[[warichu:small print]], [[kern:0.1em:spaced]], and {東京|とう.きょう}.
4975
4976[same link](https://example.com) and [same target](https://example.com)
4977"#;
4978        let bytes = render_docx_with_profile(
4979            source,
4980            r#"{
4981                "layout":{"system":"japanese-publisher"},
4982                "typesetting":{"writingMode":"horizontal","fontSize":12},
4983                "pagination":{"charactersPerLine":10,"linesPerPage":10,"gridMode":"strict","pageNumbers":{"enabled":true,"format":"simple","position":"bottom-left"}}
4984            }"#,
4985        )
4986        .unwrap();
4987        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4988        let mut document = String::new();
4989        zip.by_name("word/document.xml")
4990            .unwrap()
4991            .read_to_string(&mut document)
4992            .unwrap();
4993        for marker in [
4994            "MdiQuote",
4995            "MdiCode",
4996            "MdiThematicBreak",
4997            "<w:i/>",
4998            "<w:b/>",
4999            "<w:strike/>",
5000            "Courier New",
5001            "<w:br/>",
5002            "[Image: cover]",
5003            "[Image]",
5004            "<w:spacing w:val=\"24\"/>",
5005            "<w:ruby>",
5006            "w:charSpace=",
5007        ] {
5008            assert!(document.contains(marker), "missing DOCX marker: {marker}");
5009        }
5010        assert_eq!(document.matches("<w:hyperlink r:id=\"rId1\">").count(), 2);
5011
5012        let mut footer = String::new();
5013        zip.by_name("word/footer1.xml")
5014            .unwrap()
5015            .read_to_string(&mut footer)
5016            .unwrap();
5017        assert!(footer.contains("<w:jc w:val=\"left\"/>"));
5018        assert!(footer.contains("> PAGE <"));
5019    }
5020
5021    #[test]
5022    fn renders_pdf_with_an_available_native_chromium() {
5023        let Some(chromium_path) = find_chromium() else {
5024            return;
5025        };
5026        let pdf = render_pdf(
5027            "# 題\n\n{東京|とうきょう}",
5028            &PdfOptions {
5029                chromium_path: Some(chromium_path),
5030            },
5031        )
5032        .unwrap();
5033        assert!(pdf.starts_with(b"%PDF-"));
5034    }
5035
5036    #[test]
5037    fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
5038        for seed in 0..256 {
5039            let source = generated_source(seed);
5040            let output = parse_output(&source);
5041            assert_eq!(output.document.span.start_byte, 0);
5042            assert_eq!(output.document.span.end_byte as usize, source.len());
5043            for child in &output.document.children {
5044                assert_valid_spans(child, &source);
5045            }
5046            for diagnostic in &output.diagnostics {
5047                let span = diagnostic
5048                    .span
5049                    .expect("parser diagnostics have source spans");
5050                assert!(span.start_byte <= span.end_byte);
5051                assert!((span.end_byte as usize) <= source.len());
5052            }
5053
5054            let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
5055                .expect("every UTF-8 input has a serializable wire result");
5056            assert_eq!(wire["irVersion"], MDI_IR_VERSION);
5057            assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
5058            assert_valid_spans(&wire["document"], &source);
5059
5060            // Rendering and canonicalization are total functions over parser
5061            // output: malformed delimiters may become literal text, never a
5062            // host-visible failure.
5063            assert!(!render_html_document(&output.document).is_empty());
5064            let canonical = serialize_mdi_document(&output.document);
5065            let reparsed = parse_document(&canonical);
5066            for child in &reparsed.children {
5067                assert_valid_spans(child, &canonical);
5068            }
5069            let _ = render_text_document(&reparsed);
5070        }
5071    }
5072
5073    #[test]
5074    fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
5075        let cases = [
5076            "",
5077            "plain\n",
5078            "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
5079            "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
5080            "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
5081            "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
5082            "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
5083        ];
5084        for source in cases {
5085            let first = serialize_mdi(source);
5086            let second = serialize_mdi(&first);
5087            assert_eq!(
5088                second, first,
5089                "canonical output must stabilize for {source:?}"
5090            );
5091            assert_valid_spans(
5092                &serde_json::to_value(parse_document(&first)).unwrap(),
5093                &first,
5094            );
5095        }
5096    }
5097
5098    #[test]
5099    fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
5100        let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
5101
5102        let epub = render_epub(source).unwrap();
5103        let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
5104        assert_eq!(
5105            epub.by_name("mimetype").unwrap().compression(),
5106            CompressionMethod::Stored,
5107            "EPUB requires its mimetype member to be uncompressed"
5108        );
5109        for path in [
5110            "META-INF/container.xml",
5111            "OEBPS/package.opf",
5112            "OEBPS/nav.xhtml",
5113            "OEBPS/style.css",
5114            "OEBPS/chapter-1.xhtml",
5115            "OEBPS/chapter-2.xhtml",
5116        ] {
5117            assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
5118        }
5119        let mut opf = String::new();
5120        epub.by_name("OEBPS/package.opf")
5121            .unwrap()
5122            .read_to_string(&mut opf)
5123            .unwrap();
5124        assert!(opf.contains("A &amp; &lt; B &quot;quoted&quot;"));
5125        assert!(opf.contains("O'Brien &amp; Co."));
5126
5127        let docx = render_docx(source).unwrap();
5128        let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
5129        for path in [
5130            "[Content_Types].xml",
5131            "_rels/.rels",
5132            "docProps/core.xml",
5133            "word/document.xml",
5134        ] {
5135            assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
5136        }
5137        let mut core = String::new();
5138        docx.by_name("docProps/core.xml")
5139            .unwrap()
5140            .read_to_string(&mut core)
5141            .unwrap();
5142        assert!(core.contains("A &amp; &lt; B &quot;quoted&quot;"));
5143        let mut document = String::new();
5144        docx.by_name("word/document.xml")
5145            .unwrap()
5146            .read_to_string(&mut document)
5147            .unwrap();
5148        assert!(document.contains("&lt;unsafe&gt;"));
5149        assert!(document.contains("&amp;"));
5150    }
5151
5152    #[test]
5153    fn classifies_every_legacy_block_macro_shape() {
5154        let amount = |value| match classify_block_macro(value) {
5155            BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
5156            BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
5157        };
5158        assert_eq!(amount(" [[indent:12]] "), Some(12));
5159        assert_eq!(amount("[[bottom]]"), Some(0));
5160        assert_eq!(amount("[[bottom:3]]"), Some(3));
5161        assert_eq!(amount("literal"), None);
5162        assert!(matches!(
5163            classify_block_macro("[[pagebreak]]"),
5164            BlockMacroClass::Pagebreak(None)
5165        ));
5166        assert!(matches!(
5167            classify_block_macro("[[pagebreak:left]]"),
5168            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
5169        ));
5170        assert!(matches!(
5171            classify_block_macro("[[pagebreak:right]]"),
5172            BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
5173        ));
5174        for literal in [
5175            "text",
5176            "[[unknown:1]]",
5177            "[[indent:]]",
5178            "[[indent:0]]",
5179            "[[indent:01]]",
5180            "[[indent:x]]",
5181            "[[indent:999999999999999999999999999999]]",
5182        ] {
5183            assert!(matches!(
5184                classify_block_macro(literal),
5185                BlockMacroClass::Literal
5186            ));
5187        }
5188    }
5189
5190    #[test]
5191    fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
5192        let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
5193        assert_eq!(newer.diagnostics.len(), 1);
5194        assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
5195        assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
5196        assert_eq!(
5197            newer.diagnostics[0].span,
5198            newer.document.frontmatter.map(|f| f.span)
5199        );
5200
5201        for source in [
5202            "---\nmdi: '2.0'\n---\n",
5203            "---\nmdi: 3\n---\n",
5204            "---\n- sequence\n- values\n---\n",
5205            "---\n[malformed\n---\n",
5206        ] {
5207            let output = parse_output(source);
5208            assert!(output.diagnostics.is_empty());
5209            assert!(output.document.frontmatter.is_some());
5210        }
5211    }
5212
5213    #[test]
5214    fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
5215        let missing =
5216            std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
5217        let error = render_pdf(
5218            "text",
5219            &PdfOptions {
5220                chromium_path: Some(missing),
5221            },
5222        )
5223        .unwrap_err();
5224        assert!(error.contains("failed to start Chromium"));
5225
5226        let current_exe = std::env::current_exe().unwrap();
5227        let error = render_pdf(
5228            "text",
5229            &PdfOptions {
5230                chromium_path: Some(current_exe),
5231            },
5232        )
5233        .unwrap_err();
5234        assert!(error.contains("Chromium PDF rendering failed"));
5235    }
5236
5237    #[test]
5238    fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
5239        let mut scalar = serde_json::json!(null);
5240        lower_markdown_inside_mdi(&mut scalar, "");
5241        shift_spans(&mut scalar, 10);
5242        annotate_and_lower(&mut scalar, "", false);
5243        inject_block_markers(&mut scalar, &[]);
5244        assert_valid_spans(&scalar, "");
5245
5246        let mut paragraph_with_invalid_span = serde_json::json!({
5247            "type": "paragraph",
5248            "span": {"startByte": 2, "endByte": 3}
5249        });
5250        lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
5251
5252        let mut text_without_value = serde_json::json!({
5253            "type": "text",
5254            "position": {"start": {"offset": 0}, "end": {"offset": 0}}
5255        });
5256        annotate_and_lower(&mut text_without_value, "", false);
5257        let mut text_without_position = serde_json::json!({
5258            "type": "text",
5259            "value": "^12^"
5260        });
5261        annotate_and_lower(&mut text_without_position, "^12^", false);
5262        assert_eq!(text_without_position["children"][0]["type"], "tcy");
5263        let mut text_with_partial_span = serde_json::json!({
5264            "type": "text",
5265            "value": "^12^",
5266            "span": {}
5267        });
5268        annotate_and_lower(&mut text_with_partial_span, "^12^", false);
5269        assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
5270
5271        assert!(markdown_macro_children("not-a-macro", 0).is_none());
5272        assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
5273
5274        let span = SourceSpan {
5275            start_byte: 0,
5276            end_byte: 10,
5277        };
5278        for (is_indent, amount, expected) in [
5279            (true, 2, "[[indent:2]]"),
5280            (false, 0, "[[bottom]]"),
5281            (false, 3, "[[bottom:3]]"),
5282        ] {
5283            let mut output = Vec::new();
5284            let mut pending = Some((span, is_indent, amount));
5285            flush_pending_literal(&mut output, &mut pending);
5286            assert_eq!(output[0]["children"][0]["value"], expected);
5287        }
5288
5289        assert!(matches!(
5290            paragraph(
5291                "text",
5292                Some(PendingBlock::Bottom {
5293                    amount: 2,
5294                    source: "[[bottom:2]]".to_owned()
5295                })
5296            ),
5297            MdiBlock::Paragraph {
5298                bottom: Some(2),
5299                ..
5300            }
5301        ));
5302        assert!(pending_block("[[unknown:2]]").is_none());
5303        assert!(boten("《《》》").is_none());
5304        assert!(!valid_kern("1.em"));
5305        assert!(!valid_kern("1.2.3em"));
5306        assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
5307
5308        let mut writer = FailAfterWrites {
5309            inner: Cursor::new(Vec::new()),
5310            remaining: 1,
5311        };
5312        writer.flush().unwrap();
5313
5314        let mut formatted = Vec::new();
5315        text_format_block(
5316            &serde_json::json!({"type": "blank"}),
5317            TextFormat::Plain,
5318            "",
5319            &[],
5320            &[],
5321            &mut formatted,
5322        );
5323        text_format_block(
5324            &serde_json::json!({"type": "unknown"}),
5325            TextFormat::Plain,
5326            "",
5327            &[],
5328            &[],
5329            &mut formatted,
5330        );
5331        assert_eq!(formatted, vec![String::new()]);
5332
5333        assert_eq!(
5334            text_format_inline(
5335                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
5336                TextFormat::Ruby,
5337                &[]
5338            ),
5339            "{字|じ}"
5340        );
5341        assert_eq!(
5342            text_format_inline(
5343                &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
5344                TextFormat::Plain,
5345                &[]
5346            ),
5347            "字"
5348        );
5349        assert_eq!(
5350            text_format_inline(
5351                &serde_json::json!({"type":"image", "alt":""}),
5352                TextFormat::Plain,
5353                &[]
5354            ),
5355            "[画像]"
5356        );
5357
5358        let mut html = String::new();
5359        render_html_node(&serde_json::json!({}), &mut html);
5360        render_html_node(
5361            &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
5362            &mut html,
5363        );
5364        render_html_children(&serde_json::json!({"type":"root"}), &mut html);
5365        assert_eq!(html, "ok");
5366
5367        let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
5368        assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
5369        let heading = parse_document("[[indent:2]]\n# heading");
5370        assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
5371        let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
5372        assert_eq!(trailing.children.len(), 2);
5373
5374        let partial_document = Document {
5375            span: SourceSpan::default(),
5376            frontmatter: None,
5377            children: vec![serde_json::json!({
5378                "type": "root",
5379                "children": [{"type":"text", "value":"partial"}]
5380            })],
5381        };
5382        assert!(render_docx_document(&partial_document).is_ok());
5383    }
5384
5385    #[test]
5386    fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
5387        let empty = render_epub("").unwrap();
5388        let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
5389        assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
5390
5391        let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
5392        let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
5393        assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
5394    }
5395
5396    #[test]
5397    fn archive_writers_propagate_failures_from_every_write_stage() {
5398        let document = parse_document(
5399            "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
5400        );
5401
5402        let mut epub_errors = 0;
5403        let mut epub_success = false;
5404        for remaining in 0..256 {
5405            let writer = FailAfterWrites {
5406                inner: Cursor::new(Vec::new()),
5407                remaining,
5408            };
5409            let mut zip = ZipWriter::new(writer);
5410            let result = write_epub_document(&document, &mut zip);
5411            if result.is_err() {
5412                epub_errors += 1;
5413            } else {
5414                epub_success = true;
5415            }
5416            // A deliberately broken writer can leave `ZipWriter` midway
5417            // through a local header, where its Drop finalizer cannot safely
5418            // recover. The writer owns only this test's in-memory buffer.
5419            std::mem::forget(zip);
5420            if epub_success {
5421                break;
5422            }
5423        }
5424        assert!(epub_errors > 0);
5425        assert!(epub_success);
5426
5427        let mut docx_errors = 0;
5428        let mut docx_success = false;
5429        for remaining in 0..256 {
5430            let writer = FailAfterWrites {
5431                inner: Cursor::new(Vec::new()),
5432                remaining,
5433            };
5434            let mut zip = ZipWriter::new(writer);
5435            let result = write_docx_document(&document, &mut zip);
5436            if result.is_err() {
5437                docx_errors += 1;
5438            } else {
5439                docx_success = true;
5440            }
5441            std::mem::forget(zip);
5442            if docx_success {
5443                break;
5444            }
5445        }
5446        assert!(docx_errors > 0);
5447        assert!(docx_success);
5448    }
5449
5450    #[allow(unsafe_code)]
5451    fn ffi_bytes(result: ffi::MdiFfiResult) -> Result<Vec<u8>, String> {
5452        let value = if result.value.len == 0 {
5453            Vec::new()
5454        } else {
5455            unsafe { std::slice::from_raw_parts(result.value.data, result.value.len).to_vec() }
5456        };
5457        let error = if result.error.len == 0 {
5458            None
5459        } else {
5460            Some(unsafe {
5461                std::str::from_utf8(std::slice::from_raw_parts(
5462                    result.error.data,
5463                    result.error.len,
5464                ))
5465                .unwrap()
5466                .to_owned()
5467            })
5468        };
5469        unsafe {
5470            ffi::mdi_free_buffer(result.value);
5471            ffi::mdi_free_buffer(result.error);
5472        }
5473        error.map_or(Ok(value), Err)
5474    }
5475
5476    #[test]
5477    #[allow(unsafe_code)]
5478    fn c_abi_returns_owned_versioned_wire_data_for_every_export() {
5479        let source = "{東京|とうきょう} ^12^";
5480        let json = String::from_utf8(
5481            ffi_bytes(ffi::mdi_parse_json(source.as_ptr(), source.len())).unwrap(),
5482        )
5483        .unwrap();
5484        assert!(json.contains("\"irVersion\":\"1.0\""));
5485        assert!(json.contains("\"type\":\"ruby\""));
5486
5487        let html = String::from_utf8(
5488            ffi_bytes(ffi::mdi_render_html(source.as_ptr(), source.len())).unwrap(),
5489        )
5490        .unwrap();
5491        assert!(html.contains("<ruby class=\"mdi-ruby\">東京"));
5492        assert_eq!(
5493            String::from_utf8(
5494                ffi_bytes(ffi::mdi_serialize_mdi(source.as_ptr(), source.len())).unwrap()
5495            )
5496            .unwrap(),
5497            "{東京|とうきょう} ^12^\n"
5498        );
5499        assert_eq!(
5500            String::from_utf8(
5501                ffi_bytes(ffi::mdi_render_text(source.as_ptr(), source.len())).unwrap()
5502            )
5503            .unwrap(),
5504            "東京 12\n"
5505        );
5506        let format = b"note";
5507        let indent = " ".as_bytes();
5508        assert_eq!(
5509            String::from_utf8(
5510                ffi_bytes(ffi::mdi_render_text_format(
5511                    source.as_ptr(),
5512                    source.len(),
5513                    format.as_ptr(),
5514                    format.len(),
5515                    indent.as_ptr(),
5516                    indent.len(),
5517                ))
5518                .unwrap()
5519            )
5520            .unwrap(),
5521            " |東京《とうきょう》 12"
5522        );
5523        assert!(
5524            ffi_bytes(ffi::mdi_render_epub(source.as_ptr(), source.len()))
5525                .unwrap()
5526                .starts_with(b"PK")
5527        );
5528        assert!(
5529            ffi_bytes(ffi::mdi_render_docx(source.as_ptr(), source.len()))
5530                .unwrap()
5531                .starts_with(b"PK")
5532        );
5533
5534        assert!(
5535            ffi_bytes(ffi::mdi_render_text(std::ptr::null(), 0))
5536                .unwrap()
5537                .is_empty()
5538        );
5539        let invalid_utf8 = [0xff];
5540        assert_eq!(
5541            ffi_bytes(ffi::mdi_render_html(
5542                invalid_utf8.as_ptr(),
5543                invalid_utf8.len()
5544            ))
5545            .unwrap_err(),
5546            "MDI source must be valid UTF-8"
5547        );
5548
5549        assert_eq!(
5550            ffi_bytes(ffi::mdi_parse_json(std::ptr::null(), 1)).unwrap_err(),
5551            "MDI source pointer is null"
5552        );
5553        assert_eq!(
5554            ffi_bytes(ffi::mdi_render_epub(std::ptr::null(), 1)).unwrap_err(),
5555            "MDI source pointer is null"
5556        );
5557
5558        let invalid_format = b"invalid";
5559        assert_eq!(
5560            ffi_bytes(ffi::mdi_render_text_format(
5561                source.as_ptr(),
5562                source.len(),
5563                invalid_format.as_ptr(),
5564                invalid_format.len(),
5565                std::ptr::null(),
5566                0,
5567            ))
5568            .unwrap_err(),
5569            "Unsupported text format: invalid"
5570        );
5571        assert_eq!(
5572            ffi_bytes(ffi::mdi_render_text_format(
5573                source.as_ptr(),
5574                source.len(),
5575                std::ptr::null(),
5576                1,
5577                std::ptr::null(),
5578                0,
5579            ))
5580            .unwrap_err(),
5581            "MDI text format pointer is null"
5582        );
5583        assert_eq!(
5584            ffi_bytes(ffi::mdi_render_text_format(
5585                source.as_ptr(),
5586                source.len(),
5587                format.as_ptr(),
5588                format.len(),
5589                invalid_utf8.as_ptr(),
5590                invalid_utf8.len(),
5591            ))
5592            .unwrap_err(),
5593            "MDI text indent prefix must be valid UTF-8"
5594        );
5595    }
5596}