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