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