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