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