Skip to main content

mdi_core/
lib.rs

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