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};
13use unicode_segmentation::UnicodeSegmentation;
14use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
15
16pub const MDI_SPEC_VERSION: &str = "2.0";
18
19pub const MDI_IR_VERSION: &str = "1.0";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ParseOutput {
31 pub ir_version: &'static str,
32 pub syntax_version: &'static str,
33 pub capabilities: ParserCapabilities,
34 pub document: Document,
35 pub diagnostics: Vec<Diagnostic>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ParserCapabilities {
42 pub mdi: bool,
43 pub common_mark: bool,
44 pub gfm: bool,
45 pub front_matter: bool,
46 pub source_spans: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Diagnostic {
53 pub severity: DiagnosticSeverity,
54 pub code: String,
55 pub message: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub span: Option<SourceSpan>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub enum DiagnosticSeverity {
64 Warning,
65 Error,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct SourceSpan {
72 pub start_byte: u32,
73 pub end_byte: u32,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct Document {
82 pub span: SourceSpan,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub frontmatter: Option<Frontmatter>,
85 pub children: Vec<serde_json::Value>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct Frontmatter {
92 pub span: SourceSpan,
93 pub raw: String,
94 pub entries: Vec<FrontmatterEntry>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct FrontmatterEntry {
100 pub key: String,
101 pub value: serde_json::Value,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct MdiSyntaxDocument {
109 pub blocks: Vec<MdiBlock>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(tag = "type", rename_all = "camelCase")]
116pub enum MdiBlock {
117 Paragraph {
118 inlines: Vec<Inline>,
119 indent: Option<u32>,
120 bottom: Option<u32>,
121 },
122 Blank,
123 Pagebreak {
124 variant: Option<PagebreakVariant>,
125 },
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub enum PagebreakVariant {
132 Left,
133 Right,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Inline {
139 Text(String),
140 Ruby {
141 base: String,
142 ruby: RubyReading,
143 },
144 Tcy(String),
145 Break,
146 Em {
147 mark: String,
148 children: Vec<Inline>,
149 },
150 NoBreak(Vec<Inline>),
151 Warichu(Vec<Inline>),
152 Kern {
153 amount: String,
154 children: Vec<Inline>,
155 },
156}
157
158impl Serialize for Inline {
163 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164 where
165 S: Serializer,
166 {
167 match self {
168 Self::Text(value) => {
169 let mut node = serializer.serialize_struct("Inline", 2)?;
170 node.serialize_field("type", "text")?;
171 node.serialize_field("value", value)?;
172 node.end()
173 }
174 Self::Ruby { base, ruby } => {
175 let mut node = serializer.serialize_struct("Inline", 3)?;
176 node.serialize_field("type", "ruby")?;
177 node.serialize_field("base", base)?;
178 node.serialize_field("ruby", ruby)?;
179 node.end()
180 }
181 Self::Tcy(value) => {
182 let mut node = serializer.serialize_struct("Inline", 2)?;
183 node.serialize_field("type", "tcy")?;
184 node.serialize_field("value", value)?;
185 node.end()
186 }
187 Self::Break => {
188 let mut node = serializer.serialize_struct("Inline", 1)?;
189 node.serialize_field("type", "break")?;
190 node.end()
191 }
192 Self::Em { mark, children } => {
193 let mut node = serializer.serialize_struct("Inline", 3)?;
194 node.serialize_field("type", "em")?;
195 node.serialize_field("mark", mark)?;
196 node.serialize_field("children", children)?;
197 node.end()
198 }
199 Self::NoBreak(children) => {
200 let mut node = serializer.serialize_struct("Inline", 2)?;
201 node.serialize_field("type", "noBreak")?;
202 node.serialize_field("children", children)?;
203 node.end()
204 }
205 Self::Warichu(children) => {
206 let mut node = serializer.serialize_struct("Inline", 2)?;
207 node.serialize_field("type", "warichu")?;
208 node.serialize_field("children", children)?;
209 node.end()
210 }
211 Self::Kern { amount, children } => {
212 let mut node = serializer.serialize_struct("Inline", 3)?;
213 node.serialize_field("type", "kern")?;
214 node.serialize_field("amount", amount)?;
215 node.serialize_field("children", children)?;
216 node.end()
217 }
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224#[serde(tag = "type", content = "value", rename_all = "camelCase")]
225pub enum RubyReading {
226 Group(String),
227 Split(Vec<String>),
228}
229
230pub fn parse_mdi_syntax(source: &str) -> MdiSyntaxDocument {
233 let mut blocks = Vec::new();
234 let mut pending: Option<PendingBlock> = None;
235
236 for line in source.lines() {
237 if is_blank_marker(line) {
238 flush_pending(&mut blocks, &mut pending);
239 blocks.push(MdiBlock::Blank);
240 continue;
241 }
242 if let Some(pagebreak) = pagebreak(line) {
243 flush_pending(&mut blocks, &mut pending);
244 blocks.push(MdiBlock::Pagebreak { variant: pagebreak });
245 continue;
246 }
247 if let Some(marker) = pending_block(line) {
248 if pending.is_some() {
249 flush_pending(&mut blocks, &mut pending);
250 blocks.push(paragraph(line, None));
251 } else {
252 pending = Some(marker);
253 }
254 continue;
255 }
256
257 blocks.push(paragraph(line, pending.take()));
258 }
259 flush_pending(&mut blocks, &mut pending);
260 MdiSyntaxDocument { blocks }
261}
262
263pub fn parse_document(source: &str) -> Document {
267 let prepared = prepare_block_markers(source);
268 let mut constructs = markdown::Constructs::gfm();
269 constructs.frontmatter = true;
270 let options = markdown::ParseOptions {
271 constructs,
272 ..markdown::ParseOptions::default()
273 };
274 let tree = markdown::to_mdast(&prepared.markdown, &options)
275 .expect("MDI does not enable MDX, so Markdown parsing cannot fail");
276 let mut root = serde_json::to_value(tree).expect("markdown AST is serializable");
277 let frontmatter = extract_frontmatter(&root, source);
278 annotate_and_lower(&mut root, source, false);
279 lower_markdown_inside_mdi(&mut root, source);
280 inject_block_markers(&mut root, &prepared.markers);
281 let children = root
282 .get_mut("children")
283 .and_then(serde_json::Value::as_array_mut)
284 .map(|children| {
285 children
286 .drain(..)
287 .filter(|child| {
288 child.get("type").and_then(serde_json::Value::as_str) != Some("yaml")
289 })
290 .collect()
291 })
292 .unwrap_or_default();
293 Document {
294 span: SourceSpan {
295 start_byte: 0,
296 end_byte: source.len() as u32,
297 },
298 frontmatter,
299 children,
300 }
301}
302
303fn lower_markdown_inside_mdi(node: &mut serde_json::Value, source: &str) {
307 let Some(object) = node.as_object_mut() else {
308 return;
309 };
310 if object.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
311 let span = object.get("span").cloned();
312 if let Some(raw) = span
313 .as_ref()
314 .and_then(|span| source_from_span(span, source))
315 {
316 let raw = raw.trim_end_matches(['\r', '\n']);
317 if let Some(children) =
318 markdown_paragraph_children(raw, span.as_ref().expect("span exists"))
319 {
320 object.insert("children".to_owned(), serde_json::Value::Array(children));
321 return;
322 }
323 }
324 }
325 if let Some(children) = object
326 .get_mut("children")
327 .and_then(serde_json::Value::as_array_mut)
328 {
329 for child in children {
330 lower_markdown_inside_mdi(child, source);
331 }
332 }
333}
334
335fn markdown_paragraph_children(
336 raw: &str,
337 span: &serde_json::Value,
338) -> Option<Vec<serde_json::Value>> {
339 let paragraph_start = span.get("startByte")?.as_u64()? as usize;
340 let mut output = Vec::new();
341 let mut index = 0;
342 let mut plain_start = 0;
343 let mut found = false;
344 while index < raw.len() {
345 let rest = &raw[index..];
346 if rest.starts_with("[[")
347 && let Some(end) = close_macro(rest)
348 && let Some(mut macro_node) =
349 markdown_macro_children(&rest[..end + 2], paragraph_start + index)
350 {
351 output.append(&mut markdown_fragment_children(
352 &raw[plain_start..index],
353 paragraph_start + plain_start,
354 ));
355 output.append(&mut macro_node);
356 index += end + 2;
357 plain_start = index;
358 found = true;
359 continue;
360 }
361 index += rest.chars().next()?.len_utf8();
362 }
363 if !found {
364 return None;
365 }
366 output.append(&mut markdown_fragment_children(
367 &raw[plain_start..],
368 paragraph_start + plain_start,
369 ));
370 Some(output)
371}
372
373fn markdown_fragment_children(source: &str, start_byte: usize) -> Vec<serde_json::Value> {
374 if source.is_empty() {
375 return Vec::new();
376 }
377 if let Some((text, identifier)) = trailing_footnote_reference(source) {
378 let mut children = markdown_fragment_children(text, start_byte);
379 children.push(serde_json::json!({
380 "type": "footnoteReference",
381 "identifier": identifier,
382 "label": identifier,
383 "span": SourceSpan { start_byte: (start_byte + text.len()) as u32, end_byte: (start_byte + source.len()) as u32 },
384 }));
385 return children;
386 }
387 let leading = source
388 .chars()
389 .take_while(|character| character.is_whitespace())
390 .collect::<String>();
391 let trailing = source
392 .chars()
393 .rev()
394 .take_while(|character| character.is_whitespace())
395 .collect::<String>()
396 .chars()
397 .rev()
398 .collect::<String>();
399 let tree = markdown::to_mdast(source, &markdown_options())
400 .expect("MDI fragment parsing cannot fail when MDX is disabled");
401 let mut tree = serde_json::to_value(tree).expect("Markdown fragment AST is serializable");
402 annotate_and_lower(&mut tree, source, false);
403 shift_spans(&mut tree, start_byte);
404 let mut children = tree
405 .get_mut("children")
406 .and_then(serde_json::Value::as_array_mut)
407 .and_then(|children| children.first_mut())
408 .and_then(|paragraph| paragraph.get("children"))
409 .and_then(serde_json::Value::as_array)
410 .cloned()
411 .unwrap_or_default();
412 if !leading.is_empty()
413 && !children
414 .first()
415 .and_then(|child| child.get("value"))
416 .and_then(serde_json::Value::as_str)
417 .is_some_and(|value| value.starts_with(&leading))
418 {
419 children.insert(
420 0,
421 serde_json::json!({ "type": "text", "value": leading, "span": SourceSpan { start_byte: start_byte as u32, end_byte: (start_byte + leading.len()) as u32 } }),
422 );
423 }
424 if !trailing.is_empty()
425 && !children
426 .last()
427 .and_then(|child| child.get("value"))
428 .and_then(serde_json::Value::as_str)
429 .is_some_and(|value| value.ends_with(&trailing))
430 {
431 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 } }));
432 }
433 children
434}
435
436fn trailing_footnote_reference(source: &str) -> Option<(&str, &str)> {
437 let (text, suffix) = source.rsplit_once("[^")?;
438 let identifier = suffix.strip_suffix(']')?;
439 (!identifier.is_empty() && !identifier.chars().any(char::is_whitespace))
440 .then_some((text, identifier))
441}
442
443fn markdown_options() -> markdown::ParseOptions {
444 let mut constructs = markdown::Constructs::gfm();
445 constructs.frontmatter = false;
446 markdown::ParseOptions {
447 constructs,
448 ..markdown::ParseOptions::default()
449 }
450}
451
452fn source_from_span<'a>(span: &serde_json::Value, source: &'a str) -> Option<&'a str> {
453 source.get(span.get("startByte")?.as_u64()? as usize..span.get("endByte")?.as_u64()? as usize)
454}
455
456fn decoded_byte_offsets(decoded: &str, raw: &str) -> Option<Vec<(usize, usize)>> {
460 let mut offsets = vec![(0, 0)];
461 let mut raw_index = 0;
462 for (decoded_index, character) in decoded.char_indices() {
463 let expected_end = decoded_index + character.len_utf8();
464 if raw[raw_index..].starts_with('\\') {
465 let after_slash = raw_index + '\\'.len_utf8();
466 if raw[after_slash..].starts_with(character) {
467 raw_index = after_slash;
468 }
469 }
470 if !raw[raw_index..].starts_with(character) {
471 return None;
472 }
473 raw_index += character.len_utf8();
474 offsets.push((expected_end, raw_index));
475 }
476 (raw_index == raw.len()).then_some(offsets)
477}
478
479fn source_offset(offsets: &[(usize, usize)], decoded_offset: usize) -> Option<usize> {
480 offsets
481 .binary_search_by_key(&decoded_offset, |(decoded, _)| *decoded)
482 .ok()
483 .map(|index| offsets[index].1)
484}
485
486fn shift_spans(node: &mut serde_json::Value, amount: usize) {
489 let Some(object) = node.as_object_mut() else {
490 return;
491 };
492 if let Some(span) = object
493 .get_mut("span")
494 .and_then(serde_json::Value::as_object_mut)
495 {
496 for key in ["startByte", "endByte"] {
497 if let Some(offset) = span.get(key).and_then(serde_json::Value::as_u64) {
498 span.insert(key.to_owned(), serde_json::json!(offset + amount as u64));
499 }
500 }
501 }
502 if let Some(children) = object
503 .get_mut("children")
504 .and_then(serde_json::Value::as_array_mut)
505 {
506 for child in children {
507 shift_spans(child, amount);
508 }
509 }
510}
511
512fn markdown_macro_children(raw: &str, start_byte: usize) -> Option<Vec<serde_json::Value>> {
513 if !raw.starts_with("[[") {
514 return None;
515 }
516 let end = close_macro(raw)?;
517 if end + 2 != raw.len() {
518 return None;
519 }
520 let body = &raw[2..end];
521 let (name, payload) = body.split_once(':')?;
522 let (type_name, extra, content, content_offset) = match name {
523 "no-break" if !payload.is_empty() => (
524 "noBreak",
525 serde_json::Map::new(),
526 payload,
527 2 + name.len() + 1,
528 ),
529 "warichu" => (
530 "warichu",
531 serde_json::Map::new(),
532 payload,
533 2 + name.len() + 1,
534 ),
535 "kern" => {
536 let (amount, content) = payload.split_once(':')?;
537 if !valid_kern(amount) {
538 return None;
539 }
540 let mut extra = serde_json::Map::new();
541 extra.insert("amount".to_owned(), serde_json::json!(unescape_mdi(amount)));
542 (
543 "kern",
544 extra,
545 content,
546 2 + name.len() + 1 + amount.len() + 1,
547 )
548 }
549 "em" => {
550 let (mark, content) = bare_index(payload, ':')
551 .and_then(|index| {
552 let mark = unescape_mdi(&payload[..index]);
553 (mark.graphemes(true).count() == 1
554 && !mark.chars().any(|c| c.is_whitespace() || c.is_control()))
555 .then_some((mark, &payload[index + 1..]))
556 })
557 .unwrap_or_else(|| ("﹅".to_owned(), payload));
558 let mut extra = serde_json::Map::new();
559 extra.insert("mark".to_owned(), serde_json::json!(mark));
560 let content_offset = raw.len() - 2 - content.len();
561 ("em", extra, content, content_offset)
562 }
563 _ => return None,
564 };
565 let mut constructs = markdown::Constructs::gfm();
566 let options = markdown::ParseOptions {
567 constructs: {
568 constructs.frontmatter = false;
569 constructs
570 },
571 ..markdown::ParseOptions::default()
572 };
573 let tree = markdown::to_mdast(content, &options).ok()?;
574 let mut value = serde_json::to_value(tree).ok()?;
575 annotate_and_lower(&mut value, content, false);
576 shift_spans(&mut value, start_byte + content_offset);
577 let children = value
578 .get_mut("children")?
579 .as_array_mut()?
580 .first_mut()?
581 .get_mut("children")?
582 .as_array()?
583 .clone();
584 let mut node = extra;
585 node.insert("type".to_owned(), serde_json::json!(type_name));
586 node.insert("children".to_owned(), serde_json::Value::Array(children));
587 node.insert(
588 "span".to_owned(),
589 serde_json::json!(SourceSpan {
590 start_byte: start_byte as u32,
591 end_byte: (start_byte + raw.len()) as u32,
592 }),
593 );
594 Some(vec![serde_json::Value::Object(node)])
595}
596
597#[derive(Clone)]
598enum PreparedBlockMarker {
599 Blank(SourceSpan),
600 Pagebreak(SourceSpan, Option<PagebreakVariant>),
601 Indent(SourceSpan, bool, u32),
602}
603
604struct PreparedSource {
605 markdown: String,
606 markers: Vec<PreparedBlockMarker>,
607}
608
609fn prepare_block_markers(source: &str) -> PreparedSource {
614 let mut markdown = String::with_capacity(source.len());
615 let mut markers = Vec::new();
616 let mut offset = 0;
617 let mut fenced = false;
618 for line in source.split_inclusive('\n') {
619 let without_lf = line.strip_suffix('\n').unwrap_or(line);
620 let content = without_lf.strip_suffix('\r').unwrap_or(without_lf);
621 let trimmed_start = content.trim_start();
622 let is_fence = trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~");
623 if is_fence {
624 fenced = !fenced;
625 }
626 let span = SourceSpan {
627 start_byte: offset as u32,
628 end_byte: (offset + content.len()) as u32,
629 };
630 let marker = if !fenced && !trimmed_start.starts_with('>') && content == trimmed_start {
631 if is_blank_marker(content) {
632 Some(PreparedBlockMarker::Blank(span))
633 } else if let Some(variant) = pagebreak(content) {
634 Some(PreparedBlockMarker::Pagebreak(span, variant))
635 } else {
636 pending_block(content).map(|marker| match marker {
637 PendingBlock::Indent { amount, .. } => {
638 PreparedBlockMarker::Indent(span, true, amount)
639 }
640 PendingBlock::Bottom { amount, .. } => {
641 PreparedBlockMarker::Indent(span, false, amount)
642 }
643 })
644 }
645 } else {
646 None
647 };
648 if let Some(marker) = marker {
649 markers.push(marker);
650 markdown.push_str(&" ".repeat(content.len()));
651 markdown.push_str(&line[content.len()..]);
652 } else {
653 markdown.push_str(line);
654 }
655 offset += line.len();
656 }
657 PreparedSource { markdown, markers }
658}
659
660fn inject_block_markers(root: &mut serde_json::Value, markers: &[PreparedBlockMarker]) {
661 let Some(children) = root
662 .get_mut("children")
663 .and_then(serde_json::Value::as_array_mut)
664 else {
665 return;
666 };
667 let mut output = Vec::with_capacity(children.len() + markers.len());
668 let mut marker_index = 0;
669 let mut pending: Option<(SourceSpan, bool, u32)> = None;
670 for mut child in children.drain(..) {
671 let start = child
672 .pointer("/span/startByte")
673 .and_then(serde_json::Value::as_u64)
674 .unwrap_or(u64::MAX) as u32;
675 while let Some(marker) = markers.get(marker_index) {
676 if marker_span(marker).end_byte > start {
677 break;
678 }
679 marker_index += 1;
680 match marker.clone() {
681 PreparedBlockMarker::Blank(span) => {
682 flush_pending_literal(&mut output, &mut pending);
683 output.push(serde_json::json!({ "type": "blank", "span": span }));
684 }
685 PreparedBlockMarker::Pagebreak(span, variant) => {
686 flush_pending_literal(&mut output, &mut pending);
687 output.push(serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }));
688 }
689 PreparedBlockMarker::Indent(span, is_indent, amount) => {
690 if pending.is_some() {
691 flush_pending_literal(&mut output, &mut pending);
692 }
693 pending = Some((span, is_indent, amount));
694 }
695 }
696 }
697 if let Some((_span, is_indent, amount)) = pending.take() {
698 if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
699 child.as_object_mut().expect("node is object").insert(
700 if is_indent { "indent" } else { "bottom" }.to_owned(),
701 serde_json::json!(amount),
702 );
703 } else {
704 flush_pending_literal(&mut output, &mut Some((_span, is_indent, amount)));
705 }
706 }
707 output.push(child);
708 }
709 while let Some(marker) = markers.get(marker_index) {
710 marker_index += 1;
711 match marker.clone() {
712 PreparedBlockMarker::Blank(span) => {
713 flush_pending_literal(&mut output, &mut pending);
714 output.push(serde_json::json!({ "type": "blank", "span": span }));
715 }
716 PreparedBlockMarker::Pagebreak(span, variant) => {
717 flush_pending_literal(&mut output, &mut pending);
718 output.push(
719 serde_json::json!({ "type": "pagebreak", "variant": variant, "span": span }),
720 );
721 }
722 PreparedBlockMarker::Indent(span, is_indent, amount) => {
723 if pending.is_some() {
724 flush_pending_literal(&mut output, &mut pending);
725 }
726 pending = Some((span, is_indent, amount));
727 }
728 }
729 }
730 flush_pending_literal(&mut output, &mut pending);
731 *children = output;
732}
733
734fn marker_span(marker: &PreparedBlockMarker) -> SourceSpan {
735 match marker {
736 PreparedBlockMarker::Blank(span)
737 | PreparedBlockMarker::Pagebreak(span, _)
738 | PreparedBlockMarker::Indent(span, _, _) => *span,
739 }
740}
741
742fn flush_pending_literal(
743 output: &mut Vec<serde_json::Value>,
744 pending: &mut Option<(SourceSpan, bool, u32)>,
745) {
746 let Some((span, is_indent, amount)) = pending.take() else {
747 return;
748 };
749 let value = if is_indent {
750 format!("[[indent:{amount}]]")
751 } else if amount == 0 {
752 "[[bottom]]".to_owned()
753 } else {
754 format!("[[bottom:{amount}]]")
755 };
756 output.push(serde_json::json!({ "type": "paragraph", "children": [{ "type": "text", "value": value, "span": span }], "span": span }));
757}
758
759fn annotate_and_lower(node: &mut serde_json::Value, source: &str, protected: bool) {
760 let Some(object) = node.as_object_mut() else {
761 return;
762 };
763 let node_type = object
764 .get("type")
765 .and_then(serde_json::Value::as_str)
766 .unwrap_or("")
767 .to_owned();
768 let protected = protected || matches!(node_type.as_str(), "code" | "inlineCode" | "html");
773 let span = object
774 .remove("position")
775 .as_ref()
776 .and_then(|position| span_from_position(position, source));
777 if let Some(span) = span {
778 object.insert("span".to_owned(), serde_json::json!(span));
779 }
780 if let Some(children) = object
781 .get_mut("children")
782 .and_then(serde_json::Value::as_array_mut)
783 {
784 for child in children.iter_mut() {
785 annotate_and_lower(child, source, protected);
786 }
787 let mut flattened = Vec::with_capacity(children.len());
788 for child in children.drain(..) {
789 if child.get("type").and_then(serde_json::Value::as_str) == Some("mdiFragment")
790 && let Some(mut fragment) = child
791 .get("children")
792 .and_then(serde_json::Value::as_array)
793 .cloned()
794 {
795 flattened.append(&mut fragment);
796 continue;
797 }
798 flattened.push(child);
799 }
800 *children = flattened;
801 }
802 if protected || node_type != "text" {
803 return;
804 }
805 let Some(rendered_value) = object.get("value").and_then(serde_json::Value::as_str) else {
806 return;
807 };
808 let source_offsets = span
813 .as_ref()
814 .and_then(|span| source.get(span.start_byte as usize..span.end_byte as usize))
815 .and_then(|raw| decoded_byte_offsets(rendered_value, raw));
816 if !looks_like_mdi(rendered_value) {
817 return;
818 }
819 let span = object.get("span").cloned();
820 let parsed = parse_inline_parts(rendered_value);
821 if let Some((Inline::Text(value), _, _)) = parsed.first()
822 && parsed.len() == 1
823 && value == rendered_value
824 {
825 return;
826 }
827 let replacement: Vec<serde_json::Value> = parsed
828 .into_iter()
829 .map(|(inline, start, end)| {
830 let mut value = serde_json::to_value(inline).expect("MDI inline is serializable");
831 if let (Some(token_span), Some(object)) = (&span, value.as_object_mut()) {
832 let start_byte = token_span
833 .get("startByte")
834 .and_then(serde_json::Value::as_u64);
835 if let Some(start_byte) = start_byte {
836 let start = source_offsets
837 .as_ref()
838 .and_then(|offsets| source_offset(offsets, start))
839 .unwrap_or(start);
840 let end = source_offsets
841 .as_ref()
842 .and_then(|offsets| source_offset(offsets, end))
843 .unwrap_or(end);
844 object.insert(
845 "span".to_owned(),
846 serde_json::json!(SourceSpan {
847 start_byte: (start_byte as usize + start) as u32,
848 end_byte: (start_byte as usize + end) as u32,
849 }),
850 );
851 }
852 }
853 value
854 })
855 .collect();
856 *node = serde_json::json!({ "type": "mdiFragment", "children": replacement, "span": span });
857}
858
859fn looks_like_mdi(value: &str) -> bool {
860 value.contains(['{', '^', '《', '[', '\\'])
861}
862
863fn span_from_position(value: &serde_json::Value, source: &str) -> Option<SourceSpan> {
864 let start = value.pointer("/start/offset")?.as_u64()? as usize;
865 let end = value.pointer("/end/offset")?.as_u64()? as usize;
866 Some(SourceSpan {
867 start_byte: character_offset_to_byte(source, start) as u32,
868 end_byte: character_offset_to_byte(source, end) as u32,
869 })
870}
871
872fn character_offset_to_byte(source: &str, offset: usize) -> usize {
873 offset.min(source.len())
875}
876
877fn extract_frontmatter(root: &serde_json::Value, source: &str) -> Option<Frontmatter> {
878 let yaml = root.get("children")?.as_array()?.first()?;
879 if yaml.get("type")?.as_str()? != "yaml" {
880 return None;
881 }
882 let raw = yaml.get("value")?.as_str()?.to_owned();
883 let span = yaml
884 .get("position")
885 .and_then(|value| span_from_position(value, source))?;
886 let entries = match serde_yaml::from_str::<serde_yaml::Value>(&raw) {
887 Ok(serde_yaml::Value::Mapping(mapping)) => mapping
888 .into_iter()
889 .filter_map(|(key, value)| {
890 let key = key.as_str()?.to_owned();
891 let value = serde_json::to_value(value).ok()?;
892 Some(FrontmatterEntry { key, value })
893 })
894 .collect(),
895 _ => Vec::new(),
896 };
897 Some(Frontmatter { span, raw, entries })
898}
899
900fn diagnostics(document: &Document) -> Vec<Diagnostic> {
901 let Some(frontmatter) = document.frontmatter.as_ref() else {
902 return Vec::new();
903 };
904 let declared = frontmatter.entries.iter().find(|entry| entry.key == "mdi");
905 let Some(declared) = declared.and_then(|entry| entry.value.as_str()) else {
906 return Vec::new();
907 };
908 if declared > MDI_SPEC_VERSION {
909 vec![Diagnostic {
910 severity: DiagnosticSeverity::Warning,
911 code: "mdi.version.unsupported".to_owned(),
912 message: format!("MDI {declared} is newer than the supported {MDI_SPEC_VERSION}"),
913 span: Some(frontmatter.span),
914 }]
915 } else {
916 Vec::new()
917 }
918}
919
920pub fn parse_output(source: &str) -> ParseOutput {
923 let document = parse_document(source);
924 ParseOutput {
925 ir_version: MDI_IR_VERSION,
926 syntax_version: MDI_SPEC_VERSION,
927 capabilities: ParserCapabilities {
928 mdi: true,
929 common_mark: true,
930 gfm: true,
931 front_matter: true,
932 source_spans: true,
933 },
934 diagnostics: diagnostics(&document),
935 document,
936 }
937}
938
939pub fn parse_json(source: &str) -> String {
945 serde_json::to_string(&parse_output(source))
946 .expect("serializing the MDI parse output cannot fail")
947}
948
949#[allow(unsafe_code)]
956pub mod ffi {
957 use super::{parse_json, render_docx, render_epub, render_html, render_text, serialize_mdi};
958 use std::slice;
959
960 #[repr(C)]
961 #[derive(Debug, Clone, Copy)]
962 pub struct MdiFfiBuffer {
963 pub data: *mut u8,
964 pub len: usize,
965 }
966
967 #[repr(C)]
968 #[derive(Debug, Clone, Copy)]
969 pub struct MdiFfiResult {
970 pub value: MdiFfiBuffer,
971 pub error: MdiFfiBuffer,
972 }
973
974 fn empty_buffer() -> MdiFfiBuffer {
975 MdiFfiBuffer {
976 data: std::ptr::null_mut(),
977 len: 0,
978 }
979 }
980
981 fn buffer(value: Vec<u8>) -> MdiFfiBuffer {
982 if value.is_empty() {
983 return empty_buffer();
984 }
985 let mut value = value.into_boxed_slice();
986 let result = MdiFfiBuffer {
987 data: value.as_mut_ptr(),
988 len: value.len(),
989 };
990 std::mem::forget(value);
991 result
992 }
993
994 fn success(value: Vec<u8>) -> MdiFfiResult {
995 MdiFfiResult {
996 value: buffer(value),
997 error: empty_buffer(),
998 }
999 }
1000
1001 fn failure(message: impl Into<String>) -> MdiFfiResult {
1002 MdiFfiResult {
1003 value: empty_buffer(),
1004 error: buffer(message.into().into_bytes()),
1005 }
1006 }
1007
1008 fn source<'a>(data: *const u8, len: usize) -> Result<&'a str, String> {
1009 if data.is_null() && len != 0 {
1010 return Err("MDI source pointer is null".to_owned());
1011 }
1012 let bytes = if len == 0 {
1013 &[]
1014 } else {
1015 unsafe { slice::from_raw_parts(data, len) }
1016 };
1017 std::str::from_utf8(bytes).map_err(|_| "MDI source must be valid UTF-8".to_owned())
1018 }
1019
1020 fn string_result(
1021 data: *const u8,
1022 len: usize,
1023 operation: impl FnOnce(&str) -> String,
1024 ) -> MdiFfiResult {
1025 match source(data, len) {
1026 Ok(source) => success(operation(source).into_bytes()),
1027 Err(error) => failure(error),
1028 }
1029 }
1030
1031 #[unsafe(no_mangle)]
1032 pub extern "C" fn mdi_parse_json(data: *const u8, len: usize) -> MdiFfiResult {
1033 string_result(data, len, parse_json)
1034 }
1035 #[unsafe(no_mangle)]
1036 pub extern "C" fn mdi_render_html(data: *const u8, len: usize) -> MdiFfiResult {
1037 string_result(data, len, render_html)
1038 }
1039 #[unsafe(no_mangle)]
1040 pub extern "C" fn mdi_serialize_mdi(data: *const u8, len: usize) -> MdiFfiResult {
1041 string_result(data, len, serialize_mdi)
1042 }
1043 #[unsafe(no_mangle)]
1044 pub extern "C" fn mdi_render_text(data: *const u8, len: usize) -> MdiFfiResult {
1045 string_result(data, len, render_text)
1046 }
1047
1048 fn binary_result(
1049 data: *const u8,
1050 len: usize,
1051 operation: impl FnOnce(&str) -> Result<Vec<u8>, String>,
1052 ) -> MdiFfiResult {
1053 match source(data, len).and_then(operation) {
1054 Ok(value) => success(value),
1055 Err(error) => failure(error),
1056 }
1057 }
1058
1059 #[unsafe(no_mangle)]
1060 pub extern "C" fn mdi_render_epub(data: *const u8, len: usize) -> MdiFfiResult {
1061 binary_result(data, len, render_epub)
1062 }
1063 #[unsafe(no_mangle)]
1064 pub extern "C" fn mdi_render_docx(data: *const u8, len: usize) -> MdiFfiResult {
1065 binary_result(data, len, render_docx)
1066 }
1067
1068 #[unsafe(no_mangle)]
1075 pub unsafe extern "C" fn mdi_free_buffer(buffer: MdiFfiBuffer) {
1076 if !buffer.data.is_null() && buffer.len != 0 {
1077 unsafe {
1078 drop(Vec::from_raw_parts(buffer.data, buffer.len, buffer.len));
1079 }
1080 }
1081 }
1082}
1083
1084pub fn render_html(source: &str) -> String {
1089 render_html_document(&parse_document(source))
1090}
1091
1092pub fn render_html_document(document: &Document) -> String {
1094 let frontmatter = document.frontmatter.as_ref();
1095 let field = |key: &str| {
1096 frontmatter
1097 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1098 .and_then(|entry| entry.value.as_str())
1099 };
1100 let lang = field("lang").unwrap_or("ja");
1101 let title = field("title")
1102 .map(|title| format!("<title>{}</title>", escape_html(title)))
1103 .unwrap_or_default();
1104 let vertical = matches!(field("writing-mode"), Some("vertical"));
1105 let writing_mode = if vertical {
1106 " style=\"writing-mode: vertical-rl;\""
1107 } else {
1108 ""
1109 };
1110 let mut body = String::new();
1111 let mut footnotes = Vec::new();
1112 for child in &document.children {
1113 if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
1114 footnotes.push(child);
1115 } else {
1116 render_html_node(child, &mut body);
1117 }
1118 }
1119 if !footnotes.is_empty() {
1120 body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\">Footnotes</h2><ol>");
1121 for (index, footnote) in footnotes.into_iter().enumerate() {
1122 body.push_str("<li id=\"user-content-fn-");
1123 body.push_str(&escape_html(
1124 footnote
1125 .get("identifier")
1126 .and_then(serde_json::Value::as_str)
1127 .unwrap_or(&format!("{}", index + 1)),
1128 ));
1129 body.push_str("\">");
1130 render_html_children(footnote, &mut body);
1131 body.push_str("</li>");
1132 }
1133 body.push_str("</ol></section>");
1134 }
1135 format!(
1136 "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
1137 escape_html(lang),
1138 writing_mode,
1139 title,
1140 MDI_STYLESHEET,
1141 body
1142 )
1143}
1144
1145pub fn serialize_mdi(source: &str) -> String {
1147 serialize_mdi_document(&parse_document(source))
1148}
1149
1150pub fn serialize_mdi_document(document: &Document) -> String {
1152 let mut output = String::new();
1153 if let Some(frontmatter) = &document.frontmatter {
1154 output.push_str("---\n");
1155 output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1156 output.push_str("\n---\n\n");
1157 }
1158 for (index, node) in document.children.iter().enumerate() {
1159 if index > 0 && !output.ends_with("\n\n") {
1160 output.push('\n');
1161 }
1162 serialize_block(node, &mut output, "");
1163 }
1164 output
1165}
1166
1167pub fn render_text(source: &str) -> String {
1171 render_text_document(&parse_document(source))
1172}
1173
1174pub fn render_text_document(document: &Document) -> String {
1176 let mut output = String::new();
1177 for node in &document.children {
1178 render_text_node(node, &mut output);
1179 if !output.ends_with('\n') {
1180 output.push('\n');
1181 }
1182 }
1183 output
1184}
1185
1186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1188pub enum TextFormat {
1189 Plain,
1190 Ruby,
1191 Narou,
1192 Kakuyomu,
1193 Aozora,
1194}
1195
1196impl TextFormat {
1197 pub fn parse(value: &str) -> Option<Self> {
1199 match value {
1200 "txt" => Some(Self::Plain),
1201 "txt-ruby" => Some(Self::Ruby),
1202 "narou" => Some(Self::Narou),
1203 "kakuyomu" => Some(Self::Kakuyomu),
1204 "aozora" => Some(Self::Aozora),
1205 _ => None,
1206 }
1207 }
1208}
1209
1210pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1213 let document = parse_document(source);
1214 let mut heading_depths = document
1215 .children
1216 .iter()
1217 .filter(|node| node.get("type").and_then(serde_json::Value::as_str) == Some("heading"))
1218 .filter_map(|node| node.get("depth").and_then(serde_json::Value::as_u64))
1219 .collect::<Vec<_>>();
1220 heading_depths.sort_unstable();
1221 heading_depths.dedup();
1222 let definitions: Vec<&serde_json::Value> = document
1223 .children
1224 .iter()
1225 .filter(|node| {
1226 node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1227 })
1228 .collect();
1229 let mut blocks = Vec::new();
1230 for node in &document.children {
1231 text_format_block(
1232 node,
1233 format,
1234 indent_prefix,
1235 &definitions,
1236 &heading_depths,
1237 &mut blocks,
1238 );
1239 }
1240 if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1241 blocks.push(String::new());
1242 blocks.push("Footnotes".to_owned());
1243 for (index, definition) in definitions.iter().enumerate() {
1244 let text = children(definition)
1245 .iter()
1246 .filter(|child| {
1247 child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1248 })
1249 .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1250 .collect::<Vec<_>>()
1251 .join(" ");
1252 blocks.push(format!("{}. {text}", index + 1));
1253 }
1254 }
1255 if matches!(format, TextFormat::Aozora) && heading_depths.len() > 3 {
1256 blocks.push(String::new());
1257 blocks.push("※小見出しよりもさらに下位の見出しには、注記しませんでした。".to_owned());
1258 }
1259 let output = blocks.join("\n");
1260 if matches!(format, TextFormat::Aozora) {
1261 output.replace('\n', "\r\n")
1262 } else {
1263 output
1264 }
1265}
1266
1267fn text_format_block(
1268 node: &serde_json::Value,
1269 format: TextFormat,
1270 prefix: &str,
1271 definitions: &[&serde_json::Value],
1272 heading_depths: &[u64],
1273 output: &mut Vec<String>,
1274) {
1275 let kind = node
1276 .get("type")
1277 .and_then(serde_json::Value::as_str)
1278 .unwrap_or_default();
1279 match kind {
1280 "footnoteDefinition" | "definition" => {}
1281 "paragraph" => {
1282 let value = text_format_inline_children(node, format, definitions);
1283 let block_prefix = text_format_block_prefix(node, format);
1284 output.push(format!("{prefix}{block_prefix}{value}"));
1285 }
1286 "heading" => {
1287 let value = text_format_inline_children(node, format, definitions);
1288 if matches!(format, TextFormat::Aozora) {
1289 let depth = node
1290 .get("depth")
1291 .and_then(serde_json::Value::as_u64)
1292 .unwrap_or(3);
1293 if let Some(size) = aozora_heading_size(depth, heading_depths) {
1294 let reference = text_format_plain_inline_children(node);
1295 if aozora_needs_range_annotation(node) {
1296 output.push(format!("[#{size}見出し]{value}[#{size}見出し終わり]"));
1297 } else {
1298 output.push(format!("{value}[#「{reference}」は{size}見出し]"));
1299 }
1300 } else {
1301 output.push(value);
1302 }
1303 } else {
1304 output.push(value);
1305 }
1306 }
1307 "list" => {
1308 for (index, item) in children(node).iter().enumerate() {
1309 for child in children(item) {
1310 if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1311 let bullet = if node
1312 .get("ordered")
1313 .and_then(serde_json::Value::as_bool)
1314 .unwrap_or(false)
1315 {
1316 format!("{}. ", index + 1)
1317 } else {
1318 "- ".to_owned()
1319 };
1320 output.push(format!(
1321 "{prefix}{bullet}{}",
1322 text_format_inline_children(child, format, definitions)
1323 ));
1324 } else {
1325 text_format_block(
1326 child,
1327 format,
1328 prefix,
1329 definitions,
1330 heading_depths,
1331 output,
1332 );
1333 }
1334 }
1335 }
1336 }
1337 "blockquote" => {
1338 for child in children(node) {
1339 text_format_block(child, format, prefix, definitions, heading_depths, output);
1340 }
1341 }
1342 "code" => output.extend(
1343 node.get("value")
1344 .and_then(serde_json::Value::as_str)
1345 .unwrap_or_default()
1346 .lines()
1347 .map(|line| text_format_literal(line, format)),
1348 ),
1349 "table" => {
1350 for row in children(node) {
1351 output.push(
1352 children(row)
1353 .iter()
1354 .map(|cell| text_format_inline_children(cell, format, definitions))
1355 .collect::<Vec<_>>()
1356 .join("\t"),
1357 );
1358 }
1359 }
1360 "thematicBreak" => output.push("――――――".to_owned()),
1361 "blank" => output.push(String::new()),
1362 "pagebreak" => {
1363 if matches!(format, TextFormat::Aozora) {
1364 let annotation = match node.get("variant").and_then(serde_json::Value::as_str) {
1365 Some("left") => "[#改丁]",
1366 Some("right") => "[#改見開き]",
1367 _ => "[#改ページ]",
1368 };
1369 output.push(annotation.to_owned());
1370 } else {
1371 output.push(String::new());
1372 }
1373 }
1374 _ => {}
1375 }
1376}
1377
1378fn aozora_heading_size(depth: u64, heading_depths: &[u64]) -> Option<&'static str> {
1379 let index = heading_depths
1380 .iter()
1381 .position(|candidate| *candidate == depth)?;
1382 match heading_depths.len() {
1383 1 => Some("中"),
1384 2 => ["大", "中"].get(index).copied(),
1385 _ => ["大", "中", "小"].get(index).copied(),
1386 }
1387}
1388
1389fn text_format_block_prefix(node: &serde_json::Value, format: TextFormat) -> String {
1390 let indent = node
1391 .get("indent")
1392 .and_then(serde_json::Value::as_u64)
1393 .filter(|amount| *amount > 0);
1394 let bottom = node.get("bottom").and_then(serde_json::Value::as_u64);
1395 if matches!(format, TextFormat::Aozora) {
1396 if let Some(amount) = bottom {
1397 return if amount == 0 {
1398 "[#地付き]".to_owned()
1399 } else {
1400 format!("[#地から{}字上げ]", fullwidth_digits(amount))
1401 };
1402 }
1403 return indent
1404 .map(|amount| format!("[#{}字下げ]", fullwidth_digits(amount)))
1405 .unwrap_or_default();
1406 }
1407 indent
1408 .map(|amount| " ".repeat(amount as usize))
1409 .unwrap_or_default()
1410}
1411
1412fn fullwidth_digits(value: u64) -> String {
1413 value
1414 .to_string()
1415 .replace('0', "0")
1416 .replace('1', "1")
1417 .replace('2', "2")
1418 .replace('3', "3")
1419 .replace('4', "4")
1420 .replace('5', "5")
1421 .replace('6', "6")
1422 .replace('7', "7")
1423 .replace('8', "8")
1424 .replace('9', "9")
1425}
1426
1427fn text_format_inline_children(
1428 node: &serde_json::Value,
1429 format: TextFormat,
1430 definitions: &[&serde_json::Value],
1431) -> String {
1432 children(node)
1433 .iter()
1434 .map(|node| text_format_inline(node, format, definitions))
1435 .collect()
1436}
1437fn text_format_inline(
1438 node: &serde_json::Value,
1439 format: TextFormat,
1440 definitions: &[&serde_json::Value],
1441) -> String {
1442 match node
1443 .get("type")
1444 .and_then(serde_json::Value::as_str)
1445 .unwrap_or_default()
1446 {
1447 "text" | "inlineCode" => text_format_literal(
1448 node.get("value")
1449 .and_then(serde_json::Value::as_str)
1450 .unwrap_or_default(),
1451 format,
1452 ),
1453 "tcy" => {
1454 let value = text_format_literal(
1455 node.get("value")
1456 .and_then(serde_json::Value::as_str)
1457 .unwrap_or_default(),
1458 format,
1459 );
1460 if matches!(format, TextFormat::Aozora) {
1461 format!("{value}[#「{value}」は縦中横]")
1462 } else {
1463 value
1464 }
1465 }
1466 "break" => "\n".to_owned(),
1467 "ruby" => {
1468 let base = node
1469 .get("base")
1470 .and_then(serde_json::Value::as_str)
1471 .unwrap_or_default();
1472 let reading = node
1473 .pointer("/ruby/value")
1474 .map(|value| match value {
1475 serde_json::Value::Array(parts) => parts
1476 .iter()
1477 .filter_map(serde_json::Value::as_str)
1478 .collect::<Vec<_>>()
1479 .join(if matches!(format, TextFormat::Ruby) {
1480 "."
1481 } else {
1482 ""
1483 }),
1484 serde_json::Value::String(value) => value.to_owned(),
1485 _ => String::new(),
1486 })
1487 .unwrap_or_default();
1488 match format {
1489 TextFormat::Plain => base.to_owned(),
1490 TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1491 _ => text_format_platform_ruby(base, &reading, format),
1492 }
1493 }
1494 "em" => {
1495 let value = text_format_inline_children(node, format, definitions);
1496 match format {
1497 TextFormat::Aozora if !value.is_empty() && !value.contains('\n') => {
1498 let name = aozora_boten_name(
1499 node.get("mark")
1500 .and_then(serde_json::Value::as_str)
1501 .unwrap_or("﹅"),
1502 );
1503 format!("[#{name}]{value}[#{name}終わり]")
1504 }
1505 TextFormat::Kakuyomu
1506 if !value.is_empty()
1507 && !value.contains('\n')
1508 && !value.contains('《')
1509 && !node_contains_type(node, "ruby") =>
1510 {
1511 format!("《《{value}》》")
1512 }
1513 TextFormat::Narou
1514 if !value.is_empty()
1515 && !value.contains('\n')
1516 && !node_contains_type(node, "ruby") =>
1517 {
1518 value
1519 .graphemes(true)
1520 .map(|character| {
1521 text_format_platform_ruby(character, "・", TextFormat::Narou)
1522 })
1523 .collect()
1524 }
1525 _ => value,
1526 }
1527 }
1528 "image" => node
1529 .get("alt")
1530 .and_then(serde_json::Value::as_str)
1531 .filter(|alt| !alt.is_empty())
1532 .map(|alt| format!("[画像: {}]", text_format_literal(alt, format)))
1533 .unwrap_or_else(|| "[画像]".to_owned()),
1534 "warichu" => {
1535 let value = text_format_inline_children(node, format, definitions);
1536 if matches!(format, TextFormat::Aozora) && !value.is_empty() && !value.contains('\n') {
1537 format!("[#割り注]{value}[#割り注終わり]")
1538 } else {
1539 value
1540 }
1541 }
1542 "footnoteReference" => {
1543 if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1544 String::new()
1545 } else {
1546 let identifier = node
1547 .get("identifier")
1548 .and_then(serde_json::Value::as_str)
1549 .unwrap_or_default();
1550 let index = definitions
1551 .iter()
1552 .position(|definition| {
1553 definition
1554 .get("identifier")
1555 .and_then(serde_json::Value::as_str)
1556 == Some(identifier)
1557 })
1558 .map(|index| index + 1)
1559 .unwrap_or(0);
1560 if matches!(format, TextFormat::Aozora) {
1561 format!("(注{index})")
1562 } else {
1563 format!("[注{index}]")
1564 }
1565 }
1566 }
1567 _ => text_format_inline_children(node, format, definitions),
1568 }
1569}
1570
1571fn text_format_platform_ruby(base: &str, reading: &str, format: TextFormat) -> String {
1572 let valid = match format {
1573 TextFormat::Narou => {
1574 (1..=10).contains(&base.graphemes(true).count())
1575 && (1..=10).contains(&reading.graphemes(true).count())
1576 && !base.chars().any(narou_ruby_problem_character)
1577 && !reading.chars().any(narou_ruby_problem_character)
1578 }
1579 TextFormat::Kakuyomu => {
1580 (1..=20).contains(&base.graphemes(true).count())
1581 && (1..=50).contains(&reading.graphemes(true).count())
1582 && !base.contains(['\r', '\n'])
1583 && !reading.contains(['\r', '\n'])
1584 && !base.contains(['《', '》'])
1585 && !reading.contains(['《', '》'])
1586 }
1587 TextFormat::Aozora => {
1588 !base.is_empty()
1589 && !reading.is_empty()
1590 && !base.contains(['\r', '\n'])
1591 && !reading.contains(['\r', '\n'])
1592 && !base.chars().any(aozora_reserved_character)
1593 && !reading.chars().any(aozora_reserved_character)
1594 }
1595 TextFormat::Plain | TextFormat::Ruby => false,
1596 };
1597 if valid {
1598 format!("|{base}《{reading}》")
1599 } else {
1600 text_format_literal(base, format)
1601 }
1602}
1603
1604fn narou_ruby_problem_character(character: char) -> bool {
1605 matches!(character, '&' | '"' | '<' | '>')
1606}
1607
1608fn aozora_boten_name(mark: &str) -> &'static str {
1609 match mark {
1610 "﹆" => "白ゴマ傍点",
1611 "●" => "丸傍点",
1612 "○" => "白丸傍点",
1613 "▲" => "黒三角傍点",
1614 "△" => "白三角傍点",
1615 "◎" => "二重丸傍点",
1616 "×" => "ばつ傍点",
1617 _ => "傍点",
1618 }
1619}
1620
1621fn node_contains_type(node: &serde_json::Value, expected: &str) -> bool {
1622 node.get("type").and_then(serde_json::Value::as_str) == Some(expected)
1623 || children(node)
1624 .iter()
1625 .any(|child| node_contains_type(child, expected))
1626}
1627
1628fn text_format_plain_inline_children(node: &serde_json::Value) -> String {
1629 children(node)
1630 .iter()
1631 .map(text_format_plain_inline)
1632 .collect()
1633}
1634
1635fn text_format_plain_inline(node: &serde_json::Value) -> String {
1636 match node
1637 .get("type")
1638 .and_then(serde_json::Value::as_str)
1639 .unwrap_or_default()
1640 {
1641 "text" | "inlineCode" | "tcy" => node
1642 .get("value")
1643 .and_then(serde_json::Value::as_str)
1644 .unwrap_or_default()
1645 .to_owned(),
1646 "ruby" => node
1647 .get("base")
1648 .and_then(serde_json::Value::as_str)
1649 .unwrap_or_default()
1650 .to_owned(),
1651 "break" => "\n".to_owned(),
1652 "image" => node
1653 .get("alt")
1654 .and_then(serde_json::Value::as_str)
1655 .unwrap_or_default()
1656 .to_owned(),
1657 _ => text_format_plain_inline_children(node),
1658 }
1659}
1660
1661fn aozora_needs_range_annotation(node: &serde_json::Value) -> bool {
1662 node_contains_type(node, "em")
1663 || text_format_plain_inline_children(node)
1664 .chars()
1665 .any(aozora_reserved_character)
1666}
1667
1668fn aozora_reserved_character(character: char) -> bool {
1669 matches!(
1670 character,
1671 '《' | '》' | '[' | ']' | '〔' | '〕' | '|' | '#' | '※'
1672 )
1673}
1674
1675fn text_format_literal(value: &str, format: TextFormat) -> String {
1676 match format {
1677 TextFormat::Kakuyomu => value.replace('《', "|《"),
1678 TextFormat::Narou => value.replace('(', "|(").replace('(', "|("),
1679 TextFormat::Aozora => value
1680 .chars()
1681 .map(|character| match character {
1682 '《' => "※[#始め二重山括弧、1-1-52]".to_owned(),
1683 '》' => "※[#終わり二重山括弧、1-1-53]".to_owned(),
1684 '[' => "※[#始め角括弧、1-1-46]".to_owned(),
1685 ']' => "※[#終わり角括弧、1-1-47]".to_owned(),
1686 '〔' => "※[#始めきっこう(亀甲)括弧、1-1-44]".to_owned(),
1687 '〕' => "※[#終わりきっこう(亀甲)括弧、1-1-45]".to_owned(),
1688 '|' => "※[#縦線、1-1-35]".to_owned(),
1689 '#' => "※[#井げた、1-1-84]".to_owned(),
1690 '※' => "※[#米印、1-2-8]".to_owned(),
1691 _ => character.to_string(),
1692 })
1693 .collect(),
1694 TextFormat::Plain | TextFormat::Ruby => value.to_owned(),
1695 }
1696}
1697
1698fn render_text_node(node: &serde_json::Value, out: &mut String) {
1699 match node
1700 .get("type")
1701 .and_then(serde_json::Value::as_str)
1702 .unwrap_or_default()
1703 {
1704 "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
1705 node.get("value")
1706 .and_then(serde_json::Value::as_str)
1707 .unwrap_or_default(),
1708 ),
1709 "ruby" => out.push_str(
1710 node.get("base")
1711 .and_then(serde_json::Value::as_str)
1712 .unwrap_or_default(),
1713 ),
1714 "image" => out.push_str(
1715 node.get("alt")
1716 .and_then(serde_json::Value::as_str)
1717 .unwrap_or_default(),
1718 ),
1719 "break" => out.push('\n'),
1720 "blank" => out.push('\n'),
1721 "pagebreak" => out.push_str("\n\x0C\n"),
1722 "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
1723 render_text_children(node, out);
1724 out.push('\n');
1725 }
1726 "tableCell" => {
1727 render_text_children(node, out);
1728 out.push('\t');
1729 }
1730 _ => render_text_children(node, out),
1731 }
1732}
1733
1734fn render_text_children(node: &serde_json::Value, out: &mut String) {
1735 for child in children(node) {
1736 render_text_node(child, out);
1737 }
1738}
1739
1740fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
1741 let kind = node
1742 .get("type")
1743 .and_then(serde_json::Value::as_str)
1744 .unwrap_or_default();
1745 match kind {
1746 "paragraph" => {
1747 if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
1748 out.push_str(prefix);
1749 out.push_str(&format!("[[indent:{amount}]]\n"));
1750 }
1751 if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
1752 out.push_str(prefix);
1753 if amount == 0 {
1754 out.push_str("[[bottom]]\n");
1755 } else {
1756 out.push_str(&format!("[[bottom:{amount}]]\n"));
1757 }
1758 }
1759 out.push_str(prefix);
1760 serialize_inline_children(node, out);
1761 out.push('\n');
1762 }
1763 "heading" => {
1764 out.push_str(prefix);
1765 out.push_str(
1766 &"#".repeat(
1767 node.get("depth")
1768 .and_then(serde_json::Value::as_u64)
1769 .unwrap_or(1) as usize,
1770 ),
1771 );
1772 out.push(' ');
1773 serialize_inline_children(node, out);
1774 out.push('\n');
1775 }
1776 "blockquote" => {
1777 let mut content = String::new();
1778 for child in children(node) {
1779 serialize_block(child, &mut content, "");
1780 }
1781 for line in content.trim_end_matches('\n').lines() {
1782 out.push_str(prefix);
1783 out.push_str("> ");
1784 out.push_str(line);
1785 out.push('\n');
1786 }
1787 }
1788 "list" => {
1789 let ordered = node
1790 .get("ordered")
1791 .and_then(serde_json::Value::as_bool)
1792 .unwrap_or(false);
1793 let start = node
1794 .get("start")
1795 .and_then(serde_json::Value::as_u64)
1796 .unwrap_or(1);
1797 for (index, item) in children(node).iter().enumerate() {
1798 out.push_str(prefix);
1799 if ordered {
1800 out.push_str(&format!("{}.", start + index as u64));
1801 } else {
1802 out.push('-');
1803 }
1804 out.push(' ');
1805 if let Some(first) = children(item).first() {
1806 serialize_block(first, out, "");
1807 }
1808 for child in children(item).iter().skip(1) {
1809 serialize_block(child, out, " ");
1810 }
1811 }
1812 }
1813 "code" => {
1814 out.push_str(prefix);
1815 out.push_str("```");
1816 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
1817 out.push_str(lang);
1818 }
1819 out.push('\n');
1820 out.push_str(
1821 node.get("value")
1822 .and_then(serde_json::Value::as_str)
1823 .unwrap_or_default(),
1824 );
1825 out.push_str("\n```\n");
1826 }
1827 "thematicBreak" => out.push_str("---\n"),
1828 "blank" => out.push_str("\\\n"),
1829 "pagebreak" => {
1830 out.push_str("[[pagebreak");
1831 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
1832 out.push(':');
1833 out.push_str(variant);
1834 }
1835 out.push_str("]]\n");
1836 }
1837 "table" => serialize_table(node, out),
1838 "html" => {
1839 out.push_str(
1840 node.get("value")
1841 .and_then(serde_json::Value::as_str)
1842 .unwrap_or_default(),
1843 );
1844 out.push('\n');
1845 }
1846 _ => {
1847 serialize_inline(node, out);
1848 out.push('\n');
1849 }
1850 }
1851}
1852
1853fn serialize_table(node: &serde_json::Value, out: &mut String) {
1854 for (row_index, row) in children(node).iter().enumerate() {
1855 out.push('|');
1856 for cell in children(row) {
1857 out.push(' ');
1858 serialize_inline_children(cell, out);
1859 out.push_str(" |");
1860 }
1861 out.push('\n');
1862 if row_index == 0 {
1863 out.push('|');
1864 for _ in children(row) {
1865 out.push_str(" --- |");
1866 }
1867 out.push('\n');
1868 }
1869 }
1870}
1871
1872fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
1873 for child in children(node) {
1874 serialize_inline(child, out);
1875 }
1876}
1877
1878fn serialize_inline(node: &serde_json::Value, out: &mut String) {
1879 let kind = node
1880 .get("type")
1881 .and_then(serde_json::Value::as_str)
1882 .unwrap_or_default();
1883 match kind {
1884 "text" | "html" => out.push_str(
1885 node.get("value")
1886 .and_then(serde_json::Value::as_str)
1887 .unwrap_or_default(),
1888 ),
1889 "emphasis" => {
1890 out.push('*');
1891 serialize_inline_children(node, out);
1892 out.push('*');
1893 }
1894 "strong" => {
1895 out.push_str("**");
1896 serialize_inline_children(node, out);
1897 out.push_str("**");
1898 }
1899 "delete" => {
1900 out.push_str("~~");
1901 serialize_inline_children(node, out);
1902 out.push_str("~~");
1903 }
1904 "inlineCode" => {
1905 out.push('`');
1906 out.push_str(
1907 node.get("value")
1908 .and_then(serde_json::Value::as_str)
1909 .unwrap_or_default(),
1910 );
1911 out.push('`');
1912 }
1913 "link" => {
1914 out.push('[');
1915 serialize_inline_children(node, out);
1916 out.push_str("](");
1917 out.push_str(
1918 node.get("url")
1919 .and_then(serde_json::Value::as_str)
1920 .unwrap_or_default(),
1921 );
1922 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
1923 out.push_str(" \\");
1924 out.push_str(title);
1925 out.push('\"');
1926 }
1927 out.push(')');
1928 }
1929 "image" => {
1930 out.push_str(";
1937 out.push_str(
1938 node.get("url")
1939 .and_then(serde_json::Value::as_str)
1940 .unwrap_or_default(),
1941 );
1942 out.push(')');
1943 }
1944 "ruby" => {
1945 out.push('{');
1946 out.push_str(
1947 node.get("base")
1948 .and_then(serde_json::Value::as_str)
1949 .unwrap_or_default(),
1950 );
1951 out.push('|');
1952 if let Some(values) = node
1953 .pointer("/ruby/value")
1954 .and_then(serde_json::Value::as_array)
1955 {
1956 for (index, value) in values.iter().enumerate() {
1957 if index > 0 {
1958 out.push('.');
1959 }
1960 out.push_str(value.as_str().unwrap_or_default());
1961 }
1962 } else {
1963 out.push_str(
1964 node.pointer("/ruby/value")
1965 .and_then(serde_json::Value::as_str)
1966 .unwrap_or_default(),
1967 );
1968 }
1969 out.push('}');
1970 }
1971 "tcy" => {
1972 out.push('^');
1973 out.push_str(
1974 node.get("value")
1975 .and_then(serde_json::Value::as_str)
1976 .unwrap_or_default(),
1977 );
1978 out.push('^');
1979 }
1980 "break" => out.push_str("[[br]]"),
1983 "em" => {
1984 out.push_str("[[em:");
1985 let mark = node
1986 .get("mark")
1987 .and_then(serde_json::Value::as_str)
1988 .unwrap_or("﹅");
1989 if mark != "﹅" {
1990 out.push_str(mark);
1991 out.push(':');
1992 }
1993 serialize_inline_children(node, out);
1994 out.push_str("]]");
1995 }
1996 "noBreak" => {
1997 out.push_str("[[no-break:");
1998 serialize_inline_children(node, out);
1999 out.push_str("]]");
2000 }
2001 "warichu" => {
2002 out.push_str("[[warichu:");
2003 serialize_inline_children(node, out);
2004 out.push_str("]]");
2005 }
2006 "kern" => {
2007 out.push_str("[[kern:");
2008 out.push_str(
2009 node.get("amount")
2010 .and_then(serde_json::Value::as_str)
2011 .unwrap_or_default(),
2012 );
2013 out.push(':');
2014 serialize_inline_children(node, out);
2015 out.push_str("]]");
2016 }
2017 "footnoteReference" => {
2018 out.push_str("[^");
2019 out.push_str(
2020 node.get("identifier")
2021 .and_then(serde_json::Value::as_str)
2022 .unwrap_or_default(),
2023 );
2024 out.push(']');
2025 }
2026 _ => serialize_inline_children(node, out),
2027 }
2028}
2029
2030fn children(node: &serde_json::Value) -> &[serde_json::Value] {
2031 node.get("children")
2032 .and_then(serde_json::Value::as_array)
2033 .map(Vec::as_slice)
2034 .unwrap_or(&[])
2035}
2036
2037pub 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}";
2040
2041pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
2045 render_epub_document(&parse_document(source))
2046}
2047
2048pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
2050 let cursor = Cursor::new(Vec::new());
2051 let mut zip = ZipWriter::new(cursor);
2052 write_epub_document(document, &mut zip)?;
2053 zip.finish()
2054 .map(|cursor| cursor.into_inner())
2055 .map_err(|error| error.to_string())
2056}
2057
2058fn write_epub_document<W: Write + Seek>(
2059 document: &Document,
2060 zip: &mut ZipWriter<W>,
2061) -> Result<(), String> {
2062 let field = |key: &str| {
2063 document
2064 .frontmatter
2065 .as_ref()
2066 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2067 .and_then(|entry| entry.value.as_str())
2068 };
2069 let title = field("title").unwrap_or("Untitled");
2070 let author = field("author");
2071 let language = field("lang").unwrap_or("ja");
2072 let identifier = field("identifier").unwrap_or("urn:mdi:document");
2073 let vertical = matches!(field("writing-mode"), Some("vertical"));
2074 let chapters = epub_chapters(document);
2075 let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2076 epub_file(zip, "mimetype", "application/epub+zip", stored)?;
2077 let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2078 epub_file(
2079 zip,
2080 "META-INF/container.xml",
2081 "<?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>",
2082 compressed,
2083 )?;
2084 let writing = if vertical {
2085 "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
2086 } else {
2087 ""
2088 };
2089 epub_file(
2090 zip,
2091 "OEBPS/style.css",
2092 &format!(
2093 "body{{font-family:serif;{writing}line-height:1.8;margin:1em}}p{{text-indent:1em;margin:.3em 0}}{MDI_STYLESHEET}"
2094 ),
2095 compressed,
2096 )?;
2097 let nav_items = chapters
2098 .iter()
2099 .enumerate()
2100 .map(|(index, chapter)| {
2101 format!(
2102 "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
2103 index + 1,
2104 escape_html(&chapter.title)
2105 )
2106 })
2107 .collect::<String>();
2108 epub_file(
2109 zip,
2110 "OEBPS/nav.xhtml",
2111 &epub_xhtml(
2112 "Contents",
2113 language,
2114 &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
2115 ),
2116 compressed,
2117 )?;
2118 for (index, chapter) in chapters.iter().enumerate() {
2119 epub_file(
2120 zip,
2121 &format!("OEBPS/chapter-{}.xhtml", index + 1),
2122 &epub_xhtml(
2123 if chapter.title.is_empty() {
2124 title
2125 } else {
2126 &chapter.title
2127 },
2128 language,
2129 &chapter.html,
2130 ),
2131 compressed,
2132 )?;
2133 }
2134 let manifest = format!("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/><item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>{}", chapters.iter().enumerate().map(|(index, _)| format!("<item id=\"chapter-{}\" href=\"chapter-{}.xhtml\" media-type=\"application/xhtml+xml\"/>", index + 1, index + 1)).collect::<String>());
2135 let spine = chapters
2136 .iter()
2137 .enumerate()
2138 .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
2139 .collect::<String>();
2140 let creator = author
2141 .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
2142 .unwrap_or_default();
2143 let progression = if vertical {
2144 " page-progression-direction=\"rtl\""
2145 } else {
2146 ""
2147 };
2148 epub_file(
2149 zip,
2150 "OEBPS/package.opf",
2151 &format!(
2152 "<?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}</metadata><manifest>{manifest}</manifest><spine{progression}>{spine}</spine></package>",
2153 escape_html(identifier),
2154 escape_html(title),
2155 escape_html(language)
2156 ),
2157 compressed,
2158 )?;
2159 Ok(())
2160}
2161
2162pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
2166 render_docx_document(&parse_document(source))
2167}
2168
2169pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
2171 let cursor = Cursor::new(Vec::new());
2172 let mut zip = ZipWriter::new(cursor);
2173 write_docx_document(document, &mut zip)?;
2174 zip.finish()
2175 .map(|cursor| cursor.into_inner())
2176 .map_err(|error| error.to_string())
2177}
2178
2179fn write_docx_document<W: Write + Seek>(
2180 document: &Document,
2181 zip: &mut ZipWriter<W>,
2182) -> Result<(), String> {
2183 let title = document
2184 .frontmatter
2185 .as_ref()
2186 .and_then(|frontmatter| {
2187 frontmatter
2188 .entries
2189 .iter()
2190 .find(|entry| entry.key == "title")
2191 })
2192 .and_then(|entry| entry.value.as_str())
2193 .unwrap_or("Untitled");
2194 let mut content = String::new();
2195 for node in &document.children {
2196 if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2197 content.push('\x0C');
2198 content.push('\n');
2199 } else {
2200 render_text_node(node, &mut content);
2201 if !content.ends_with('\n') {
2202 content.push('\n');
2203 }
2204 }
2205 }
2206 let paragraphs = content
2207 .split('\n')
2208 .map(|line| {
2209 if line.contains('\x0C') {
2210 "<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>".to_owned()
2211 } else {
2212 format!(
2213 "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
2214 escape_xml(line)
2215 )
2216 }
2217 })
2218 .collect::<String>();
2219 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2220 docx_file(
2221 zip,
2222 "[Content_Types].xml",
2223 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/><Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/></Types>",
2224 options,
2225 )?;
2226 docx_file(
2227 zip,
2228 "_rels/.rels",
2229 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/><Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/></Relationships>",
2230 options,
2231 )?;
2232 docx_file(
2233 zip,
2234 "docProps/core.xml",
2235 &format!(
2236 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
2237 escape_xml(title)
2238 ),
2239 options,
2240 )?;
2241 docx_file(
2242 zip,
2243 "word/document.xml",
2244 &format!(
2245 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>{paragraphs}<w:sectPr/></w:body></w:document>"
2246 ),
2247 options,
2248 )?;
2249 Ok(())
2250}
2251
2252#[derive(Debug, Clone, Default)]
2255pub struct PdfOptions {
2256 pub chromium_path: Option<PathBuf>,
2257}
2258
2259pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
2262 let chromium = options
2263 .chromium_path
2264 .clone()
2265 .or_else(find_chromium)
2266 .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
2267 let nonce = SystemTime::now()
2268 .duration_since(UNIX_EPOCH)
2269 .map_err(|error| error.to_string())?
2270 .as_nanos();
2271 let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
2272 fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
2273 let html_path = directory.join("document.html");
2274 let pdf_path = directory.join("document.pdf");
2275 let result = (|| {
2276 fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
2277 let output = Command::new(&chromium)
2278 .arg("--headless=new")
2279 .arg("--disable-gpu")
2280 .arg("--no-pdf-header-footer")
2281 .arg(format!("--print-to-pdf={}", pdf_path.display()))
2282 .arg(format!("file://{}", html_path.display()))
2283 .output()
2284 .map_err(|error| {
2285 format!(
2286 "failed to start Chromium at {}: {error}",
2287 chromium.display()
2288 )
2289 })?;
2290 if !output.status.success() {
2291 return Err(format!(
2292 "Chromium PDF rendering failed: {}",
2293 String::from_utf8_lossy(&output.stderr)
2294 ));
2295 }
2296 fs::read(&pdf_path).map_err(|error| error.to_string())
2297 })();
2298 let _ = fs::remove_dir_all(&directory);
2299 result
2300}
2301
2302pub fn find_chromium() -> Option<PathBuf> {
2305 let candidates = [
2306 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
2307 "/Applications/Chromium.app/Contents/MacOS/Chromium",
2308 "/usr/bin/google-chrome",
2309 "/usr/bin/chromium",
2310 "/usr/bin/chromium-browser",
2311 ];
2312 candidates
2313 .iter()
2314 .map(Path::new)
2315 .find(|path| path.is_file())
2316 .map(Path::to_path_buf)
2317}
2318
2319fn docx_file<W: Write + Seek>(
2320 zip: &mut ZipWriter<W>,
2321 path: &str,
2322 content: &str,
2323 options: SimpleFileOptions,
2324) -> Result<(), String> {
2325 zip.start_file(path, options)
2326 .map_err(|error| error.to_string())?;
2327 zip.write_all(content.as_bytes())
2328 .map_err(|error| error.to_string())
2329}
2330fn escape_xml(value: &str) -> String {
2331 value
2332 .replace('&', "&")
2333 .replace('<', "<")
2334 .replace('>', ">")
2335 .replace('"', """)
2336 .replace('\'', "'")
2337}
2338
2339struct EpubChapter {
2340 title: String,
2341 html: String,
2342}
2343fn epub_chapters(document: &Document) -> Vec<EpubChapter> {
2344 let mut chapters = vec![EpubChapter {
2345 title: String::new(),
2346 html: String::new(),
2347 }];
2348 for node in &document.children {
2349 if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2350 if !chapters
2351 .last()
2352 .is_some_and(|chapter| chapter.html.is_empty())
2353 {
2354 chapters.push(EpubChapter {
2355 title: String::new(),
2356 html: String::new(),
2357 });
2358 }
2359 continue;
2360 }
2361 if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2362 && node.get("depth").and_then(serde_json::Value::as_u64) == Some(1)
2363 && !chapters
2364 .last()
2365 .is_some_and(|chapter| chapter.html.is_empty())
2366 {
2367 chapters.push(EpubChapter {
2368 title: String::new(),
2369 html: String::new(),
2370 });
2371 }
2372 let chapter = chapters.last_mut().expect("one chapter exists");
2373 if chapter.title.is_empty()
2374 && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2375 {
2376 chapter.title = plain_node_text(node);
2377 }
2378 render_html_node(node, &mut chapter.html);
2379 }
2380 let chapters: Vec<_> = chapters
2381 .into_iter()
2382 .filter(|chapter| !chapter.html.is_empty())
2383 .collect();
2384 if chapters.is_empty() {
2385 vec![EpubChapter {
2386 title: String::new(),
2387 html: String::new(),
2388 }]
2389 } else {
2390 chapters
2391 }
2392}
2393fn plain_node_text(node: &serde_json::Value) -> String {
2394 let mut text = String::new();
2395 render_text_children(node, &mut text);
2396 text
2397}
2398fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
2399 format!(
2400 "<?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>",
2401 escape_html(language),
2402 escape_html(language),
2403 escape_html(title),
2404 body.replace("<br>", "<br/>").replace("<hr>", "<hr/>")
2405 )
2406}
2407fn epub_file<W: Write + Seek>(
2408 zip: &mut ZipWriter<W>,
2409 path: &str,
2410 content: &str,
2411 options: SimpleFileOptions,
2412) -> Result<(), String> {
2413 zip.start_file(path, options)
2414 .map_err(|error| error.to_string())?;
2415 zip.write_all(content.as_bytes())
2416 .map_err(|error| error.to_string())
2417}
2418
2419fn render_html_node(node: &serde_json::Value, out: &mut String) {
2420 let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
2421 return;
2422 };
2423 let children = |out: &mut String| render_html_children(node, out);
2424 match kind {
2425 "text" => out.push_str(&escape_html(
2426 node.get("value")
2427 .and_then(serde_json::Value::as_str)
2428 .unwrap_or_default(),
2429 )),
2430 "root" => children(out),
2431 "paragraph" => {
2432 let class = if node.get("indent").is_some() {
2433 " class=\"mdi-indent\""
2434 } else if node.get("bottom").is_some() {
2435 " class=\"mdi-bottom\""
2436 } else {
2437 ""
2438 };
2439 let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
2440 {
2441 format!(" style=\"--mdi-indent:{amount};\"")
2442 } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2443 format!(" style=\"--mdi-shift:{amount};\"")
2444 } else {
2445 String::new()
2446 };
2447 out.push_str("<p");
2448 out.push_str(class);
2449 out.push_str(&style);
2450 out.push('>');
2451 children(out);
2452 out.push_str("</p>");
2453 }
2454 "heading" => {
2455 let depth = node
2456 .get("depth")
2457 .and_then(serde_json::Value::as_u64)
2458 .filter(|depth| (1..=6).contains(depth))
2459 .unwrap_or(1);
2460 out.push_str(&format!("<h{depth}>"));
2461 children(out);
2462 out.push_str(&format!("</h{depth}>"));
2463 }
2464 "emphasis" => wrapped(out, "em", children),
2465 "strong" => wrapped(out, "strong", children),
2466 "delete" => wrapped(out, "del", children),
2467 "blockquote" => wrapped(out, "blockquote", children),
2468 "list" => {
2469 let ordered = node
2470 .get("ordered")
2471 .and_then(serde_json::Value::as_bool)
2472 .unwrap_or(false);
2473 let tag = if ordered { "ol" } else { "ul" };
2474 let start = node
2475 .get("start")
2476 .and_then(serde_json::Value::as_u64)
2477 .filter(|start| *start != 1)
2478 .map(|start| format!(" start=\"{start}\""))
2479 .unwrap_or_default();
2480 out.push('<');
2481 out.push_str(tag);
2482 out.push_str(&start);
2483 out.push('>');
2484 children(out);
2485 out.push_str("</");
2486 out.push_str(tag);
2487 out.push('>');
2488 }
2489 "listItem" => wrapped(out, "li", children),
2490 "thematicBreak" => out.push_str("<hr>"),
2491 "break" => out.push_str("<br>"),
2492 "inlineCode" => {
2493 out.push_str("<code>");
2494 out.push_str(&escape_html(
2495 node.get("value")
2496 .and_then(serde_json::Value::as_str)
2497 .unwrap_or_default(),
2498 ));
2499 out.push_str("</code>");
2500 }
2501 "code" => {
2502 out.push_str("<pre><code");
2503 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2504 out.push_str(" class=\"language-");
2505 out.push_str(&escape_html(lang));
2506 out.push('"');
2507 }
2508 out.push('>');
2509 out.push_str(&escape_html(
2510 node.get("value")
2511 .and_then(serde_json::Value::as_str)
2512 .unwrap_or_default(),
2513 ));
2514 out.push_str("</code></pre>");
2515 }
2516 "link" => {
2517 out.push_str("<a href=\"");
2518 out.push_str(&escape_html(
2519 node.get("url")
2520 .and_then(serde_json::Value::as_str)
2521 .unwrap_or_default(),
2522 ));
2523 out.push('"');
2524 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2525 out.push_str(" title=\"");
2526 out.push_str(&escape_html(title));
2527 out.push('"');
2528 }
2529 out.push('>');
2530 children(out);
2531 out.push_str("</a>");
2532 }
2533 "image" => {
2534 out.push_str("<img src=\"");
2535 out.push_str(&escape_html(
2536 node.get("url")
2537 .and_then(serde_json::Value::as_str)
2538 .unwrap_or_default(),
2539 ));
2540 out.push_str("\" alt=\"");
2541 out.push_str(&escape_html(
2542 node.get("alt")
2543 .and_then(serde_json::Value::as_str)
2544 .unwrap_or_default(),
2545 ));
2546 out.push_str("\">");
2547 }
2548 "table" => wrapped(out, "table", children),
2549 "tableRow" => wrapped(out, "tr", children),
2550 "tableCell" => wrapped(out, "td", children),
2551 "footnoteReference" => {
2552 let label = node
2553 .get("label")
2554 .and_then(serde_json::Value::as_str)
2555 .unwrap_or_default();
2556 out.push_str("<sup class=\"footnote-ref\">");
2557 out.push_str(&escape_html(label));
2558 out.push_str("</sup>");
2559 }
2560 "footnoteDefinition" | "definition" => {}
2561 "html" => out.push_str(&escape_html(
2562 node.get("value")
2563 .and_then(serde_json::Value::as_str)
2564 .unwrap_or_default(),
2565 )),
2566 "ruby" => render_ruby(node, out),
2567 "tcy" => {
2568 out.push_str("<span class=\"mdi-tcy\">");
2569 out.push_str(&escape_html(
2570 node.get("value")
2571 .and_then(serde_json::Value::as_str)
2572 .unwrap_or_default(),
2573 ));
2574 out.push_str("</span>");
2575 }
2576 "em" => {
2577 let mark = node
2578 .get("mark")
2579 .and_then(serde_json::Value::as_str)
2580 .unwrap_or("﹅");
2581 out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:"");
2582 out.push_str(&escape_css_string(mark));
2583 out.push_str("";\">");
2584 children(out);
2585 out.push_str("</span>");
2586 }
2587 "noBreak" => {
2588 out.push_str("<span class=\"mdi-nobr\">");
2589 children(out);
2590 out.push_str("</span>");
2591 }
2592 "warichu" => {
2593 out.push_str("<span class=\"mdi-warichu\">");
2594 children(out);
2595 out.push_str("</span>");
2596 }
2597 "kern" => {
2598 out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
2599 out.push_str(&escape_html(
2600 node.get("amount")
2601 .and_then(serde_json::Value::as_str)
2602 .unwrap_or_default(),
2603 ));
2604 out.push_str(";\">");
2605 children(out);
2606 out.push_str("</span>");
2607 }
2608 "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
2609 "pagebreak" => {
2610 out.push_str("<div class=\"mdi-pagebreak");
2611 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2612 out.push_str(" mdi-pagebreak-");
2613 out.push_str(&escape_html(variant));
2614 }
2615 out.push_str("\" role=\"presentation\"></div>");
2616 }
2617 _ => children(out),
2618 }
2619}
2620
2621fn render_html_children(node: &serde_json::Value, out: &mut String) {
2622 if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
2623 for child in children {
2624 render_html_node(child, out);
2625 }
2626 }
2627}
2628
2629fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
2630 out.push('<');
2631 out.push_str(tag);
2632 out.push('>');
2633 children(out);
2634 out.push_str("</");
2635 out.push_str(tag);
2636 out.push('>');
2637}
2638
2639fn render_ruby(node: &serde_json::Value, out: &mut String) {
2640 let base = node
2641 .get("base")
2642 .and_then(serde_json::Value::as_str)
2643 .unwrap_or_default();
2644 let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
2645 out.push_str("<ruby class=\"mdi-ruby\">");
2646 if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
2647 for (base, reading) in base.graphemes(true).zip(parts) {
2648 out.push_str(&escape_html(base));
2649 render_ruby_reading(reading.as_str().unwrap_or_default(), out);
2650 }
2651 } else {
2652 out.push_str(&escape_html(base));
2653 render_ruby_reading(
2654 reading
2655 .and_then(serde_json::Value::as_str)
2656 .unwrap_or_default(),
2657 out,
2658 );
2659 }
2660 out.push_str("</ruby>");
2661}
2662
2663fn render_ruby_reading(reading: &str, out: &mut String) {
2664 out.push_str("<rp>(</rp><rt>");
2665 out.push_str(&escape_html(reading));
2666 out.push_str("</rt><rp>)</rp>");
2667}
2668
2669fn escape_html(value: &str) -> String {
2670 value
2671 .replace('&', "&")
2672 .replace('<', "<")
2673 .replace('>', ">")
2674 .replace('"', """)
2675}
2676fn escape_css_string(value: &str) -> String {
2677 value.replace('\\', "\\\\").replace('"', "\\\"")
2678}
2679
2680pub fn parse_inlines(source: &str) -> Vec<Inline> {
2682 parse_inline_parts(source)
2683 .into_iter()
2684 .map(|(inline, _, _)| inline)
2685 .collect()
2686}
2687
2688fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
2692 let mut out = Vec::new();
2693 let mut text = String::new();
2694 let mut text_start = 0;
2695 let mut index = 0;
2696
2697 while index < source.len() {
2698 let rest = &source[index..];
2699 if rest.starts_with('\\') {
2700 let mut chars = rest.chars();
2701 let slash = chars.next().expect("prefix was checked");
2702 let Some(next) = chars.next() else {
2703 text.push(slash);
2704 index += slash.len_utf8();
2705 continue;
2706 };
2707 if is_escapable(next) {
2708 text.push(next);
2709 } else {
2710 text.push(slash);
2711 text.push(next);
2712 }
2713 index += slash.len_utf8() + next.len_utf8();
2714 continue;
2715 }
2716 if let Some((inline, consumed)) = ruby(rest) {
2717 push_inline_text(&mut out, &mut text, text_start, index);
2718 out.push((inline, index, index + consumed));
2719 index += consumed;
2720 text_start = index;
2721 continue;
2722 }
2723 if let Some((inline, consumed)) = tcy(rest) {
2724 push_inline_text(&mut out, &mut text, text_start, index);
2725 out.push((inline, index, index + consumed));
2726 index += consumed;
2727 text_start = index;
2728 continue;
2729 }
2730 if let Some((inline, consumed)) = boten(rest) {
2731 push_inline_text(&mut out, &mut text, text_start, index);
2732 out.push((inline, index, index + consumed));
2733 index += consumed;
2734 text_start = index;
2735 continue;
2736 }
2737 if let Some((inline, consumed)) = bracket_macro(rest) {
2738 push_inline_text(&mut out, &mut text, text_start, index);
2739 out.push((inline, index, index + consumed));
2740 index += consumed;
2741 text_start = index;
2742 continue;
2743 }
2744 let character = rest.chars().next().expect("index is in bounds");
2745 text.push(character);
2746 index += character.len_utf8();
2747 }
2748 push_inline_text(&mut out, &mut text, text_start, index);
2749 out
2750}
2751
2752#[derive(Clone)]
2753enum PendingBlock {
2754 Indent { amount: u32, source: String },
2755 Bottom { amount: u32, source: String },
2756}
2757
2758fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
2759 let (indent, bottom) = match pending {
2760 Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
2761 Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
2762 None => (None, None),
2763 };
2764 MdiBlock::Paragraph {
2765 inlines: parse_inlines(line),
2766 indent,
2767 bottom,
2768 }
2769}
2770
2771fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
2772 if let Some(marker) = pending.take() {
2773 let source = match marker {
2776 PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
2777 };
2778 blocks.push(paragraph(&source, None));
2779 }
2780}
2781
2782fn is_blank_marker(line: &str) -> bool {
2783 let value = line.trim_end_matches([' ', '\t']);
2784 value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
2785}
2786
2787fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
2788 match line.trim() {
2789 "[[pagebreak]]" => Some(None),
2790 "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
2791 "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
2792 _ => None,
2793 }
2794}
2795
2796fn pending_block(line: &str) -> Option<PendingBlock> {
2797 let value = line.trim();
2798 if value == "[[bottom]]" {
2799 return Some(PendingBlock::Bottom {
2800 amount: 0,
2801 source: value.to_owned(),
2802 });
2803 }
2804 let (kind, amount) = value
2805 .strip_prefix("[[")?
2806 .strip_suffix("]]")?
2807 .split_once(':')?;
2808 if amount.is_empty()
2809 || amount.starts_with('0')
2810 || !amount.bytes().all(|byte| byte.is_ascii_digit())
2811 {
2812 return None;
2813 }
2814 let amount = amount.parse().ok()?;
2815 match kind {
2816 "indent" => Some(PendingBlock::Indent {
2817 amount,
2818 source: value.to_owned(),
2819 }),
2820 "bottom" => Some(PendingBlock::Bottom {
2821 amount,
2822 source: value.to_owned(),
2823 }),
2824 _ => None,
2825 }
2826}
2827
2828fn ruby(value: &str) -> Option<(Inline, usize)> {
2829 if !value.starts_with('{') {
2830 return None;
2831 }
2832 let end = close_unescaped(value, 1, '}')?;
2833 let body = &value[1..end];
2834 let separator = bare_index(body, '|')?;
2835 let base = unescape_ruby(&body[..separator]);
2836 let raw_ruby = &body[separator + 1..];
2837 let ruby = split_ruby(&base, raw_ruby);
2838 Some((Inline::Ruby { base, ruby }, end + 1))
2839}
2840
2841fn split_ruby(base: &str, raw: &str) -> RubyReading {
2842 let segments = split_unescaped(raw, '.');
2843 if segments.len() == 1 {
2844 return RubyReading::Group(unescape_ruby(raw));
2845 }
2846 let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
2847 if segments.len() == base.graphemes(true).count()
2848 && segments.iter().all(|part| !part.is_empty())
2849 {
2850 RubyReading::Split(segments)
2851 } else {
2852 RubyReading::Group(segments.concat())
2853 }
2854}
2855
2856fn tcy(value: &str) -> Option<(Inline, usize)> {
2857 if !value.starts_with('^') {
2858 return None;
2859 }
2860 let closing = value[1..].find('^')? + 1;
2861 let body = &value[1..closing];
2862 if body.is_empty()
2863 || body.chars().count() > 6
2864 || !body
2865 .chars()
2866 .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
2867 {
2868 return None;
2869 }
2870 Some((Inline::Tcy(body.to_owned()), closing + 1))
2871}
2872
2873fn boten(value: &str) -> Option<(Inline, usize)> {
2874 let prefix = "《《";
2875 if !value.starts_with(prefix) {
2876 return None;
2877 }
2878 let end = close_boten_alias(value)?;
2879 let body = &value[prefix.len()..end];
2880 if body.is_empty()
2881 || body.contains('\n')
2882 || contains_unescaped(body, '《')
2883 || contains_unescaped(body, '》')
2884 {
2885 return None;
2886 }
2887 Some((
2888 Inline::Em {
2889 mark: "﹅".to_owned(),
2890 children: vec![Inline::Text(unescape_mdi(body))],
2891 },
2892 end + "》》".len(),
2893 ))
2894}
2895
2896fn close_boten_alias(value: &str) -> Option<usize> {
2899 let mut index = "《《".len();
2900 while index < value.len() {
2901 let rest = &value[index..];
2902 if rest.starts_with('\\') {
2903 let next = rest.chars().nth(1)?;
2904 index += 1 + next.len_utf8();
2905 } else if rest.starts_with("》》") {
2906 return Some(index);
2907 } else {
2908 index += rest.chars().next()?.len_utf8();
2909 }
2910 }
2911 None
2912}
2913
2914fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
2915 if !value.starts_with("[[") {
2916 return None;
2917 }
2918 if value.starts_with("[[br]]") {
2919 return Some((Inline::Break, "[[br]]".len()));
2920 }
2921 let end = close_macro(value)?;
2922 let body = &value[2..end];
2923 let (name, payload) = body.split_once(':')?;
2924 let children = |content: &str| parse_inlines(content);
2925 let inline = match name {
2926 "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
2927 "warichu" => Inline::Warichu(children(payload)),
2928 "kern" => {
2929 let (amount, content) = payload.split_once(':')?;
2930 if !valid_kern(amount) {
2931 return None;
2932 }
2933 Inline::Kern {
2934 amount: unescape_mdi(amount),
2935 children: children(content),
2936 }
2937 }
2938 "em" => {
2939 let (mark, content) = match bare_index(payload, ':') {
2940 Some(index) => {
2941 let candidate = unescape_mdi(&payload[..index]);
2942 if candidate.graphemes(true).count() == 1
2943 && !candidate
2944 .chars()
2945 .any(|c| c.is_whitespace() || c.is_control())
2946 {
2947 (candidate, &payload[index + 1..])
2948 } else {
2949 ("﹅".to_owned(), payload)
2950 }
2951 }
2952 None => ("﹅".to_owned(), payload),
2953 };
2954 Inline::Em {
2955 mark,
2956 children: children(content),
2957 }
2958 }
2959 _ => return None,
2960 };
2961 Some((inline, end + 2))
2962}
2963
2964fn close_macro(value: &str) -> Option<usize> {
2965 let mut index = 2;
2966 let mut depth = 1;
2967 while index < value.len() {
2968 let rest = &value[index..];
2969 if rest.starts_with('\\') {
2970 index += rest.chars().nth(1)?.len_utf8() + 1;
2971 } else if rest.starts_with("[[") {
2972 depth += 1;
2973 index += 2;
2974 } else if rest.starts_with("]]") {
2975 depth -= 1;
2976 if depth == 0 {
2977 return Some(index);
2978 }
2979 index += 2;
2980 } else {
2981 index += rest.chars().next()?.len_utf8();
2982 }
2983 }
2984 None
2985}
2986
2987fn valid_kern(value: &str) -> bool {
2988 let value = value.strip_suffix("em").unwrap_or("");
2989 let value = value.strip_prefix(['+', '-']).unwrap_or(value);
2990 let mut parts = value.split('.');
2991 let whole = parts.next().unwrap_or("");
2992 let fraction = parts.next();
2993 parts.next().is_none()
2994 && !whole.is_empty()
2995 && whole.bytes().all(|b| b.is_ascii_digit())
2996 && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
2997}
2998
2999fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
3000 let mut escaped = false;
3001 for (index, character) in value[start..].char_indices() {
3002 if escaped {
3003 escaped = false;
3004 continue;
3005 }
3006 if character == '\\' {
3007 escaped = true;
3008 continue;
3009 }
3010 if character == needle {
3011 return Some(start + index);
3012 }
3013 if character == '\n' {
3014 return None;
3015 }
3016 }
3017 None
3018}
3019
3020fn bare_index(value: &str, needle: char) -> Option<usize> {
3021 let mut escaped = false;
3022 for (index, character) in value.char_indices() {
3023 if escaped {
3024 escaped = false;
3025 continue;
3026 }
3027 if character == '\\' {
3028 escaped = true;
3029 continue;
3030 }
3031 if character == needle {
3032 return Some(index);
3033 }
3034 }
3035 None
3036}
3037
3038fn contains_unescaped(value: &str, needle: char) -> bool {
3039 bare_index(value, needle).is_some()
3040}
3041
3042fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
3043 let mut parts = Vec::new();
3044 let mut start = 0;
3045 let mut escaped = false;
3046 for (index, character) in value.char_indices() {
3047 if escaped {
3048 escaped = false;
3049 continue;
3050 }
3051 if character == '\\' {
3052 escaped = true;
3053 continue;
3054 }
3055 if character == separator {
3056 parts.push(&value[start..index]);
3057 start = index + character.len_utf8();
3058 }
3059 }
3060 parts.push(&value[start..]);
3061 parts
3062}
3063
3064const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
3069const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
3070
3071fn unescape_mdi(value: &str) -> String {
3072 unescape(value, ESCAPABLE_MDI)
3073}
3074
3075fn unescape_ruby(value: &str) -> String {
3076 unescape(value, ESCAPABLE_RUBY)
3077}
3078
3079fn unescape(value: &str, allowed: &str) -> String {
3080 let mut out = String::new();
3081 let mut chars = value.chars();
3082 while let Some(character) = chars.next() {
3083 if character == '\\' {
3084 if let Some(next) = chars.next() {
3085 if allowed.contains(next) {
3086 out.push(next);
3087 } else {
3088 out.push(character);
3089 out.push(next);
3090 }
3091 } else {
3092 out.push(character);
3093 }
3094 } else {
3095 out.push(character);
3096 }
3097 }
3098 out
3099}
3100
3101fn is_escapable(character: char) -> bool {
3102 ESCAPABLE_MDI.contains(character)
3103}
3104
3105fn push_inline_text(
3106 out: &mut Vec<(Inline, usize, usize)>,
3107 text: &mut String,
3108 start: usize,
3109 end: usize,
3110) {
3111 if !text.is_empty() {
3112 out.push((Inline::Text(std::mem::take(text)), start, end));
3113 }
3114}
3115
3116#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3124enum BlockMacroClass {
3125 Indent(u32),
3126 Bottom(u32),
3127 Pagebreak(Option<PagebreakVariant>),
3128 Literal,
3129}
3130
3131#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3132fn classify_block_macro(source: &str) -> BlockMacroClass {
3133 let value = source.trim();
3134 match value {
3135 "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
3136 "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
3137 "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
3138 "[[bottom]]" => return BlockMacroClass::Bottom(0),
3139 _ => {}
3140 }
3141 if let Some((kind, amount)) = value
3142 .strip_prefix("[[")
3143 .and_then(|rest| rest.strip_suffix("]]"))
3144 .and_then(|inner| inner.split_once(':'))
3145 {
3146 let valid_amount = !amount.is_empty()
3147 && !amount.starts_with('0')
3148 && amount.bytes().all(|b| b.is_ascii_digit());
3149 if valid_amount && let Ok(amount) = amount.parse::<u32>() {
3150 match kind {
3151 "indent" => return BlockMacroClass::Indent(amount),
3152 "bottom" => return BlockMacroClass::Bottom(amount),
3153 _ => {}
3154 }
3155 }
3156 }
3157 BlockMacroClass::Literal
3158}
3159
3160#[cfg(feature = "wasm")]
3163mod wasm {
3164 use super::{
3165 BlockMacroClass, PagebreakVariant, RubyReading, TextFormat, classify_block_macro,
3166 parse_json, render_docx, render_epub, render_html, render_text, render_text_format,
3167 serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
3168 };
3169 use wasm_bindgen::prelude::*;
3170
3171 #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
3175 pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
3176 parse_json(source)
3177 }
3178
3179 #[wasm_bindgen(js_name = renderHtml)]
3181 pub fn wasm_render_html(source: &str) -> String {
3182 render_html(source)
3183 }
3184
3185 #[wasm_bindgen(js_name = serializeMdi)]
3187 pub fn wasm_serialize_mdi(source: &str) -> String {
3188 serialize_mdi(source)
3189 }
3190
3191 #[wasm_bindgen(js_name = renderText)]
3193 pub fn wasm_render_text(source: &str) -> String {
3194 render_text(source)
3195 }
3196
3197 #[wasm_bindgen(js_name = renderTextFormat)]
3199 pub fn wasm_render_text_format(
3200 source: &str,
3201 format: &str,
3202 indent_prefix: &str,
3203 ) -> Result<String, JsValue> {
3204 let format = TextFormat::parse(format)
3205 .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
3206 Ok(render_text_format(source, format, indent_prefix))
3207 }
3208
3209 #[wasm_bindgen(js_name = renderEpub)]
3211 pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
3212 render_epub(source)
3213 .map(Vec::into_boxed_slice)
3214 .map_err(|message| JsValue::from_str(&message))
3215 }
3216
3217 #[wasm_bindgen(js_name = renderDocx)]
3219 pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
3220 render_docx(source)
3221 .map(Vec::into_boxed_slice)
3222 .map_err(|message| JsValue::from_str(&message))
3223 }
3224
3225 #[wasm_bindgen(js_name = unescapeMdi)]
3226 pub fn wasm_unescape_mdi(value: &str) -> String {
3227 unescape_mdi(value)
3228 }
3229
3230 #[wasm_bindgen(js_name = unescapeRubyText)]
3231 pub fn wasm_unescape_ruby(value: &str) -> String {
3232 unescape_ruby(value)
3233 }
3234
3235 #[wasm_bindgen(js_name = resolveRuby)]
3237 pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
3238 match split_ruby(base, raw_ruby) {
3239 RubyReading::Group(value) => JsValue::from_str(&value),
3240 RubyReading::Split(parts) => {
3241 let array = js_sys::Array::new();
3242 for part in parts {
3243 array.push(&JsValue::from_str(&part));
3244 }
3245 array.into()
3246 }
3247 }
3248 }
3249
3250 #[wasm_bindgen(js_name = blockMacroKind)]
3251 pub fn wasm_block_macro_kind(source: &str) -> String {
3252 match classify_block_macro(source) {
3253 BlockMacroClass::Indent(_) => "indent",
3254 BlockMacroClass::Bottom(_) => "bottom",
3255 BlockMacroClass::Pagebreak(_) => "pagebreak",
3256 BlockMacroClass::Literal => "literal",
3257 }
3258 .to_owned()
3259 }
3260
3261 #[wasm_bindgen(js_name = blockMacroAmount)]
3263 pub fn wasm_block_macro_amount(source: &str) -> i32 {
3264 match classify_block_macro(source) {
3265 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
3266 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
3267 }
3268 }
3269
3270 #[wasm_bindgen(js_name = blockMacroVariant)]
3272 pub fn wasm_block_macro_variant(source: &str) -> String {
3273 match classify_block_macro(source) {
3274 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
3275 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
3276 _ => "",
3277 }
3278 .to_owned()
3279 }
3280}
3281
3282#[cfg(test)]
3283mod tests {
3284 use super::*;
3285 use std::io::{Error, ErrorKind, Read, SeekFrom};
3286 use zip::ZipArchive;
3287
3288 struct FailAfterWrites {
3289 inner: Cursor<Vec<u8>>,
3290 remaining: usize,
3291 }
3292
3293 impl Write for FailAfterWrites {
3294 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
3295 if self.remaining == 0 {
3296 return Err(Error::new(
3297 ErrorKind::Other,
3298 "injected archive write failure",
3299 ));
3300 }
3301 self.remaining -= 1;
3302 self.inner.write(buffer)
3303 }
3304
3305 fn flush(&mut self) -> std::io::Result<()> {
3306 self.inner.flush()
3307 }
3308 }
3309
3310 impl Seek for FailAfterWrites {
3311 fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
3312 self.inner.seek(position)
3313 }
3314 }
3315
3316 fn assert_valid_spans(value: &serde_json::Value, source: &str) {
3317 let Some(object) = value.as_object() else {
3318 return;
3319 };
3320 if let Some(span) = object.get("span") {
3321 let start = span
3322 .get("startByte")
3323 .and_then(serde_json::Value::as_u64)
3324 .expect("span has startByte") as usize;
3325 let end = span
3326 .get("endByte")
3327 .and_then(serde_json::Value::as_u64)
3328 .expect("span has endByte") as usize;
3329 assert!(start <= end, "span start must not exceed end: {span}");
3330 assert!(
3331 end <= source.len(),
3332 "span must be inside its source: {span}"
3333 );
3334 assert!(
3335 source.is_char_boundary(start),
3336 "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
3337 );
3338 let remainder = &source[start..];
3339 assert!(
3340 source.is_char_boundary(end),
3341 "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
3342 remainder
3343 );
3344 }
3345 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
3346 for child in children {
3347 assert_valid_spans(child, source);
3348 }
3349 }
3350 }
3351
3352 fn generated_source(mut state: u64) -> String {
3353 if state == 0 {
3357 return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
3358 }
3359 const FRAGMENTS: &[&str] = &[
3360 "東京",
3361 "𠮟る",
3362 "👨👩👧",
3363 " ",
3364 "\\n",
3365 "\\\\",
3366 "{",
3367 "}",
3368 "|",
3369 ".",
3370 "^12^",
3371 "^_^",
3372 "{東京|とう.きょう}",
3373 "[[em:強調]]",
3374 "[[no-break:^12^]]",
3375 "[[kern:wide:x]]",
3376 "[[indent:2]]",
3377 "[[pagebreak:left]]",
3378 "《《傍点》》",
3379 "**strong**",
3380 "`^12^`",
3381 "[^n]",
3382 "\n\n",
3383 "| a | b |\n| - | - |\n| 1 | 2 |\n",
3384 "[link](https://example.test/?a=1&b=2)",
3385 "<script>x</script>",
3386 "\\{",
3387 "\\[",
3388 ];
3389 let mut source = String::new();
3390 for _ in 0..32 {
3391 state ^= state << 13;
3392 state ^= state >> 7;
3393 state ^= state << 17;
3394 source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
3395 }
3396 source
3397 }
3398
3399 #[test]
3400 fn parses_ruby_and_split_ruby() {
3401 assert_eq!(
3402 parse_inlines("{東京|とう.きょう}"),
3403 vec![Inline::Ruby {
3404 base: "東京".into(),
3405 ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
3406 }]
3407 );
3408 assert_eq!(
3409 parse_inlines("{東京|.きょう}"),
3410 vec![Inline::Ruby {
3411 base: "東京".into(),
3412 ruby: RubyReading::Group("きょう".into()),
3413 }]
3414 );
3415 }
3416
3417 #[test]
3418 fn parses_nested_macros_and_escapes() {
3419 assert_eq!(
3420 parse_inlines("[[em:●:a[[no-break:b]]]]"),
3421 vec![Inline::Em {
3422 mark: "●".into(),
3423 children: vec![
3424 Inline::Text("a".into()),
3425 Inline::NoBreak(vec![Inline::Text("b".into())])
3426 ],
3427 }]
3428 );
3429 }
3430
3431 #[test]
3432 fn parses_tcy_and_boten() {
3433 assert_eq!(
3434 parse_inlines("第^12^話《《重要》》"),
3435 vec![
3436 Inline::Text("第".into()),
3437 Inline::Tcy("12".into()),
3438 Inline::Text("話".into()),
3439 Inline::Em {
3440 mark: "﹅".into(),
3441 children: vec![Inline::Text("重要".into())]
3442 },
3443 ]
3444 );
3445 }
3446
3447 #[test]
3448 fn recognises_block_macros() {
3449 assert_eq!(
3450 parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
3451 MdiSyntaxDocument {
3452 blocks: vec![
3453 MdiBlock::Paragraph {
3454 inlines: vec![Inline::Text("本文".into())],
3455 indent: Some(2),
3456 bottom: None
3457 },
3458 MdiBlock::Pagebreak {
3459 variant: Some(PagebreakVariant::Left)
3460 },
3461 MdiBlock::Blank,
3462 ]
3463 }
3464 );
3465 }
3466
3467 #[test]
3468 fn keeps_invalid_syntax_literal() {
3469 assert_eq!(
3470 parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
3471 vec![Inline::Text(
3472 "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
3473 )]
3474 );
3475 }
3476
3477 #[test]
3478 fn recovers_from_malformed_inline_syntax_at_delimiter_boundaries() {
3479 for (source, expected) in [
3483 ("{東京|とうきょう", "{東京|とうきょう"),
3484 ("{東京|とうきょう ^12^", "{東京|とうきょう 12"),
3485 ("^1234567^ ^12^", "^1234567^ 12"),
3486 ("[[no-break:]][[em:強調]]", "[[no-break:]]強調"),
3487 ("[[kern:wide:字]][[warichu:注]]", "[[kern:wide:字]]注"),
3488 ("[[em:未閉", "[[em:未閉"),
3489 ("《《未閉", "《《未閉"),
3490 ("{", "{"),
3491 ("^", "^"),
3492 ("[[", "[["),
3493 ] {
3494 let rendered = render_text(source);
3495 assert_eq!(rendered.trim_end(), expected, "source: {source:?}");
3496 assert!(
3497 parse_output(source).diagnostics.is_empty(),
3498 "source: {source:?}"
3499 );
3500 }
3501 }
3502
3503 #[test]
3504 fn honors_escaped_alias_delimiters_without_consuming_them_as_closers() {
3505 assert_eq!(
3508 parse_inlines("《《a\\》》》"),
3509 vec![Inline::Em {
3510 mark: "﹅".into(),
3511 children: vec![Inline::Text("a》".into())],
3512 }]
3513 );
3514
3515 assert_eq!(
3518 parse_inlines("《《a\\》》"),
3519 vec![Inline::Text("《《a》》".into())]
3520 );
3521 }
3522
3523 #[test]
3524 fn applies_mdi_escapes_once_before_recognition() {
3525 assert_eq!(
3526 parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
3527 vec![Inline::Text(
3528 "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
3529 )]
3530 );
3531 }
3532
3533 #[test]
3534 fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
3535 assert_eq!(
3536 parse_inlines(r"\\ \n \a \0 \-"),
3537 vec![Inline::Text(r"\ \n \a \0 \-".into())]
3538 );
3539 }
3540
3541 #[test]
3542 fn treats_boten_alias_content_as_plain_text() {
3543 assert_eq!(
3544 parse_inlines(r"《《a\《b》》"),
3545 vec![Inline::Em {
3546 mark: "﹅".into(),
3547 children: vec![Inline::Text("a《b".into())]
3548 }]
3549 );
3550 assert_eq!(
3551 parse_inlines("《《雪》考》"),
3552 vec![Inline::Text("《《雪》考》".into())]
3553 );
3554 }
3555
3556 #[test]
3557 fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
3558 assert_eq!(
3559 parse_inlines("[[em:ab:cd]]"),
3560 vec![Inline::Em {
3561 mark: "﹅".into(),
3562 children: vec![Inline::Text("ab:cd".into())]
3563 }]
3564 );
3565 }
3566
3567 #[test]
3568 fn counts_extended_graphemes_for_split_ruby() {
3569 assert_eq!(
3570 parse_inlines("{𠮟る|しか.る}"),
3571 vec![Inline::Ruby {
3572 base: "𠮟る".into(),
3573 ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
3574 }]
3575 );
3576 assert_eq!(
3577 parse_inlines("{👨👩👧|かぞく}"),
3578 vec![Inline::Ruby {
3579 base: "👨👩👧".into(),
3580 ruby: RubyReading::Group("かぞく".into())
3581 }]
3582 );
3583 }
3584
3585 #[test]
3586 fn leaves_unattached_or_stacked_block_macros_literal() {
3587 assert_eq!(
3588 parse_mdi_syntax("[[indent:2]]"),
3589 MdiSyntaxDocument {
3590 blocks: vec![MdiBlock::Paragraph {
3591 inlines: vec![Inline::Text("[[indent:2]]".into())],
3592 indent: None,
3593 bottom: None
3594 }]
3595 }
3596 );
3597 assert_eq!(
3598 parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
3599 MdiSyntaxDocument {
3600 blocks: vec![
3601 MdiBlock::Paragraph {
3602 inlines: vec![Inline::Text("[[indent:2]]".into())],
3603 indent: None,
3604 bottom: None
3605 },
3606 MdiBlock::Paragraph {
3607 inlines: vec![Inline::Text("[[bottom]]".into())],
3608 indent: None,
3609 bottom: None
3610 },
3611 MdiBlock::Paragraph {
3612 inlines: vec![Inline::Text("本文".into())],
3613 indent: None,
3614 bottom: None
3615 }
3616 ]
3617 }
3618 );
3619 }
3620
3621 #[test]
3622 fn serializes_the_versioned_binding_contract() {
3623 let value: serde_json::Value =
3624 serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
3625 .expect("parse output is valid JSON");
3626
3627 assert_eq!(value["irVersion"], "1.0");
3628 assert_eq!(value["syntaxVersion"], "2.0");
3629 assert_eq!(value["capabilities"]["mdi"], true);
3630 assert_eq!(value["capabilities"]["commonMark"], true);
3631 assert_eq!(value["capabilities"]["gfm"], true);
3632 assert_eq!(value["capabilities"]["frontMatter"], true);
3633 assert_eq!(value["capabilities"]["sourceSpans"], true);
3634 assert_eq!(value["diagnostics"], serde_json::json!([]));
3635 assert_eq!(value["document"]["children"][0]["type"], "paragraph");
3636 assert_eq!(
3637 value["document"]["children"][0]["children"][1]["type"],
3638 "tcy"
3639 );
3640 assert_eq!(value["document"]["span"]["endByte"], 43);
3641 }
3642
3643 #[test]
3644 fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
3645 let document = parse_document(
3646 "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
3647 );
3648 assert_eq!(
3649 document.frontmatter.as_ref().unwrap().entries[0].key,
3650 "title"
3651 );
3652 assert_eq!(document.children[0]["type"], "heading");
3653 assert_eq!(document.children[1]["type"], "list");
3654 assert_eq!(document.children[2]["type"], "table");
3655 assert_eq!(document.children[0]["span"]["startByte"], 43);
3656 }
3657
3658 #[test]
3659 fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
3660 let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
3661 assert_eq!(document.children[0]["type"], "blockquote");
3662 assert!(document.children.iter().any(|node| node["type"] == "code"));
3663 }
3664
3665 #[test]
3666 fn lowers_root_flow_markers_without_a_host_markdown_parser() {
3667 let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
3668 assert_eq!(document.children[0]["type"], "paragraph");
3669 assert_eq!(document.children[0]["indent"], 2);
3670 assert_eq!(document.children[1]["type"], "pagebreak");
3671 assert_eq!(document.children[1]["variant"], "right");
3672 assert_eq!(document.children[2]["type"], "blank");
3673 }
3674
3675 #[test]
3676 fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
3677 let document = parse_document("[[em:**重要**]]");
3678 let em = &document.children[0]["children"][0];
3679 assert_eq!(em["type"], "em");
3680 assert_eq!(em["mark"], "﹅");
3681 assert_eq!(em["children"][0]["type"], "strong");
3682 assert_eq!(em["children"][0]["children"][0]["value"], "重要");
3683 }
3684
3685 #[test]
3686 fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
3687 assert!(
3688 markdown_paragraph_children(
3689 "前 [[em:**重要**]] 後",
3690 &serde_json::json!({"startByte": 0, "endByte": 26})
3691 )
3692 .is_some()
3693 );
3694 let document = parse_document("前 [[em:**重要**]] 後");
3695 let children = &document.children[0]["children"];
3696 assert_eq!(children[0]["value"], "前");
3697 assert_eq!(children[1]["value"], " ");
3698 assert_eq!(children[2]["type"], "em");
3699 assert_eq!(children[2]["children"][0]["type"], "strong");
3700 assert_eq!(children[3]["value"], " ");
3701 assert_eq!(children[4]["value"], "後");
3702 assert_eq!(children[2]["span"]["startByte"], 4);
3703 assert_eq!(children[2]["span"]["endByte"], 21);
3704 assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
3705 assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
3706 }
3707
3708 #[test]
3709 fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
3710 let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
3711 let children = &document.children[0]["children"][0]["children"];
3712 assert_eq!(children[0]["type"], "ruby");
3713 assert_eq!(children[1]["type"], "noBreak");
3714 assert_eq!(children[1]["children"][0]["type"], "tcy");
3715 }
3716
3717 #[test]
3718 fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
3719 let document =
3720 parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
3721 let paragraph = &document.children[1];
3722 assert_eq!(paragraph["span"]["startByte"], 7);
3723 assert_eq!(paragraph["children"][0]["type"], "ruby");
3724 assert_eq!(paragraph["children"][0]["base"], "東京");
3725 assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
3726 assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
3727 assert_eq!(paragraph["children"][2]["type"], "em");
3728 assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
3729 assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
3730 assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
3731 assert_eq!(paragraph["children"][4]["identifier"], "n");
3732 }
3733
3734 #[test]
3735 fn renders_a_standalone_html_document_from_rust_ir() {
3736 let html = render_html(
3737 "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
3738 );
3739 assert!(html.starts_with("<!DOCTYPE html>"));
3740 assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
3741 assert!(html.contains("<title>雪女</title>"));
3742 assert!(html.contains("<h1>題</h1>"));
3743 assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
3744 assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
3745 }
3746
3747 #[test]
3748 fn renders_every_documented_mdi_construct_in_vertical_html() {
3749 let vertical = "---\ntitle: 構文\nlang: ja\nwriting-mode: vertical\n---\n\n";
3750 for (name, source, expected) in [
3751 (
3752 "front matter",
3753 "# 見出し",
3754 "<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">",
3755 ),
3756 (
3757 "group ruby",
3758 "{東京|とうきょう}",
3759 "<ruby class=\"mdi-ruby\">東京",
3760 ),
3761 (
3762 "split ruby",
3763 "{東京|とう.きょう}",
3764 "<ruby class=\"mdi-ruby\"",
3765 ),
3766 ("tate-chu-yoko", "^12^", "<span class=\"mdi-tcy\">12</span>"),
3767 ("default boten", "[[em:傍点]]", "class=\"mdi-em\""),
3768 ("custom boten", "[[em:※:任意]]", "--mdi-em:"※""),
3769 ("no-break", "[[no-break:改行禁止]]", "class=\"mdi-nobr\""),
3770 ("explicit line break", "前[[br]]次", "<br>"),
3771 ("blank backslash", "\\", "<p class=\"mdi-blank\"></p>"),
3772 ("blank br", "<br>", "<p class=\"mdi-blank\"></p>"),
3773 ("blank br slash", "<br />", "<p class=\"mdi-blank\"></p>"),
3774 (
3775 "blank legacy macro",
3776 "[[blank]]",
3777 "<p class=\"mdi-blank\"></p>",
3778 ),
3779 ("warichu", "[[warichu:割注]]", "class=\"mdi-warichu\""),
3780 ("kerning", "[[kern:-0.1em:詰め]]", "--mdi-kern:-0.1em"),
3781 ("indent", "[[indent:2]]\n字下げ", "class=\"mdi-indent\""),
3782 (
3783 "bottom alignment",
3784 "[[bottom]]\n地付き",
3785 "class=\"mdi-bottom\"",
3786 ),
3787 ("bottom shift", "[[bottom:2]]\n地付き", "--mdi-shift:2"),
3788 ("page break", "[[pagebreak]]", "class=\"mdi-pagebreak\""),
3789 (
3790 "recto page break",
3791 "[[pagebreak:right]]",
3792 "class=\"mdi-pagebreak mdi-pagebreak-right\"",
3793 ),
3794 (
3795 "verso page break",
3796 "[[pagebreak:left]]",
3797 "class=\"mdi-pagebreak mdi-pagebreak-left\"",
3798 ),
3799 ("footnote", "脚注[^n]\n\n[^n]: 注", "data-footnotes"),
3800 (
3801 "escaped delimiters",
3802 "\\{ \\} \\| \\^ \\[ \\: \\《 \\》",
3803 "{ } | ^ [ : 《 》",
3804 ),
3805 ] {
3806 let html = render_html(&format!("{vertical}{source}"));
3807 assert!(
3808 html.contains(expected),
3809 "missing {name}: {expected}; rendered {html}"
3810 );
3811 }
3812 assert!(render_html(&format!("{vertical}\\")).contains(".mdi-blank{min-block-size:1lh}"));
3813 }
3814
3815 #[test]
3816 fn renders_footnote_definitions_in_html() {
3817 let html = render_html("本文[^n]\n\n[^n]: 注の本文");
3818 assert!(html.contains("data-footnotes"));
3819 assert!(html.contains("id=\"user-content-fn-n\""));
3820 assert!(html.contains("注の本文"));
3821 }
3822
3823 #[test]
3824 fn escapes_raw_html_in_the_rust_renderer() {
3825 let html = render_html("<script>alert(1)</script>");
3826 assert!(html.contains("<script>alert(1)</script>"));
3827 assert!(!html.contains("<script>alert(1)</script>"));
3828 }
3829
3830 #[test]
3831 fn serializes_mdi_from_rust_ir() {
3832 let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
3833 assert_eq!(
3834 serialize_mdi(source),
3835 "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
3836 );
3837 }
3838
3839 #[test]
3840 fn renders_plain_text_from_rust_ir() {
3841 assert_eq!(
3842 render_text("# 題\n\n{東京|とうきょう} ^12^"),
3843 "題\n東京 12\n"
3844 );
3845 }
3846
3847 #[test]
3848 fn renders_platform_text_formats_from_rust_ir() {
3849 let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
3850 assert_eq!(
3851 render_text_format(source, TextFormat::Plain, ""),
3852 "題\n東京強調。"
3853 );
3854 assert_eq!(
3855 render_text_format(source, TextFormat::Ruby, ""),
3856 "題\n{東京|とう.きょう}強調。"
3857 );
3858 assert_eq!(
3859 render_text_format(source, TextFormat::Kakuyomu, ""),
3860 "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
3861 );
3862 assert!(
3863 render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は中見出し]")
3864 );
3865 }
3866
3867 #[test]
3868 fn renders_and_serializes_every_public_inline_and_block_variant() {
3869 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";
3870
3871 let html = render_html(source);
3872 for expected in [
3873 "<h2>中見出し</h2>",
3874 "<h3>小見出し</h3>",
3875 "<blockquote><p>引用</p>",
3876 "<ol>",
3877 "<ul>",
3878 "<pre><code class=\"language-rust\">",
3879 "<hr>",
3880 "<table>",
3881 "<a href=\"https://example.test\" title=\"題\">リンク</a>",
3882 "<img src=\"image.png\" alt=\"画像\">",
3883 "<del>削除</del>",
3884 "<code>code</code>",
3885 "<br>",
3886 "mdi-warichu",
3887 "mdi-kern",
3888 "--mdi-em:"●"",
3889 ] {
3890 assert!(html.contains(expected), "HTML contains {expected}");
3891 }
3892
3893 let canonical = serialize_mdi(source);
3894 for expected in [
3895 "[[bottom]]",
3896 "## 中見出し",
3897 "> 引用",
3898 "1. 一",
3899 "- 箇条",
3900 "```rust",
3901 "| 見出し | 値 |",
3902 "[リンク](https://example.test \\題\")",
3903 "",
3904 "~~削除~~",
3905 "`code`",
3906 "[[br]]",
3907 "[[warichu:割書]]",
3908 "[[kern:1em:字]]",
3909 "[[em:●:傍点]]",
3910 ] {
3911 assert!(
3912 canonical.contains(expected),
3913 "canonical MDI contains {expected}"
3914 );
3915 }
3916
3917 let plain = render_text(source);
3918 assert!(plain.contains("画像"));
3919 assert!(plain.contains("巢狀"));
3920
3921 for (name, format) in [
3922 ("txt", TextFormat::Plain),
3923 ("txt-ruby", TextFormat::Ruby),
3924 ("narou", TextFormat::Narou),
3925 ("kakuyomu", TextFormat::Kakuyomu),
3926 ("aozora", TextFormat::Aozora),
3927 ] {
3928 assert_eq!(TextFormat::parse(name), Some(format));
3929 assert!(!render_text_format(source, format, " ").is_empty());
3930 }
3931 assert_eq!(TextFormat::parse("unknown"), None);
3932 }
3933
3934 #[test]
3935 fn packages_an_epub_from_rust_ir() {
3936 let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
3937 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3938 let mut mimetype = String::new();
3939 zip.by_name("mimetype")
3940 .unwrap()
3941 .read_to_string(&mut mimetype)
3942 .unwrap();
3943 assert_eq!(mimetype, "application/epub+zip");
3944 let mut opf = String::new();
3945 zip.by_name("OEBPS/package.opf")
3946 .unwrap()
3947 .read_to_string(&mut opf)
3948 .unwrap();
3949 assert!(opf.contains("<dc:title>Test</dc:title>"));
3950 assert!(opf.contains("page-progression-direction=\"rtl\""));
3951 assert!(opf.contains("chapter-2.xhtml"));
3952 }
3953
3954 #[test]
3955 fn packages_a_docx_from_rust_ir() {
3956 let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
3957 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3958 let mut document = String::new();
3959 zip.by_name("word/document.xml")
3960 .unwrap()
3961 .read_to_string(&mut document)
3962 .unwrap();
3963 assert!(document.contains("題"));
3964 assert!(document.contains("東京"));
3965 let mut core = String::new();
3966 zip.by_name("docProps/core.xml")
3967 .unwrap()
3968 .read_to_string(&mut core)
3969 .unwrap();
3970 assert!(core.contains("<dc:title>Test</dc:title>"));
3971 }
3972
3973 #[test]
3974 fn renders_pdf_with_an_available_native_chromium() {
3975 let Some(chromium_path) = find_chromium() else {
3976 return;
3977 };
3978 let pdf = render_pdf(
3979 "# 題\n\n{東京|とうきょう}",
3980 &PdfOptions {
3981 chromium_path: Some(chromium_path),
3982 },
3983 )
3984 .unwrap();
3985 assert!(pdf.starts_with(b"%PDF-"));
3986 }
3987
3988 #[test]
3989 fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
3990 for seed in 0..256 {
3991 let source = generated_source(seed);
3992 let output = parse_output(&source);
3993 assert_eq!(output.document.span.start_byte, 0);
3994 assert_eq!(output.document.span.end_byte as usize, source.len());
3995 for child in &output.document.children {
3996 assert_valid_spans(child, &source);
3997 }
3998 for diagnostic in &output.diagnostics {
3999 let span = diagnostic
4000 .span
4001 .expect("parser diagnostics have source spans");
4002 assert!(span.start_byte <= span.end_byte);
4003 assert!((span.end_byte as usize) <= source.len());
4004 }
4005
4006 let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
4007 .expect("every UTF-8 input has a serializable wire result");
4008 assert_eq!(wire["irVersion"], MDI_IR_VERSION);
4009 assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
4010 assert_valid_spans(&wire["document"], &source);
4011
4012 assert!(!render_html_document(&output.document).is_empty());
4016 let canonical = serialize_mdi_document(&output.document);
4017 let reparsed = parse_document(&canonical);
4018 for child in &reparsed.children {
4019 assert_valid_spans(child, &canonical);
4020 }
4021 let _ = render_text_document(&reparsed);
4022 }
4023 }
4024
4025 #[test]
4026 fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
4027 let cases = [
4028 "",
4029 "plain\n",
4030 "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
4031 "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
4032 "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
4033 "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
4034 "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
4035 ];
4036 for source in cases {
4037 let first = serialize_mdi(source);
4038 let second = serialize_mdi(&first);
4039 assert_eq!(
4040 second, first,
4041 "canonical output must stabilize for {source:?}"
4042 );
4043 assert_valid_spans(
4044 &serde_json::to_value(parse_document(&first)).unwrap(),
4045 &first,
4046 );
4047 }
4048 }
4049
4050 #[test]
4051 fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
4052 let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
4053
4054 let epub = render_epub(source).unwrap();
4055 let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
4056 assert_eq!(
4057 epub.by_name("mimetype").unwrap().compression(),
4058 CompressionMethod::Stored,
4059 "EPUB requires its mimetype member to be uncompressed"
4060 );
4061 for path in [
4062 "META-INF/container.xml",
4063 "OEBPS/package.opf",
4064 "OEBPS/nav.xhtml",
4065 "OEBPS/style.css",
4066 "OEBPS/chapter-1.xhtml",
4067 "OEBPS/chapter-2.xhtml",
4068 ] {
4069 assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
4070 }
4071 let mut opf = String::new();
4072 epub.by_name("OEBPS/package.opf")
4073 .unwrap()
4074 .read_to_string(&mut opf)
4075 .unwrap();
4076 assert!(opf.contains("A & < B "quoted""));
4077 assert!(opf.contains("O'Brien & Co."));
4078
4079 let docx = render_docx(source).unwrap();
4080 let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
4081 for path in [
4082 "[Content_Types].xml",
4083 "_rels/.rels",
4084 "docProps/core.xml",
4085 "word/document.xml",
4086 ] {
4087 assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
4088 }
4089 let mut core = String::new();
4090 docx.by_name("docProps/core.xml")
4091 .unwrap()
4092 .read_to_string(&mut core)
4093 .unwrap();
4094 assert!(core.contains("A & < B "quoted""));
4095 let mut document = String::new();
4096 docx.by_name("word/document.xml")
4097 .unwrap()
4098 .read_to_string(&mut document)
4099 .unwrap();
4100 assert!(document.contains("<unsafe>&"));
4101 }
4102
4103 #[test]
4104 fn classifies_every_legacy_block_macro_shape() {
4105 let amount = |value| match classify_block_macro(value) {
4106 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
4107 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
4108 };
4109 assert_eq!(amount(" [[indent:12]] "), Some(12));
4110 assert_eq!(amount("[[bottom]]"), Some(0));
4111 assert_eq!(amount("[[bottom:3]]"), Some(3));
4112 assert_eq!(amount("literal"), None);
4113 assert!(matches!(
4114 classify_block_macro("[[pagebreak]]"),
4115 BlockMacroClass::Pagebreak(None)
4116 ));
4117 assert!(matches!(
4118 classify_block_macro("[[pagebreak:left]]"),
4119 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
4120 ));
4121 assert!(matches!(
4122 classify_block_macro("[[pagebreak:right]]"),
4123 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
4124 ));
4125 for literal in [
4126 "text",
4127 "[[unknown:1]]",
4128 "[[indent:]]",
4129 "[[indent:0]]",
4130 "[[indent:01]]",
4131 "[[indent:x]]",
4132 "[[indent:999999999999999999999999999999]]",
4133 ] {
4134 assert!(matches!(
4135 classify_block_macro(literal),
4136 BlockMacroClass::Literal
4137 ));
4138 }
4139 }
4140
4141 #[test]
4142 fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
4143 let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
4144 assert_eq!(newer.diagnostics.len(), 1);
4145 assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
4146 assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
4147 assert_eq!(
4148 newer.diagnostics[0].span,
4149 newer.document.frontmatter.map(|f| f.span)
4150 );
4151
4152 for source in [
4153 "---\nmdi: '2.0'\n---\n",
4154 "---\nmdi: 3\n---\n",
4155 "---\n- sequence\n- values\n---\n",
4156 "---\n[malformed\n---\n",
4157 ] {
4158 let output = parse_output(source);
4159 assert!(output.diagnostics.is_empty());
4160 assert!(output.document.frontmatter.is_some());
4161 }
4162 }
4163
4164 #[test]
4165 fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
4166 let missing =
4167 std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
4168 let error = render_pdf(
4169 "text",
4170 &PdfOptions {
4171 chromium_path: Some(missing),
4172 },
4173 )
4174 .unwrap_err();
4175 assert!(error.contains("failed to start Chromium"));
4176
4177 let current_exe = std::env::current_exe().unwrap();
4178 let error = render_pdf(
4179 "text",
4180 &PdfOptions {
4181 chromium_path: Some(current_exe),
4182 },
4183 )
4184 .unwrap_err();
4185 assert!(error.contains("Chromium PDF rendering failed"));
4186 }
4187
4188 #[test]
4189 fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
4190 let mut scalar = serde_json::json!(null);
4191 lower_markdown_inside_mdi(&mut scalar, "");
4192 shift_spans(&mut scalar, 10);
4193 annotate_and_lower(&mut scalar, "", false);
4194 inject_block_markers(&mut scalar, &[]);
4195 assert_valid_spans(&scalar, "");
4196
4197 let mut paragraph_with_invalid_span = serde_json::json!({
4198 "type": "paragraph",
4199 "span": {"startByte": 2, "endByte": 3}
4200 });
4201 lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
4202
4203 let mut text_without_value = serde_json::json!({
4204 "type": "text",
4205 "position": {"start": {"offset": 0}, "end": {"offset": 0}}
4206 });
4207 annotate_and_lower(&mut text_without_value, "", false);
4208 let mut text_without_position = serde_json::json!({
4209 "type": "text",
4210 "value": "^12^"
4211 });
4212 annotate_and_lower(&mut text_without_position, "^12^", false);
4213 assert_eq!(text_without_position["children"][0]["type"], "tcy");
4214 let mut text_with_partial_span = serde_json::json!({
4215 "type": "text",
4216 "value": "^12^",
4217 "span": {}
4218 });
4219 annotate_and_lower(&mut text_with_partial_span, "^12^", false);
4220 assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
4221
4222 assert!(markdown_macro_children("not-a-macro", 0).is_none());
4223 assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
4224
4225 let span = SourceSpan {
4226 start_byte: 0,
4227 end_byte: 10,
4228 };
4229 for (is_indent, amount, expected) in [
4230 (true, 2, "[[indent:2]]"),
4231 (false, 0, "[[bottom]]"),
4232 (false, 3, "[[bottom:3]]"),
4233 ] {
4234 let mut output = Vec::new();
4235 let mut pending = Some((span, is_indent, amount));
4236 flush_pending_literal(&mut output, &mut pending);
4237 assert_eq!(output[0]["children"][0]["value"], expected);
4238 }
4239
4240 assert!(matches!(
4241 paragraph(
4242 "text",
4243 Some(PendingBlock::Bottom {
4244 amount: 2,
4245 source: "[[bottom:2]]".to_owned()
4246 })
4247 ),
4248 MdiBlock::Paragraph {
4249 bottom: Some(2),
4250 ..
4251 }
4252 ));
4253 assert!(pending_block("[[unknown:2]]").is_none());
4254 assert!(boten("《《》》").is_none());
4255 assert!(!valid_kern("1.em"));
4256 assert!(!valid_kern("1.2.3em"));
4257 assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
4258
4259 let mut writer = FailAfterWrites {
4260 inner: Cursor::new(Vec::new()),
4261 remaining: 1,
4262 };
4263 writer.flush().unwrap();
4264
4265 let mut formatted = Vec::new();
4266 text_format_block(
4267 &serde_json::json!({"type": "blank"}),
4268 TextFormat::Plain,
4269 "",
4270 &[],
4271 &[],
4272 &mut formatted,
4273 );
4274 text_format_block(
4275 &serde_json::json!({"type": "unknown"}),
4276 TextFormat::Plain,
4277 "",
4278 &[],
4279 &[],
4280 &mut formatted,
4281 );
4282 assert_eq!(formatted, vec![String::new()]);
4283
4284 assert_eq!(
4285 text_format_inline(
4286 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
4287 TextFormat::Ruby,
4288 &[]
4289 ),
4290 "{字|じ}"
4291 );
4292 assert_eq!(
4293 text_format_inline(
4294 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
4295 TextFormat::Plain,
4296 &[]
4297 ),
4298 "字"
4299 );
4300 assert_eq!(
4301 text_format_inline(
4302 &serde_json::json!({"type":"image", "alt":""}),
4303 TextFormat::Plain,
4304 &[]
4305 ),
4306 "[画像]"
4307 );
4308
4309 let mut html = String::new();
4310 render_html_node(&serde_json::json!({}), &mut html);
4311 render_html_node(
4312 &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
4313 &mut html,
4314 );
4315 render_html_children(&serde_json::json!({"type":"root"}), &mut html);
4316 assert_eq!(html, "ok");
4317
4318 let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
4319 assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
4320 let heading = parse_document("[[indent:2]]\n# heading");
4321 assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
4322 let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
4323 assert_eq!(trailing.children.len(), 2);
4324
4325 let partial_document = Document {
4326 span: SourceSpan::default(),
4327 frontmatter: None,
4328 children: vec![serde_json::json!({
4329 "type": "root",
4330 "children": [{"type":"text", "value":"partial"}]
4331 })],
4332 };
4333 assert!(render_docx_document(&partial_document).is_ok());
4334 }
4335
4336 #[test]
4337 fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
4338 let empty = render_epub("").unwrap();
4339 let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
4340 assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
4341
4342 let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
4343 let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
4344 assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
4345 }
4346
4347 #[test]
4348 fn archive_writers_propagate_failures_from_every_write_stage() {
4349 let document = parse_document(
4350 "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
4351 );
4352
4353 let mut epub_errors = 0;
4354 let mut epub_success = false;
4355 for remaining in 0..256 {
4356 let writer = FailAfterWrites {
4357 inner: Cursor::new(Vec::new()),
4358 remaining,
4359 };
4360 let mut zip = ZipWriter::new(writer);
4361 let result = write_epub_document(&document, &mut zip);
4362 if result.is_err() {
4363 epub_errors += 1;
4364 } else {
4365 epub_success = true;
4366 }
4367 std::mem::forget(zip);
4371 if epub_success {
4372 break;
4373 }
4374 }
4375 assert!(epub_errors > 0);
4376 assert!(epub_success);
4377
4378 let mut docx_errors = 0;
4379 let mut docx_success = false;
4380 for remaining in 0..256 {
4381 let writer = FailAfterWrites {
4382 inner: Cursor::new(Vec::new()),
4383 remaining,
4384 };
4385 let mut zip = ZipWriter::new(writer);
4386 let result = write_docx_document(&document, &mut zip);
4387 if result.is_err() {
4388 docx_errors += 1;
4389 } else {
4390 docx_success = true;
4391 }
4392 std::mem::forget(zip);
4393 if docx_success {
4394 break;
4395 }
4396 }
4397 assert!(docx_errors > 0);
4398 assert!(docx_success);
4399 }
4400
4401 #[allow(unsafe_code)]
4402 fn ffi_bytes(result: ffi::MdiFfiResult) -> Result<Vec<u8>, String> {
4403 let value = if result.value.len == 0 {
4404 Vec::new()
4405 } else {
4406 unsafe { std::slice::from_raw_parts(result.value.data, result.value.len).to_vec() }
4407 };
4408 let error = if result.error.len == 0 {
4409 None
4410 } else {
4411 Some(unsafe {
4412 std::str::from_utf8(std::slice::from_raw_parts(
4413 result.error.data,
4414 result.error.len,
4415 ))
4416 .unwrap()
4417 .to_owned()
4418 })
4419 };
4420 unsafe {
4421 ffi::mdi_free_buffer(result.value);
4422 ffi::mdi_free_buffer(result.error);
4423 }
4424 error.map_or(Ok(value), Err)
4425 }
4426
4427 #[test]
4428 #[allow(unsafe_code)]
4429 fn c_abi_returns_owned_versioned_wire_data_for_every_export() {
4430 let source = "{東京|とうきょう} ^12^";
4431 let json = String::from_utf8(
4432 ffi_bytes(ffi::mdi_parse_json(source.as_ptr(), source.len())).unwrap(),
4433 )
4434 .unwrap();
4435 assert!(json.contains("\"irVersion\":\"1.0\""));
4436 assert!(json.contains("\"type\":\"ruby\""));
4437
4438 let html = String::from_utf8(
4439 ffi_bytes(ffi::mdi_render_html(source.as_ptr(), source.len())).unwrap(),
4440 )
4441 .unwrap();
4442 assert!(html.contains("<ruby class=\"mdi-ruby\">東京"));
4443 assert_eq!(
4444 String::from_utf8(
4445 ffi_bytes(ffi::mdi_serialize_mdi(source.as_ptr(), source.len())).unwrap()
4446 )
4447 .unwrap(),
4448 "{東京|とうきょう} ^12^\n"
4449 );
4450 assert_eq!(
4451 String::from_utf8(
4452 ffi_bytes(ffi::mdi_render_text(source.as_ptr(), source.len())).unwrap()
4453 )
4454 .unwrap(),
4455 "東京 12\n"
4456 );
4457 assert!(
4458 ffi_bytes(ffi::mdi_render_epub(source.as_ptr(), source.len()))
4459 .unwrap()
4460 .starts_with(b"PK")
4461 );
4462 assert!(
4463 ffi_bytes(ffi::mdi_render_docx(source.as_ptr(), source.len()))
4464 .unwrap()
4465 .starts_with(b"PK")
4466 );
4467
4468 assert!(
4469 ffi_bytes(ffi::mdi_render_text(std::ptr::null(), 0))
4470 .unwrap()
4471 .is_empty()
4472 );
4473 let invalid_utf8 = [0xff];
4474 assert_eq!(
4475 ffi_bytes(ffi::mdi_render_html(
4476 invalid_utf8.as_ptr(),
4477 invalid_utf8.len()
4478 ))
4479 .unwrap_err(),
4480 "MDI source must be valid UTF-8"
4481 );
4482
4483 assert_eq!(
4484 ffi_bytes(ffi::mdi_parse_json(std::ptr::null(), 1)).unwrap_err(),
4485 "MDI source pointer is null"
4486 );
4487 assert_eq!(
4488 ffi_bytes(ffi::mdi_render_epub(std::ptr::null(), 1)).unwrap_err(),
4489 "MDI source pointer is null"
4490 );
4491 }
4492}