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