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 parsed.len() == 1 && matches!(parsed.first(), Some((Inline::Text(_), _, _))) {
822 return;
823 }
824 let replacement: Vec<serde_json::Value> = parsed
825 .into_iter()
826 .map(|(inline, start, end)| {
827 let mut value = serde_json::to_value(inline).expect("MDI inline is serializable");
828 if let (Some(token_span), Some(object)) = (&span, value.as_object_mut()) {
829 let start_byte = token_span
830 .get("startByte")
831 .and_then(serde_json::Value::as_u64);
832 if let Some(start_byte) = start_byte {
833 let start = source_offsets
834 .as_ref()
835 .and_then(|offsets| source_offset(offsets, start))
836 .unwrap_or(start);
837 let end = source_offsets
838 .as_ref()
839 .and_then(|offsets| source_offset(offsets, end))
840 .unwrap_or(end);
841 object.insert(
842 "span".to_owned(),
843 serde_json::json!(SourceSpan {
844 start_byte: (start_byte as usize + start) as u32,
845 end_byte: (start_byte as usize + end) as u32,
846 }),
847 );
848 }
849 }
850 value
851 })
852 .collect();
853 *node = serde_json::json!({ "type": "mdiFragment", "children": replacement, "span": span });
854}
855
856fn looks_like_mdi(value: &str) -> bool {
857 value.contains(['{', '^', '《', '[', '\\'])
858}
859
860fn span_from_position(value: &serde_json::Value, source: &str) -> Option<SourceSpan> {
861 let start = value.pointer("/start/offset")?.as_u64()? as usize;
862 let end = value.pointer("/end/offset")?.as_u64()? as usize;
863 Some(SourceSpan {
864 start_byte: character_offset_to_byte(source, start) as u32,
865 end_byte: character_offset_to_byte(source, end) as u32,
866 })
867}
868
869fn character_offset_to_byte(source: &str, offset: usize) -> usize {
870 offset.min(source.len())
872}
873
874fn extract_frontmatter(root: &serde_json::Value, source: &str) -> Option<Frontmatter> {
875 let yaml = root.get("children")?.as_array()?.first()?;
876 if yaml.get("type")?.as_str()? != "yaml" {
877 return None;
878 }
879 let raw = yaml.get("value")?.as_str()?.to_owned();
880 let span = yaml
881 .get("position")
882 .and_then(|value| span_from_position(value, source))?;
883 let entries = match serde_yaml::from_str::<serde_yaml::Value>(&raw) {
884 Ok(serde_yaml::Value::Mapping(mapping)) => mapping
885 .into_iter()
886 .filter_map(|(key, value)| {
887 let key = key.as_str()?.to_owned();
888 let value = serde_json::to_value(value).ok()?;
889 Some(FrontmatterEntry { key, value })
890 })
891 .collect(),
892 _ => Vec::new(),
893 };
894 Some(Frontmatter { span, raw, entries })
895}
896
897fn diagnostics(document: &Document) -> Vec<Diagnostic> {
898 let Some(frontmatter) = document.frontmatter.as_ref() else {
899 return Vec::new();
900 };
901 let declared = frontmatter.entries.iter().find(|entry| entry.key == "mdi");
902 let Some(declared) = declared.and_then(|entry| entry.value.as_str()) else {
903 return Vec::new();
904 };
905 if declared > MDI_SPEC_VERSION {
906 vec![Diagnostic {
907 severity: DiagnosticSeverity::Warning,
908 code: "mdi.version.unsupported".to_owned(),
909 message: format!("MDI {declared} is newer than the supported {MDI_SPEC_VERSION}"),
910 span: Some(frontmatter.span),
911 }]
912 } else {
913 Vec::new()
914 }
915}
916
917pub fn parse_output(source: &str) -> ParseOutput {
920 let document = parse_document(source);
921 ParseOutput {
922 ir_version: MDI_IR_VERSION,
923 syntax_version: MDI_SPEC_VERSION,
924 capabilities: ParserCapabilities {
925 mdi: true,
926 common_mark: true,
927 gfm: true,
928 front_matter: true,
929 source_spans: true,
930 },
931 diagnostics: diagnostics(&document),
932 document,
933 }
934}
935
936pub fn parse_json(source: &str) -> String {
942 serde_json::to_string(&parse_output(source))
943 .expect("serializing the MDI parse output cannot fail")
944}
945
946pub fn render_html(source: &str) -> String {
951 render_html_document(&parse_document(source))
952}
953
954pub fn render_html_document(document: &Document) -> String {
956 let frontmatter = document.frontmatter.as_ref();
957 let field = |key: &str| {
958 frontmatter
959 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
960 .and_then(|entry| entry.value.as_str())
961 };
962 let lang = field("lang").unwrap_or("ja");
963 let title = field("title")
964 .map(|title| format!("<title>{}</title>", escape_html(title)))
965 .unwrap_or_default();
966 let vertical = matches!(field("writing-mode"), Some("vertical"));
967 let writing_mode = if vertical {
968 " style=\"writing-mode: vertical-rl;\""
969 } else {
970 ""
971 };
972 let mut body = String::new();
973 let mut footnotes = Vec::new();
974 for child in &document.children {
975 if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
976 footnotes.push(child);
977 } else {
978 render_html_node(child, &mut body);
979 }
980 }
981 if !footnotes.is_empty() {
982 body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\">Footnotes</h2><ol>");
983 for (index, footnote) in footnotes.into_iter().enumerate() {
984 body.push_str("<li id=\"user-content-fn-");
985 body.push_str(&escape_html(
986 footnote
987 .get("identifier")
988 .and_then(serde_json::Value::as_str)
989 .unwrap_or(&format!("{}", index + 1)),
990 ));
991 body.push_str("\">");
992 render_html_children(footnote, &mut body);
993 body.push_str("</li>");
994 }
995 body.push_str("</ol></section>");
996 }
997 format!(
998 "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
999 escape_html(lang),
1000 writing_mode,
1001 title,
1002 MDI_STYLESHEET,
1003 body
1004 )
1005}
1006
1007pub fn serialize_mdi(source: &str) -> String {
1009 serialize_mdi_document(&parse_document(source))
1010}
1011
1012pub fn serialize_mdi_document(document: &Document) -> String {
1014 let mut output = String::new();
1015 if let Some(frontmatter) = &document.frontmatter {
1016 output.push_str("---\n");
1017 output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1018 output.push_str("\n---\n\n");
1019 }
1020 for (index, node) in document.children.iter().enumerate() {
1021 if index > 0 && !output.ends_with("\n\n") {
1022 output.push('\n');
1023 }
1024 serialize_block(node, &mut output, "");
1025 }
1026 output
1027}
1028
1029pub fn render_text(source: &str) -> String {
1033 render_text_document(&parse_document(source))
1034}
1035
1036pub fn render_text_document(document: &Document) -> String {
1038 let mut output = String::new();
1039 for node in &document.children {
1040 render_text_node(node, &mut output);
1041 if !output.ends_with('\n') {
1042 output.push('\n');
1043 }
1044 }
1045 output
1046}
1047
1048#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum TextFormat {
1051 Plain,
1052 Ruby,
1053 Narou,
1054 Kakuyomu,
1055 Aozora,
1056}
1057
1058impl TextFormat {
1059 pub fn parse(value: &str) -> Option<Self> {
1061 match value {
1062 "txt" => Some(Self::Plain),
1063 "txt-ruby" => Some(Self::Ruby),
1064 "narou" => Some(Self::Narou),
1065 "kakuyomu" => Some(Self::Kakuyomu),
1066 "aozora" => Some(Self::Aozora),
1067 _ => None,
1068 }
1069 }
1070}
1071
1072pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1075 let document = parse_document(source);
1076 let definitions: Vec<&serde_json::Value> = document
1077 .children
1078 .iter()
1079 .filter(|node| {
1080 node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1081 })
1082 .collect();
1083 let mut blocks = Vec::new();
1084 for node in &document.children {
1085 text_format_block(node, format, indent_prefix, &definitions, &mut blocks);
1086 }
1087 if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1088 blocks.push(String::new());
1089 blocks.push("Footnotes".to_owned());
1090 for (index, definition) in definitions.iter().enumerate() {
1091 let text = children(definition)
1092 .iter()
1093 .filter(|child| {
1094 child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1095 })
1096 .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1097 .collect::<Vec<_>>()
1098 .join(" ");
1099 blocks.push(format!("{}. {text}", index + 1));
1100 }
1101 }
1102 let output = blocks.join("\n");
1103 if matches!(format, TextFormat::Aozora) {
1104 output.replace('\n', "\r\n")
1105 } else {
1106 output
1107 }
1108}
1109
1110fn text_format_block(
1111 node: &serde_json::Value,
1112 format: TextFormat,
1113 prefix: &str,
1114 definitions: &[&serde_json::Value],
1115 output: &mut Vec<String>,
1116) {
1117 let kind = node
1118 .get("type")
1119 .and_then(serde_json::Value::as_str)
1120 .unwrap_or_default();
1121 match kind {
1122 "footnoteDefinition" | "definition" => {}
1123 "paragraph" => output.push(format!(
1124 "{prefix}{}",
1125 text_format_inline_children(node, format, definitions)
1126 )),
1127 "heading" => {
1128 let value = text_format_inline_children(node, format, definitions);
1129 if matches!(format, TextFormat::Aozora) {
1130 let size = match node
1131 .get("depth")
1132 .and_then(serde_json::Value::as_u64)
1133 .unwrap_or(3)
1134 {
1135 1 => "大",
1136 2 => "中",
1137 _ => "小",
1138 };
1139 output.push(format!("{value}[#「{value}」は{size}見出し]"));
1140 } else {
1141 output.push(value);
1142 }
1143 }
1144 "list" => {
1145 for (index, item) in children(node).iter().enumerate() {
1146 for child in children(item) {
1147 if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1148 let bullet = if node
1149 .get("ordered")
1150 .and_then(serde_json::Value::as_bool)
1151 .unwrap_or(false)
1152 {
1153 format!("{}. ", index + 1)
1154 } else {
1155 "- ".to_owned()
1156 };
1157 output.push(format!(
1158 "{prefix}{bullet}{}",
1159 text_format_inline_children(child, format, definitions)
1160 ));
1161 } else {
1162 text_format_block(child, format, prefix, definitions, output);
1163 }
1164 }
1165 }
1166 }
1167 "blockquote" => {
1168 for child in children(node) {
1169 text_format_block(child, format, prefix, definitions, output);
1170 }
1171 }
1172 "code" => output.extend(
1173 node.get("value")
1174 .and_then(serde_json::Value::as_str)
1175 .unwrap_or_default()
1176 .lines()
1177 .map(str::to_owned),
1178 ),
1179 "table" => {
1180 for row in children(node) {
1181 output.push(
1182 children(row)
1183 .iter()
1184 .map(|cell| text_format_inline_children(cell, format, definitions))
1185 .collect::<Vec<_>>()
1186 .join("\t"),
1187 );
1188 }
1189 }
1190 "thematicBreak" => output.push("――――――".to_owned()),
1191 "blank" | "pagebreak" => output.push(String::new()),
1192 _ => {}
1193 }
1194}
1195
1196fn text_format_inline_children(
1197 node: &serde_json::Value,
1198 format: TextFormat,
1199 definitions: &[&serde_json::Value],
1200) -> String {
1201 children(node)
1202 .iter()
1203 .map(|node| text_format_inline(node, format, definitions))
1204 .collect()
1205}
1206fn text_format_inline(
1207 node: &serde_json::Value,
1208 format: TextFormat,
1209 definitions: &[&serde_json::Value],
1210) -> String {
1211 match node
1212 .get("type")
1213 .and_then(serde_json::Value::as_str)
1214 .unwrap_or_default()
1215 {
1216 "text" | "inlineCode" | "tcy" => node
1217 .get("value")
1218 .and_then(serde_json::Value::as_str)
1219 .unwrap_or_default()
1220 .to_owned(),
1221 "break" => "\n".to_owned(),
1222 "ruby" => {
1223 let base = node
1224 .get("base")
1225 .and_then(serde_json::Value::as_str)
1226 .unwrap_or_default();
1227 let reading = node
1228 .pointer("/ruby/value")
1229 .map(|value| match value {
1230 serde_json::Value::Array(parts) => parts
1231 .iter()
1232 .filter_map(serde_json::Value::as_str)
1233 .collect::<Vec<_>>()
1234 .join(if matches!(format, TextFormat::Ruby) {
1235 "."
1236 } else {
1237 ""
1238 }),
1239 serde_json::Value::String(value) => value.to_owned(),
1240 _ => String::new(),
1241 })
1242 .unwrap_or_default();
1243 match format {
1244 TextFormat::Plain => base.to_owned(),
1245 TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1246 _ => format!("|{base}《{reading}》"),
1247 }
1248 }
1249 "em" => {
1250 let value = text_format_inline_children(node, format, definitions);
1251 match format {
1252 TextFormat::Aozora => format!("{value}[#「{value}」に傍点]"),
1253 TextFormat::Kakuyomu => format!("《《{value}》》"),
1254 _ => value,
1255 }
1256 }
1257 "image" => node
1258 .get("alt")
1259 .and_then(serde_json::Value::as_str)
1260 .filter(|alt| !alt.is_empty())
1261 .map(|alt| format!("[画像: {alt}]"))
1262 .unwrap_or_else(|| "[画像]".to_owned()),
1263 "footnoteReference" => {
1264 if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1265 String::new()
1266 } else {
1267 let identifier = node
1268 .get("identifier")
1269 .and_then(serde_json::Value::as_str)
1270 .unwrap_or_default();
1271 let index = definitions
1272 .iter()
1273 .position(|definition| {
1274 definition
1275 .get("identifier")
1276 .and_then(serde_json::Value::as_str)
1277 == Some(identifier)
1278 })
1279 .map(|index| index + 1)
1280 .unwrap_or(0);
1281 format!("[注{index}]")
1282 }
1283 }
1284 _ => text_format_inline_children(node, format, definitions),
1285 }
1286}
1287
1288fn render_text_node(node: &serde_json::Value, out: &mut String) {
1289 match node
1290 .get("type")
1291 .and_then(serde_json::Value::as_str)
1292 .unwrap_or_default()
1293 {
1294 "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
1295 node.get("value")
1296 .and_then(serde_json::Value::as_str)
1297 .unwrap_or_default(),
1298 ),
1299 "ruby" => out.push_str(
1300 node.get("base")
1301 .and_then(serde_json::Value::as_str)
1302 .unwrap_or_default(),
1303 ),
1304 "image" => out.push_str(
1305 node.get("alt")
1306 .and_then(serde_json::Value::as_str)
1307 .unwrap_or_default(),
1308 ),
1309 "break" => out.push('\n'),
1310 "blank" => out.push('\n'),
1311 "pagebreak" => out.push_str("\n\x0C\n"),
1312 "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
1313 render_text_children(node, out);
1314 out.push('\n');
1315 }
1316 "tableCell" => {
1317 render_text_children(node, out);
1318 out.push('\t');
1319 }
1320 _ => render_text_children(node, out),
1321 }
1322}
1323
1324fn render_text_children(node: &serde_json::Value, out: &mut String) {
1325 for child in children(node) {
1326 render_text_node(child, out);
1327 }
1328}
1329
1330fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
1331 let kind = node
1332 .get("type")
1333 .and_then(serde_json::Value::as_str)
1334 .unwrap_or_default();
1335 match kind {
1336 "paragraph" => {
1337 if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
1338 out.push_str(prefix);
1339 out.push_str(&format!("[[indent:{amount}]]\n"));
1340 }
1341 if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
1342 out.push_str(prefix);
1343 if amount == 0 {
1344 out.push_str("[[bottom]]\n");
1345 } else {
1346 out.push_str(&format!("[[bottom:{amount}]]\n"));
1347 }
1348 }
1349 out.push_str(prefix);
1350 serialize_inline_children(node, out);
1351 out.push('\n');
1352 }
1353 "heading" => {
1354 out.push_str(prefix);
1355 out.push_str(
1356 &"#".repeat(
1357 node.get("depth")
1358 .and_then(serde_json::Value::as_u64)
1359 .unwrap_or(1) as usize,
1360 ),
1361 );
1362 out.push(' ');
1363 serialize_inline_children(node, out);
1364 out.push('\n');
1365 }
1366 "blockquote" => {
1367 let mut content = String::new();
1368 for child in children(node) {
1369 serialize_block(child, &mut content, "");
1370 }
1371 for line in content.trim_end_matches('\n').lines() {
1372 out.push_str(prefix);
1373 out.push_str("> ");
1374 out.push_str(line);
1375 out.push('\n');
1376 }
1377 }
1378 "list" => {
1379 let ordered = node
1380 .get("ordered")
1381 .and_then(serde_json::Value::as_bool)
1382 .unwrap_or(false);
1383 let start = node
1384 .get("start")
1385 .and_then(serde_json::Value::as_u64)
1386 .unwrap_or(1);
1387 for (index, item) in children(node).iter().enumerate() {
1388 out.push_str(prefix);
1389 if ordered {
1390 out.push_str(&format!("{}.", start + index as u64));
1391 } else {
1392 out.push('-');
1393 }
1394 out.push(' ');
1395 if let Some(first) = children(item).first() {
1396 serialize_block(first, out, "");
1397 }
1398 for child in children(item).iter().skip(1) {
1399 serialize_block(child, out, " ");
1400 }
1401 }
1402 }
1403 "code" => {
1404 out.push_str(prefix);
1405 out.push_str("```");
1406 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
1407 out.push_str(lang);
1408 }
1409 out.push('\n');
1410 out.push_str(
1411 node.get("value")
1412 .and_then(serde_json::Value::as_str)
1413 .unwrap_or_default(),
1414 );
1415 out.push_str("\n```\n");
1416 }
1417 "thematicBreak" => out.push_str("---\n"),
1418 "blank" => out.push_str("\\\n"),
1419 "pagebreak" => {
1420 out.push_str("[[pagebreak");
1421 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
1422 out.push(':');
1423 out.push_str(variant);
1424 }
1425 out.push_str("]]\n");
1426 }
1427 "table" => serialize_table(node, out),
1428 "html" => {
1429 out.push_str(
1430 node.get("value")
1431 .and_then(serde_json::Value::as_str)
1432 .unwrap_or_default(),
1433 );
1434 out.push('\n');
1435 }
1436 _ => {
1437 serialize_inline(node, out);
1438 out.push('\n');
1439 }
1440 }
1441}
1442
1443fn serialize_table(node: &serde_json::Value, out: &mut String) {
1444 for (row_index, row) in children(node).iter().enumerate() {
1445 out.push('|');
1446 for cell in children(row) {
1447 out.push(' ');
1448 serialize_inline_children(cell, out);
1449 out.push_str(" |");
1450 }
1451 out.push('\n');
1452 if row_index == 0 {
1453 out.push('|');
1454 for _ in children(row) {
1455 out.push_str(" --- |");
1456 }
1457 out.push('\n');
1458 }
1459 }
1460}
1461
1462fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
1463 for child in children(node) {
1464 serialize_inline(child, out);
1465 }
1466}
1467
1468fn serialize_inline(node: &serde_json::Value, out: &mut String) {
1469 let kind = node
1470 .get("type")
1471 .and_then(serde_json::Value::as_str)
1472 .unwrap_or_default();
1473 match kind {
1474 "text" | "html" => out.push_str(
1475 node.get("value")
1476 .and_then(serde_json::Value::as_str)
1477 .unwrap_or_default(),
1478 ),
1479 "emphasis" => {
1480 out.push('*');
1481 serialize_inline_children(node, out);
1482 out.push('*');
1483 }
1484 "strong" => {
1485 out.push_str("**");
1486 serialize_inline_children(node, out);
1487 out.push_str("**");
1488 }
1489 "delete" => {
1490 out.push_str("~~");
1491 serialize_inline_children(node, out);
1492 out.push_str("~~");
1493 }
1494 "inlineCode" => {
1495 out.push('`');
1496 out.push_str(
1497 node.get("value")
1498 .and_then(serde_json::Value::as_str)
1499 .unwrap_or_default(),
1500 );
1501 out.push('`');
1502 }
1503 "link" => {
1504 out.push('[');
1505 serialize_inline_children(node, out);
1506 out.push_str("](");
1507 out.push_str(
1508 node.get("url")
1509 .and_then(serde_json::Value::as_str)
1510 .unwrap_or_default(),
1511 );
1512 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
1513 out.push_str(" \\");
1514 out.push_str(title);
1515 out.push('\"');
1516 }
1517 out.push(')');
1518 }
1519 "image" => {
1520 out.push_str(";
1527 out.push_str(
1528 node.get("url")
1529 .and_then(serde_json::Value::as_str)
1530 .unwrap_or_default(),
1531 );
1532 out.push(')');
1533 }
1534 "ruby" => {
1535 out.push('{');
1536 out.push_str(
1537 node.get("base")
1538 .and_then(serde_json::Value::as_str)
1539 .unwrap_or_default(),
1540 );
1541 out.push('|');
1542 if let Some(values) = node
1543 .pointer("/ruby/value")
1544 .and_then(serde_json::Value::as_array)
1545 {
1546 for (index, value) in values.iter().enumerate() {
1547 if index > 0 {
1548 out.push('.');
1549 }
1550 out.push_str(value.as_str().unwrap_or_default());
1551 }
1552 } else {
1553 out.push_str(
1554 node.pointer("/ruby/value")
1555 .and_then(serde_json::Value::as_str)
1556 .unwrap_or_default(),
1557 );
1558 }
1559 out.push('}');
1560 }
1561 "tcy" => {
1562 out.push('^');
1563 out.push_str(
1564 node.get("value")
1565 .and_then(serde_json::Value::as_str)
1566 .unwrap_or_default(),
1567 );
1568 out.push('^');
1569 }
1570 "break" => out.push_str("[[br]]"),
1573 "em" => {
1574 out.push_str("[[em:");
1575 let mark = node
1576 .get("mark")
1577 .and_then(serde_json::Value::as_str)
1578 .unwrap_or("﹅");
1579 if mark != "﹅" {
1580 out.push_str(mark);
1581 out.push(':');
1582 }
1583 serialize_inline_children(node, out);
1584 out.push_str("]]");
1585 }
1586 "noBreak" => {
1587 out.push_str("[[no-break:");
1588 serialize_inline_children(node, out);
1589 out.push_str("]]");
1590 }
1591 "warichu" => {
1592 out.push_str("[[warichu:");
1593 serialize_inline_children(node, out);
1594 out.push_str("]]");
1595 }
1596 "kern" => {
1597 out.push_str("[[kern:");
1598 out.push_str(
1599 node.get("amount")
1600 .and_then(serde_json::Value::as_str)
1601 .unwrap_or_default(),
1602 );
1603 out.push(':');
1604 serialize_inline_children(node, out);
1605 out.push_str("]]");
1606 }
1607 "footnoteReference" => {
1608 out.push_str("[^");
1609 out.push_str(
1610 node.get("identifier")
1611 .and_then(serde_json::Value::as_str)
1612 .unwrap_or_default(),
1613 );
1614 out.push(']');
1615 }
1616 _ => serialize_inline_children(node, out),
1617 }
1618}
1619
1620fn children(node: &serde_json::Value) -> &[serde_json::Value] {
1621 node.get("children")
1622 .and_then(serde_json::Value::as_array)
1623 .map(Vec::as_slice)
1624 .unwrap_or(&[])
1625}
1626
1627pub 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-height:1em}.mdi-indent{margin-inline-start:calc(var(--mdi-indent)*1em)}.mdi-bottom{text-align:end}.mdi-pagebreak{break-after:page}";
1630
1631pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
1635 render_epub_document(&parse_document(source))
1636}
1637
1638pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
1640 let cursor = Cursor::new(Vec::new());
1641 let mut zip = ZipWriter::new(cursor);
1642 write_epub_document(document, &mut zip)?;
1643 zip.finish()
1644 .map(|cursor| cursor.into_inner())
1645 .map_err(|error| error.to_string())
1646}
1647
1648fn write_epub_document<W: Write + Seek>(
1649 document: &Document,
1650 zip: &mut ZipWriter<W>,
1651) -> Result<(), String> {
1652 let field = |key: &str| {
1653 document
1654 .frontmatter
1655 .as_ref()
1656 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1657 .and_then(|entry| entry.value.as_str())
1658 };
1659 let title = field("title").unwrap_or("Untitled");
1660 let author = field("author");
1661 let language = field("lang").unwrap_or("ja");
1662 let identifier = field("identifier").unwrap_or("urn:mdi:document");
1663 let vertical = matches!(field("writing-mode"), Some("vertical"));
1664 let chapters = epub_chapters(document);
1665 let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1666 epub_file(zip, "mimetype", "application/epub+zip", stored)?;
1667 let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1668 epub_file(
1669 zip,
1670 "META-INF/container.xml",
1671 "<?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>",
1672 compressed,
1673 )?;
1674 let writing = if vertical {
1675 "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
1676 } else {
1677 ""
1678 };
1679 epub_file(
1680 zip,
1681 "OEBPS/style.css",
1682 &format!(
1683 "body{{font-family:serif;{writing}line-height:1.8;margin:1em}}p{{text-indent:1em;margin:.3em 0}}{MDI_STYLESHEET}"
1684 ),
1685 compressed,
1686 )?;
1687 let nav_items = chapters
1688 .iter()
1689 .enumerate()
1690 .map(|(index, chapter)| {
1691 format!(
1692 "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
1693 index + 1,
1694 escape_html(&chapter.title)
1695 )
1696 })
1697 .collect::<String>();
1698 epub_file(
1699 zip,
1700 "OEBPS/nav.xhtml",
1701 &epub_xhtml(
1702 "Contents",
1703 language,
1704 &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
1705 ),
1706 compressed,
1707 )?;
1708 for (index, chapter) in chapters.iter().enumerate() {
1709 epub_file(
1710 zip,
1711 &format!("OEBPS/chapter-{}.xhtml", index + 1),
1712 &epub_xhtml(
1713 if chapter.title.is_empty() {
1714 title
1715 } else {
1716 &chapter.title
1717 },
1718 language,
1719 &chapter.html,
1720 ),
1721 compressed,
1722 )?;
1723 }
1724 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>());
1725 let spine = chapters
1726 .iter()
1727 .enumerate()
1728 .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
1729 .collect::<String>();
1730 let creator = author
1731 .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
1732 .unwrap_or_default();
1733 let progression = if vertical {
1734 " page-progression-direction=\"rtl\""
1735 } else {
1736 ""
1737 };
1738 epub_file(
1739 zip,
1740 "OEBPS/package.opf",
1741 &format!(
1742 "<?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>",
1743 escape_html(identifier),
1744 escape_html(title),
1745 escape_html(language)
1746 ),
1747 compressed,
1748 )?;
1749 Ok(())
1750}
1751
1752pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
1756 render_docx_document(&parse_document(source))
1757}
1758
1759pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
1761 let cursor = Cursor::new(Vec::new());
1762 let mut zip = ZipWriter::new(cursor);
1763 write_docx_document(document, &mut zip)?;
1764 zip.finish()
1765 .map(|cursor| cursor.into_inner())
1766 .map_err(|error| error.to_string())
1767}
1768
1769fn write_docx_document<W: Write + Seek>(
1770 document: &Document,
1771 zip: &mut ZipWriter<W>,
1772) -> Result<(), String> {
1773 let title = document
1774 .frontmatter
1775 .as_ref()
1776 .and_then(|frontmatter| {
1777 frontmatter
1778 .entries
1779 .iter()
1780 .find(|entry| entry.key == "title")
1781 })
1782 .and_then(|entry| entry.value.as_str())
1783 .unwrap_or("Untitled");
1784 let mut content = String::new();
1785 for node in &document.children {
1786 if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
1787 content.push('\x0C');
1788 content.push('\n');
1789 } else {
1790 render_text_node(node, &mut content);
1791 if !content.ends_with('\n') {
1792 content.push('\n');
1793 }
1794 }
1795 }
1796 let paragraphs = content
1797 .split('\n')
1798 .map(|line| {
1799 if line.contains('\x0C') {
1800 "<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>".to_owned()
1801 } else {
1802 format!(
1803 "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
1804 escape_xml(line)
1805 )
1806 }
1807 })
1808 .collect::<String>();
1809 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1810 docx_file(
1811 zip,
1812 "[Content_Types].xml",
1813 "<?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>",
1814 options,
1815 )?;
1816 docx_file(
1817 zip,
1818 "_rels/.rels",
1819 "<?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>",
1820 options,
1821 )?;
1822 docx_file(
1823 zip,
1824 "docProps/core.xml",
1825 &format!(
1826 "<?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>",
1827 escape_xml(title)
1828 ),
1829 options,
1830 )?;
1831 docx_file(
1832 zip,
1833 "word/document.xml",
1834 &format!(
1835 "<?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>"
1836 ),
1837 options,
1838 )?;
1839 Ok(())
1840}
1841
1842#[derive(Debug, Clone, Default)]
1845pub struct PdfOptions {
1846 pub chromium_path: Option<PathBuf>,
1847}
1848
1849pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
1852 let chromium = options
1853 .chromium_path
1854 .clone()
1855 .or_else(find_chromium)
1856 .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
1857 let nonce = SystemTime::now()
1858 .duration_since(UNIX_EPOCH)
1859 .map_err(|error| error.to_string())?
1860 .as_nanos();
1861 let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
1862 fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
1863 let html_path = directory.join("document.html");
1864 let pdf_path = directory.join("document.pdf");
1865 let result = (|| {
1866 fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
1867 let output = Command::new(&chromium)
1868 .arg("--headless=new")
1869 .arg("--disable-gpu")
1870 .arg("--no-pdf-header-footer")
1871 .arg(format!("--print-to-pdf={}", pdf_path.display()))
1872 .arg(format!("file://{}", html_path.display()))
1873 .output()
1874 .map_err(|error| {
1875 format!(
1876 "failed to start Chromium at {}: {error}",
1877 chromium.display()
1878 )
1879 })?;
1880 if !output.status.success() {
1881 return Err(format!(
1882 "Chromium PDF rendering failed: {}",
1883 String::from_utf8_lossy(&output.stderr)
1884 ));
1885 }
1886 fs::read(&pdf_path).map_err(|error| error.to_string())
1887 })();
1888 let _ = fs::remove_dir_all(&directory);
1889 result
1890}
1891
1892pub fn find_chromium() -> Option<PathBuf> {
1895 let candidates = [
1896 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1897 "/Applications/Chromium.app/Contents/MacOS/Chromium",
1898 "/usr/bin/google-chrome",
1899 "/usr/bin/chromium",
1900 "/usr/bin/chromium-browser",
1901 ];
1902 candidates
1903 .iter()
1904 .map(Path::new)
1905 .find(|path| path.is_file())
1906 .map(Path::to_path_buf)
1907}
1908
1909fn docx_file<W: Write + Seek>(
1910 zip: &mut ZipWriter<W>,
1911 path: &str,
1912 content: &str,
1913 options: SimpleFileOptions,
1914) -> Result<(), String> {
1915 zip.start_file(path, options)
1916 .map_err(|error| error.to_string())?;
1917 zip.write_all(content.as_bytes())
1918 .map_err(|error| error.to_string())
1919}
1920fn escape_xml(value: &str) -> String {
1921 value
1922 .replace('&', "&")
1923 .replace('<', "<")
1924 .replace('>', ">")
1925 .replace('"', """)
1926 .replace('\'', "'")
1927}
1928
1929struct EpubChapter {
1930 title: String,
1931 html: String,
1932}
1933fn epub_chapters(document: &Document) -> Vec<EpubChapter> {
1934 let mut chapters = vec![EpubChapter {
1935 title: String::new(),
1936 html: String::new(),
1937 }];
1938 for node in &document.children {
1939 if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
1940 if !chapters
1941 .last()
1942 .is_some_and(|chapter| chapter.html.is_empty())
1943 {
1944 chapters.push(EpubChapter {
1945 title: String::new(),
1946 html: String::new(),
1947 });
1948 }
1949 continue;
1950 }
1951 if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
1952 && node.get("depth").and_then(serde_json::Value::as_u64) == Some(1)
1953 && !chapters
1954 .last()
1955 .is_some_and(|chapter| chapter.html.is_empty())
1956 {
1957 chapters.push(EpubChapter {
1958 title: String::new(),
1959 html: String::new(),
1960 });
1961 }
1962 let chapter = chapters.last_mut().expect("one chapter exists");
1963 if chapter.title.is_empty()
1964 && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
1965 {
1966 chapter.title = plain_node_text(node);
1967 }
1968 render_html_node(node, &mut chapter.html);
1969 }
1970 let chapters: Vec<_> = chapters
1971 .into_iter()
1972 .filter(|chapter| !chapter.html.is_empty())
1973 .collect();
1974 if chapters.is_empty() {
1975 vec![EpubChapter {
1976 title: String::new(),
1977 html: String::new(),
1978 }]
1979 } else {
1980 chapters
1981 }
1982}
1983fn plain_node_text(node: &serde_json::Value) -> String {
1984 let mut text = String::new();
1985 render_text_children(node, &mut text);
1986 text
1987}
1988fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
1989 format!(
1990 "<?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>",
1991 escape_html(language),
1992 escape_html(language),
1993 escape_html(title),
1994 body.replace("<br>", "<br/>").replace("<hr>", "<hr/>")
1995 )
1996}
1997fn epub_file<W: Write + Seek>(
1998 zip: &mut ZipWriter<W>,
1999 path: &str,
2000 content: &str,
2001 options: SimpleFileOptions,
2002) -> Result<(), String> {
2003 zip.start_file(path, options)
2004 .map_err(|error| error.to_string())?;
2005 zip.write_all(content.as_bytes())
2006 .map_err(|error| error.to_string())
2007}
2008
2009fn render_html_node(node: &serde_json::Value, out: &mut String) {
2010 let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
2011 return;
2012 };
2013 let children = |out: &mut String| render_html_children(node, out);
2014 match kind {
2015 "text" => out.push_str(&escape_html(
2016 node.get("value")
2017 .and_then(serde_json::Value::as_str)
2018 .unwrap_or_default(),
2019 )),
2020 "root" => children(out),
2021 "paragraph" => {
2022 let class = if node.get("indent").is_some() {
2023 " class=\"mdi-indent\""
2024 } else if node.get("bottom").is_some() {
2025 " class=\"mdi-bottom\""
2026 } else {
2027 ""
2028 };
2029 let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
2030 {
2031 format!(" style=\"--mdi-indent:{amount};\"")
2032 } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2033 format!(" style=\"--mdi-shift:{amount};\"")
2034 } else {
2035 String::new()
2036 };
2037 out.push_str("<p");
2038 out.push_str(class);
2039 out.push_str(&style);
2040 out.push('>');
2041 children(out);
2042 out.push_str("</p>");
2043 }
2044 "heading" => {
2045 let depth = node
2046 .get("depth")
2047 .and_then(serde_json::Value::as_u64)
2048 .filter(|depth| (1..=6).contains(depth))
2049 .unwrap_or(1);
2050 out.push_str(&format!("<h{depth}>"));
2051 children(out);
2052 out.push_str(&format!("</h{depth}>"));
2053 }
2054 "emphasis" => wrapped(out, "em", children),
2055 "strong" => wrapped(out, "strong", children),
2056 "delete" => wrapped(out, "del", children),
2057 "blockquote" => wrapped(out, "blockquote", children),
2058 "list" => {
2059 let ordered = node
2060 .get("ordered")
2061 .and_then(serde_json::Value::as_bool)
2062 .unwrap_or(false);
2063 let tag = if ordered { "ol" } else { "ul" };
2064 let start = node
2065 .get("start")
2066 .and_then(serde_json::Value::as_u64)
2067 .filter(|start| *start != 1)
2068 .map(|start| format!(" start=\"{start}\""))
2069 .unwrap_or_default();
2070 out.push('<');
2071 out.push_str(tag);
2072 out.push_str(&start);
2073 out.push('>');
2074 children(out);
2075 out.push_str("</");
2076 out.push_str(tag);
2077 out.push('>');
2078 }
2079 "listItem" => wrapped(out, "li", children),
2080 "thematicBreak" => out.push_str("<hr>"),
2081 "break" => out.push_str("<br>"),
2082 "inlineCode" => {
2083 out.push_str("<code>");
2084 out.push_str(&escape_html(
2085 node.get("value")
2086 .and_then(serde_json::Value::as_str)
2087 .unwrap_or_default(),
2088 ));
2089 out.push_str("</code>");
2090 }
2091 "code" => {
2092 out.push_str("<pre><code");
2093 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2094 out.push_str(" class=\"language-");
2095 out.push_str(&escape_html(lang));
2096 out.push('"');
2097 }
2098 out.push('>');
2099 out.push_str(&escape_html(
2100 node.get("value")
2101 .and_then(serde_json::Value::as_str)
2102 .unwrap_or_default(),
2103 ));
2104 out.push_str("</code></pre>");
2105 }
2106 "link" => {
2107 out.push_str("<a href=\"");
2108 out.push_str(&escape_html(
2109 node.get("url")
2110 .and_then(serde_json::Value::as_str)
2111 .unwrap_or_default(),
2112 ));
2113 out.push('"');
2114 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2115 out.push_str(" title=\"");
2116 out.push_str(&escape_html(title));
2117 out.push('"');
2118 }
2119 out.push('>');
2120 children(out);
2121 out.push_str("</a>");
2122 }
2123 "image" => {
2124 out.push_str("<img src=\"");
2125 out.push_str(&escape_html(
2126 node.get("url")
2127 .and_then(serde_json::Value::as_str)
2128 .unwrap_or_default(),
2129 ));
2130 out.push_str("\" alt=\"");
2131 out.push_str(&escape_html(
2132 node.get("alt")
2133 .and_then(serde_json::Value::as_str)
2134 .unwrap_or_default(),
2135 ));
2136 out.push_str("\">");
2137 }
2138 "table" => wrapped(out, "table", children),
2139 "tableRow" => wrapped(out, "tr", children),
2140 "tableCell" => wrapped(out, "td", children),
2141 "footnoteReference" => {
2142 let label = node
2143 .get("label")
2144 .and_then(serde_json::Value::as_str)
2145 .unwrap_or_default();
2146 out.push_str("<sup class=\"footnote-ref\">");
2147 out.push_str(&escape_html(label));
2148 out.push_str("</sup>");
2149 }
2150 "footnoteDefinition" | "definition" => {}
2151 "html" => out.push_str(&escape_html(
2152 node.get("value")
2153 .and_then(serde_json::Value::as_str)
2154 .unwrap_or_default(),
2155 )),
2156 "ruby" => render_ruby(node, out),
2157 "tcy" => {
2158 out.push_str("<span class=\"mdi-tcy\">");
2159 out.push_str(&escape_html(
2160 node.get("value")
2161 .and_then(serde_json::Value::as_str)
2162 .unwrap_or_default(),
2163 ));
2164 out.push_str("</span>");
2165 }
2166 "em" => {
2167 let mark = node
2168 .get("mark")
2169 .and_then(serde_json::Value::as_str)
2170 .unwrap_or("﹅");
2171 out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:"");
2172 out.push_str(&escape_css_string(mark));
2173 out.push_str("";\">");
2174 children(out);
2175 out.push_str("</span>");
2176 }
2177 "noBreak" => {
2178 out.push_str("<span class=\"mdi-nobr\">");
2179 children(out);
2180 out.push_str("</span>");
2181 }
2182 "warichu" => {
2183 out.push_str("<span class=\"mdi-warichu\">");
2184 children(out);
2185 out.push_str("</span>");
2186 }
2187 "kern" => {
2188 out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
2189 out.push_str(&escape_html(
2190 node.get("amount")
2191 .and_then(serde_json::Value::as_str)
2192 .unwrap_or_default(),
2193 ));
2194 out.push_str(";\">");
2195 children(out);
2196 out.push_str("</span>");
2197 }
2198 "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
2199 "pagebreak" => {
2200 out.push_str("<div class=\"mdi-pagebreak");
2201 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2202 out.push_str(" mdi-pagebreak-");
2203 out.push_str(&escape_html(variant));
2204 }
2205 out.push_str("\" role=\"presentation\"></div>");
2206 }
2207 _ => children(out),
2208 }
2209}
2210
2211fn render_html_children(node: &serde_json::Value, out: &mut String) {
2212 if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
2213 for child in children {
2214 render_html_node(child, out);
2215 }
2216 }
2217}
2218
2219fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
2220 out.push('<');
2221 out.push_str(tag);
2222 out.push('>');
2223 children(out);
2224 out.push_str("</");
2225 out.push_str(tag);
2226 out.push('>');
2227}
2228
2229fn render_ruby(node: &serde_json::Value, out: &mut String) {
2230 let base = node
2231 .get("base")
2232 .and_then(serde_json::Value::as_str)
2233 .unwrap_or_default();
2234 let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
2235 out.push_str("<ruby class=\"mdi-ruby\">");
2236 if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
2237 for (base, reading) in base.graphemes(true).zip(parts) {
2238 out.push_str(&escape_html(base));
2239 render_ruby_reading(reading.as_str().unwrap_or_default(), out);
2240 }
2241 } else {
2242 out.push_str(&escape_html(base));
2243 render_ruby_reading(
2244 reading
2245 .and_then(serde_json::Value::as_str)
2246 .unwrap_or_default(),
2247 out,
2248 );
2249 }
2250 out.push_str("</ruby>");
2251}
2252
2253fn render_ruby_reading(reading: &str, out: &mut String) {
2254 out.push_str("<rp>(</rp><rt>");
2255 out.push_str(&escape_html(reading));
2256 out.push_str("</rt><rp>)</rp>");
2257}
2258
2259fn escape_html(value: &str) -> String {
2260 value
2261 .replace('&', "&")
2262 .replace('<', "<")
2263 .replace('>', ">")
2264 .replace('"', """)
2265}
2266fn escape_css_string(value: &str) -> String {
2267 value.replace('\\', "\\\\").replace('"', "\\\"")
2268}
2269
2270pub fn parse_inlines(source: &str) -> Vec<Inline> {
2272 parse_inline_parts(source)
2273 .into_iter()
2274 .map(|(inline, _, _)| inline)
2275 .collect()
2276}
2277
2278fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
2282 let mut out = Vec::new();
2283 let mut text = String::new();
2284 let mut text_start = 0;
2285 let mut index = 0;
2286
2287 while index < source.len() {
2288 let rest = &source[index..];
2289 if rest.starts_with('\\') {
2290 let mut chars = rest.chars();
2291 let slash = chars.next().expect("prefix was checked");
2292 let Some(next) = chars.next() else {
2293 text.push(slash);
2294 index += slash.len_utf8();
2295 continue;
2296 };
2297 if is_escapable(next) {
2298 text.push(next);
2299 } else {
2300 text.push(slash);
2301 text.push(next);
2302 }
2303 index += slash.len_utf8() + next.len_utf8();
2304 continue;
2305 }
2306 if let Some((inline, consumed)) = ruby(rest) {
2307 push_inline_text(&mut out, &mut text, text_start, index);
2308 out.push((inline, index, index + consumed));
2309 index += consumed;
2310 text_start = index;
2311 continue;
2312 }
2313 if let Some((inline, consumed)) = tcy(rest) {
2314 push_inline_text(&mut out, &mut text, text_start, index);
2315 out.push((inline, index, index + consumed));
2316 index += consumed;
2317 text_start = index;
2318 continue;
2319 }
2320 if let Some((inline, consumed)) = boten(rest) {
2321 push_inline_text(&mut out, &mut text, text_start, index);
2322 out.push((inline, index, index + consumed));
2323 index += consumed;
2324 text_start = index;
2325 continue;
2326 }
2327 if let Some((inline, consumed)) = bracket_macro(rest) {
2328 push_inline_text(&mut out, &mut text, text_start, index);
2329 out.push((inline, index, index + consumed));
2330 index += consumed;
2331 text_start = index;
2332 continue;
2333 }
2334 let character = rest.chars().next().expect("index is in bounds");
2335 text.push(character);
2336 index += character.len_utf8();
2337 }
2338 push_inline_text(&mut out, &mut text, text_start, index);
2339 out
2340}
2341
2342#[derive(Clone)]
2343enum PendingBlock {
2344 Indent { amount: u32, source: String },
2345 Bottom { amount: u32, source: String },
2346}
2347
2348fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
2349 let (indent, bottom) = match pending {
2350 Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
2351 Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
2352 None => (None, None),
2353 };
2354 MdiBlock::Paragraph {
2355 inlines: parse_inlines(line),
2356 indent,
2357 bottom,
2358 }
2359}
2360
2361fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
2362 if let Some(marker) = pending.take() {
2363 let source = match marker {
2366 PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
2367 };
2368 blocks.push(paragraph(&source, None));
2369 }
2370}
2371
2372fn is_blank_marker(line: &str) -> bool {
2373 let value = line.trim_end_matches([' ', '\t']);
2374 value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
2375}
2376
2377fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
2378 match line.trim() {
2379 "[[pagebreak]]" => Some(None),
2380 "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
2381 "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
2382 _ => None,
2383 }
2384}
2385
2386fn pending_block(line: &str) -> Option<PendingBlock> {
2387 let value = line.trim();
2388 if value == "[[bottom]]" {
2389 return Some(PendingBlock::Bottom {
2390 amount: 0,
2391 source: value.to_owned(),
2392 });
2393 }
2394 let (kind, amount) = value
2395 .strip_prefix("[[")?
2396 .strip_suffix("]]")?
2397 .split_once(':')?;
2398 if amount.is_empty()
2399 || amount.starts_with('0')
2400 || !amount.bytes().all(|byte| byte.is_ascii_digit())
2401 {
2402 return None;
2403 }
2404 let amount = amount.parse().ok()?;
2405 match kind {
2406 "indent" => Some(PendingBlock::Indent {
2407 amount,
2408 source: value.to_owned(),
2409 }),
2410 "bottom" => Some(PendingBlock::Bottom {
2411 amount,
2412 source: value.to_owned(),
2413 }),
2414 _ => None,
2415 }
2416}
2417
2418fn ruby(value: &str) -> Option<(Inline, usize)> {
2419 if !value.starts_with('{') {
2420 return None;
2421 }
2422 let end = close_unescaped(value, 1, '}')?;
2423 let body = &value[1..end];
2424 let separator = bare_index(body, '|')?;
2425 let base = unescape_ruby(&body[..separator]);
2426 let raw_ruby = &body[separator + 1..];
2427 let ruby = split_ruby(&base, raw_ruby);
2428 Some((Inline::Ruby { base, ruby }, end + 1))
2429}
2430
2431fn split_ruby(base: &str, raw: &str) -> RubyReading {
2432 let segments = split_unescaped(raw, '.');
2433 if segments.len() == 1 {
2434 return RubyReading::Group(unescape_ruby(raw));
2435 }
2436 let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
2437 if segments.len() == base.graphemes(true).count()
2438 && segments.iter().all(|part| !part.is_empty())
2439 {
2440 RubyReading::Split(segments)
2441 } else {
2442 RubyReading::Group(segments.concat())
2443 }
2444}
2445
2446fn tcy(value: &str) -> Option<(Inline, usize)> {
2447 if !value.starts_with('^') {
2448 return None;
2449 }
2450 let closing = value[1..].find('^')? + 1;
2451 let body = &value[1..closing];
2452 if body.is_empty()
2453 || body.chars().count() > 6
2454 || !body
2455 .chars()
2456 .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
2457 {
2458 return None;
2459 }
2460 Some((Inline::Tcy(body.to_owned()), closing + 1))
2461}
2462
2463fn boten(value: &str) -> Option<(Inline, usize)> {
2464 let prefix = "《《";
2465 if !value.starts_with(prefix) {
2466 return None;
2467 }
2468 let end = value.find("》》")?;
2469 let body = &value[prefix.len()..end];
2470 if body.is_empty()
2471 || body.contains('\n')
2472 || contains_unescaped(body, '《')
2473 || contains_unescaped(body, '》')
2474 {
2475 return None;
2476 }
2477 Some((
2478 Inline::Em {
2479 mark: "﹅".to_owned(),
2480 children: vec![Inline::Text(unescape_mdi(body))],
2481 },
2482 end + "》》".len(),
2483 ))
2484}
2485
2486fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
2487 if !value.starts_with("[[") {
2488 return None;
2489 }
2490 if value.starts_with("[[br]]") {
2491 return Some((Inline::Break, "[[br]]".len()));
2492 }
2493 let end = close_macro(value)?;
2494 let body = &value[2..end];
2495 let (name, payload) = body.split_once(':')?;
2496 let children = |content: &str| parse_inlines(content);
2497 let inline = match name {
2498 "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
2499 "warichu" => Inline::Warichu(children(payload)),
2500 "kern" => {
2501 let (amount, content) = payload.split_once(':')?;
2502 if !valid_kern(amount) {
2503 return None;
2504 }
2505 Inline::Kern {
2506 amount: unescape_mdi(amount),
2507 children: children(content),
2508 }
2509 }
2510 "em" => {
2511 let (mark, content) = match bare_index(payload, ':') {
2512 Some(index) => {
2513 let candidate = unescape_mdi(&payload[..index]);
2514 if candidate.graphemes(true).count() == 1
2515 && !candidate
2516 .chars()
2517 .any(|c| c.is_whitespace() || c.is_control())
2518 {
2519 (candidate, &payload[index + 1..])
2520 } else {
2521 ("﹅".to_owned(), payload)
2522 }
2523 }
2524 None => ("﹅".to_owned(), payload),
2525 };
2526 Inline::Em {
2527 mark,
2528 children: children(content),
2529 }
2530 }
2531 _ => return None,
2532 };
2533 Some((inline, end + 2))
2534}
2535
2536fn close_macro(value: &str) -> Option<usize> {
2537 let mut index = 2;
2538 let mut depth = 1;
2539 while index < value.len() {
2540 let rest = &value[index..];
2541 if rest.starts_with('\\') {
2542 index += rest.chars().nth(1)?.len_utf8() + 1;
2543 } else if rest.starts_with("[[") {
2544 depth += 1;
2545 index += 2;
2546 } else if rest.starts_with("]]") {
2547 depth -= 1;
2548 if depth == 0 {
2549 return Some(index);
2550 }
2551 index += 2;
2552 } else {
2553 index += rest.chars().next()?.len_utf8();
2554 }
2555 }
2556 None
2557}
2558
2559fn valid_kern(value: &str) -> bool {
2560 let value = value.strip_suffix("em").unwrap_or("");
2561 let value = value.strip_prefix(['+', '-']).unwrap_or(value);
2562 let mut parts = value.split('.');
2563 let whole = parts.next().unwrap_or("");
2564 let fraction = parts.next();
2565 parts.next().is_none()
2566 && !whole.is_empty()
2567 && whole.bytes().all(|b| b.is_ascii_digit())
2568 && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
2569}
2570
2571fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
2572 let mut escaped = false;
2573 for (index, character) in value[start..].char_indices() {
2574 if escaped {
2575 escaped = false;
2576 continue;
2577 }
2578 if character == '\\' {
2579 escaped = true;
2580 continue;
2581 }
2582 if character == needle {
2583 return Some(start + index);
2584 }
2585 if character == '\n' {
2586 return None;
2587 }
2588 }
2589 None
2590}
2591
2592fn bare_index(value: &str, needle: char) -> Option<usize> {
2593 let mut escaped = false;
2594 for (index, character) in value.char_indices() {
2595 if escaped {
2596 escaped = false;
2597 continue;
2598 }
2599 if character == '\\' {
2600 escaped = true;
2601 continue;
2602 }
2603 if character == needle {
2604 return Some(index);
2605 }
2606 }
2607 None
2608}
2609
2610fn contains_unescaped(value: &str, needle: char) -> bool {
2611 bare_index(value, needle).is_some()
2612}
2613
2614fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
2615 let mut parts = Vec::new();
2616 let mut start = 0;
2617 let mut escaped = false;
2618 for (index, character) in value.char_indices() {
2619 if escaped {
2620 escaped = false;
2621 continue;
2622 }
2623 if character == '\\' {
2624 escaped = true;
2625 continue;
2626 }
2627 if character == separator {
2628 parts.push(&value[start..index]);
2629 start = index + character.len_utf8();
2630 }
2631 }
2632 parts.push(&value[start..]);
2633 parts
2634}
2635
2636const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
2641const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
2642
2643fn unescape_mdi(value: &str) -> String {
2644 unescape(value, ESCAPABLE_MDI)
2645}
2646
2647fn unescape_ruby(value: &str) -> String {
2648 unescape(value, ESCAPABLE_RUBY)
2649}
2650
2651fn unescape(value: &str, allowed: &str) -> String {
2652 let mut out = String::new();
2653 let mut chars = value.chars();
2654 while let Some(character) = chars.next() {
2655 if character == '\\' {
2656 if let Some(next) = chars.next() {
2657 if allowed.contains(next) {
2658 out.push(next);
2659 } else {
2660 out.push(character);
2661 out.push(next);
2662 }
2663 } else {
2664 out.push(character);
2665 }
2666 } else {
2667 out.push(character);
2668 }
2669 }
2670 out
2671}
2672
2673fn is_escapable(character: char) -> bool {
2674 ESCAPABLE_MDI.contains(character)
2675}
2676
2677fn push_inline_text(
2678 out: &mut Vec<(Inline, usize, usize)>,
2679 text: &mut String,
2680 start: usize,
2681 end: usize,
2682) {
2683 if !text.is_empty() {
2684 out.push((Inline::Text(std::mem::take(text)), start, end));
2685 }
2686}
2687
2688#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
2696enum BlockMacroClass {
2697 Indent(u32),
2698 Bottom(u32),
2699 Pagebreak(Option<PagebreakVariant>),
2700 Literal,
2701}
2702
2703#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
2704fn classify_block_macro(source: &str) -> BlockMacroClass {
2705 let value = source.trim();
2706 match value {
2707 "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
2708 "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
2709 "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
2710 "[[bottom]]" => return BlockMacroClass::Bottom(0),
2711 _ => {}
2712 }
2713 if let Some((kind, amount)) = value
2714 .strip_prefix("[[")
2715 .and_then(|rest| rest.strip_suffix("]]"))
2716 .and_then(|inner| inner.split_once(':'))
2717 {
2718 let valid_amount = !amount.is_empty()
2719 && !amount.starts_with('0')
2720 && amount.bytes().all(|b| b.is_ascii_digit());
2721 if valid_amount && let Ok(amount) = amount.parse::<u32>() {
2722 match kind {
2723 "indent" => return BlockMacroClass::Indent(amount),
2724 "bottom" => return BlockMacroClass::Bottom(amount),
2725 _ => {}
2726 }
2727 }
2728 }
2729 BlockMacroClass::Literal
2730}
2731
2732#[cfg(feature = "wasm")]
2735mod wasm {
2736 use super::{
2737 BlockMacroClass, PagebreakVariant, RubyReading, TextFormat, classify_block_macro,
2738 parse_json, render_docx, render_epub, render_html, render_text, render_text_format,
2739 serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
2740 };
2741 use wasm_bindgen::prelude::*;
2742
2743 #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
2747 pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
2748 parse_json(source)
2749 }
2750
2751 #[wasm_bindgen(js_name = renderHtml)]
2753 pub fn wasm_render_html(source: &str) -> String {
2754 render_html(source)
2755 }
2756
2757 #[wasm_bindgen(js_name = serializeMdi)]
2759 pub fn wasm_serialize_mdi(source: &str) -> String {
2760 serialize_mdi(source)
2761 }
2762
2763 #[wasm_bindgen(js_name = renderText)]
2765 pub fn wasm_render_text(source: &str) -> String {
2766 render_text(source)
2767 }
2768
2769 #[wasm_bindgen(js_name = renderTextFormat)]
2771 pub fn wasm_render_text_format(
2772 source: &str,
2773 format: &str,
2774 indent_prefix: &str,
2775 ) -> Result<String, JsValue> {
2776 let format = TextFormat::parse(format)
2777 .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
2778 Ok(render_text_format(source, format, indent_prefix))
2779 }
2780
2781 #[wasm_bindgen(js_name = renderEpub)]
2783 pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
2784 render_epub(source)
2785 .map(Vec::into_boxed_slice)
2786 .map_err(|message| JsValue::from_str(&message))
2787 }
2788
2789 #[wasm_bindgen(js_name = renderDocx)]
2791 pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
2792 render_docx(source)
2793 .map(Vec::into_boxed_slice)
2794 .map_err(|message| JsValue::from_str(&message))
2795 }
2796
2797 #[wasm_bindgen(js_name = unescapeMdi)]
2798 pub fn wasm_unescape_mdi(value: &str) -> String {
2799 unescape_mdi(value)
2800 }
2801
2802 #[wasm_bindgen(js_name = unescapeRubyText)]
2803 pub fn wasm_unescape_ruby(value: &str) -> String {
2804 unescape_ruby(value)
2805 }
2806
2807 #[wasm_bindgen(js_name = resolveRuby)]
2809 pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
2810 match split_ruby(base, raw_ruby) {
2811 RubyReading::Group(value) => JsValue::from_str(&value),
2812 RubyReading::Split(parts) => {
2813 let array = js_sys::Array::new();
2814 for part in parts {
2815 array.push(&JsValue::from_str(&part));
2816 }
2817 array.into()
2818 }
2819 }
2820 }
2821
2822 #[wasm_bindgen(js_name = blockMacroKind)]
2823 pub fn wasm_block_macro_kind(source: &str) -> String {
2824 match classify_block_macro(source) {
2825 BlockMacroClass::Indent(_) => "indent",
2826 BlockMacroClass::Bottom(_) => "bottom",
2827 BlockMacroClass::Pagebreak(_) => "pagebreak",
2828 BlockMacroClass::Literal => "literal",
2829 }
2830 .to_owned()
2831 }
2832
2833 #[wasm_bindgen(js_name = blockMacroAmount)]
2835 pub fn wasm_block_macro_amount(source: &str) -> i32 {
2836 match classify_block_macro(source) {
2837 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
2838 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
2839 }
2840 }
2841
2842 #[wasm_bindgen(js_name = blockMacroVariant)]
2844 pub fn wasm_block_macro_variant(source: &str) -> String {
2845 match classify_block_macro(source) {
2846 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
2847 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
2848 _ => "",
2849 }
2850 .to_owned()
2851 }
2852}
2853
2854#[cfg(test)]
2855mod tests {
2856 use super::*;
2857 use std::io::{Error, ErrorKind, Read, SeekFrom};
2858 use zip::ZipArchive;
2859
2860 struct FailAfterWrites {
2861 inner: Cursor<Vec<u8>>,
2862 remaining: usize,
2863 }
2864
2865 impl Write for FailAfterWrites {
2866 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
2867 if self.remaining == 0 {
2868 return Err(Error::new(
2869 ErrorKind::Other,
2870 "injected archive write failure",
2871 ));
2872 }
2873 self.remaining -= 1;
2874 self.inner.write(buffer)
2875 }
2876
2877 fn flush(&mut self) -> std::io::Result<()> {
2878 self.inner.flush()
2879 }
2880 }
2881
2882 impl Seek for FailAfterWrites {
2883 fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
2884 self.inner.seek(position)
2885 }
2886 }
2887
2888 fn assert_valid_spans(value: &serde_json::Value, source: &str) {
2889 let Some(object) = value.as_object() else {
2890 return;
2891 };
2892 if let Some(span) = object.get("span") {
2893 let start = span
2894 .get("startByte")
2895 .and_then(serde_json::Value::as_u64)
2896 .expect("span has startByte") as usize;
2897 let end = span
2898 .get("endByte")
2899 .and_then(serde_json::Value::as_u64)
2900 .expect("span has endByte") as usize;
2901 assert!(start <= end, "span start must not exceed end: {span}");
2902 assert!(
2903 end <= source.len(),
2904 "span must be inside its source: {span}"
2905 );
2906 assert!(
2907 source.is_char_boundary(start),
2908 "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
2909 );
2910 let remainder = &source[start..];
2911 assert!(
2912 source.is_char_boundary(end),
2913 "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
2914 remainder
2915 );
2916 }
2917 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
2918 for child in children {
2919 assert_valid_spans(child, source);
2920 }
2921 }
2922 }
2923
2924 fn generated_source(mut state: u64) -> String {
2925 if state == 0 {
2929 return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
2930 }
2931 const FRAGMENTS: &[&str] = &[
2932 "東京",
2933 "𠮟る",
2934 "👨👩👧",
2935 " ",
2936 "\\n",
2937 "\\\\",
2938 "{",
2939 "}",
2940 "|",
2941 ".",
2942 "^12^",
2943 "^_^",
2944 "{東京|とう.きょう}",
2945 "[[em:強調]]",
2946 "[[no-break:^12^]]",
2947 "[[kern:wide:x]]",
2948 "[[indent:2]]",
2949 "[[pagebreak:left]]",
2950 "《《傍点》》",
2951 "**strong**",
2952 "`^12^`",
2953 "[^n]",
2954 "\n\n",
2955 "| a | b |\n| - | - |\n| 1 | 2 |\n",
2956 "[link](https://example.test/?a=1&b=2)",
2957 "<script>x</script>",
2958 "\\{",
2959 "\\[",
2960 ];
2961 let mut source = String::new();
2962 for _ in 0..32 {
2963 state ^= state << 13;
2964 state ^= state >> 7;
2965 state ^= state << 17;
2966 source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
2967 }
2968 source
2969 }
2970
2971 #[test]
2972 fn parses_ruby_and_split_ruby() {
2973 assert_eq!(
2974 parse_inlines("{東京|とう.きょう}"),
2975 vec![Inline::Ruby {
2976 base: "東京".into(),
2977 ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
2978 }]
2979 );
2980 assert_eq!(
2981 parse_inlines("{東京|.きょう}"),
2982 vec![Inline::Ruby {
2983 base: "東京".into(),
2984 ruby: RubyReading::Group("きょう".into()),
2985 }]
2986 );
2987 }
2988
2989 #[test]
2990 fn parses_nested_macros_and_escapes() {
2991 assert_eq!(
2992 parse_inlines("[[em:●:a[[no-break:b]]]]"),
2993 vec![Inline::Em {
2994 mark: "●".into(),
2995 children: vec![
2996 Inline::Text("a".into()),
2997 Inline::NoBreak(vec![Inline::Text("b".into())])
2998 ],
2999 }]
3000 );
3001 }
3002
3003 #[test]
3004 fn parses_tcy_and_boten() {
3005 assert_eq!(
3006 parse_inlines("第^12^話《《重要》》"),
3007 vec![
3008 Inline::Text("第".into()),
3009 Inline::Tcy("12".into()),
3010 Inline::Text("話".into()),
3011 Inline::Em {
3012 mark: "﹅".into(),
3013 children: vec![Inline::Text("重要".into())]
3014 },
3015 ]
3016 );
3017 }
3018
3019 #[test]
3020 fn recognises_block_macros() {
3021 assert_eq!(
3022 parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
3023 MdiSyntaxDocument {
3024 blocks: vec![
3025 MdiBlock::Paragraph {
3026 inlines: vec![Inline::Text("本文".into())],
3027 indent: Some(2),
3028 bottom: None
3029 },
3030 MdiBlock::Pagebreak {
3031 variant: Some(PagebreakVariant::Left)
3032 },
3033 MdiBlock::Blank,
3034 ]
3035 }
3036 );
3037 }
3038
3039 #[test]
3040 fn keeps_invalid_syntax_literal() {
3041 assert_eq!(
3042 parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
3043 vec![Inline::Text(
3044 "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
3045 )]
3046 );
3047 }
3048
3049 #[test]
3050 fn applies_mdi_escapes_once_before_recognition() {
3051 assert_eq!(
3052 parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
3053 vec![Inline::Text(
3054 "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
3055 )]
3056 );
3057 }
3058
3059 #[test]
3060 fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
3061 assert_eq!(
3062 parse_inlines(r"\\ \n \a \0 \-"),
3063 vec![Inline::Text(r"\ \n \a \0 \-".into())]
3064 );
3065 }
3066
3067 #[test]
3068 fn treats_boten_alias_content_as_plain_text() {
3069 assert_eq!(
3070 parse_inlines(r"《《a\《b》》"),
3071 vec![Inline::Em {
3072 mark: "﹅".into(),
3073 children: vec![Inline::Text("a《b".into())]
3074 }]
3075 );
3076 assert_eq!(
3077 parse_inlines("《《雪》考》"),
3078 vec![Inline::Text("《《雪》考》".into())]
3079 );
3080 }
3081
3082 #[test]
3083 fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
3084 assert_eq!(
3085 parse_inlines("[[em:ab:cd]]"),
3086 vec![Inline::Em {
3087 mark: "﹅".into(),
3088 children: vec![Inline::Text("ab:cd".into())]
3089 }]
3090 );
3091 }
3092
3093 #[test]
3094 fn counts_extended_graphemes_for_split_ruby() {
3095 assert_eq!(
3096 parse_inlines("{𠮟る|しか.る}"),
3097 vec![Inline::Ruby {
3098 base: "𠮟る".into(),
3099 ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
3100 }]
3101 );
3102 assert_eq!(
3103 parse_inlines("{👨👩👧|かぞく}"),
3104 vec![Inline::Ruby {
3105 base: "👨👩👧".into(),
3106 ruby: RubyReading::Group("かぞく".into())
3107 }]
3108 );
3109 }
3110
3111 #[test]
3112 fn leaves_unattached_or_stacked_block_macros_literal() {
3113 assert_eq!(
3114 parse_mdi_syntax("[[indent:2]]"),
3115 MdiSyntaxDocument {
3116 blocks: vec![MdiBlock::Paragraph {
3117 inlines: vec![Inline::Text("[[indent:2]]".into())],
3118 indent: None,
3119 bottom: None
3120 }]
3121 }
3122 );
3123 assert_eq!(
3124 parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
3125 MdiSyntaxDocument {
3126 blocks: vec![
3127 MdiBlock::Paragraph {
3128 inlines: vec![Inline::Text("[[indent:2]]".into())],
3129 indent: None,
3130 bottom: None
3131 },
3132 MdiBlock::Paragraph {
3133 inlines: vec![Inline::Text("[[bottom]]".into())],
3134 indent: None,
3135 bottom: None
3136 },
3137 MdiBlock::Paragraph {
3138 inlines: vec![Inline::Text("本文".into())],
3139 indent: None,
3140 bottom: None
3141 }
3142 ]
3143 }
3144 );
3145 }
3146
3147 #[test]
3148 fn serializes_the_versioned_binding_contract() {
3149 let value: serde_json::Value =
3150 serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
3151 .expect("parse output is valid JSON");
3152
3153 assert_eq!(value["irVersion"], "1.0");
3154 assert_eq!(value["syntaxVersion"], "2.0");
3155 assert_eq!(value["capabilities"]["mdi"], true);
3156 assert_eq!(value["capabilities"]["commonMark"], true);
3157 assert_eq!(value["capabilities"]["gfm"], true);
3158 assert_eq!(value["capabilities"]["frontMatter"], true);
3159 assert_eq!(value["capabilities"]["sourceSpans"], true);
3160 assert_eq!(value["diagnostics"], serde_json::json!([]));
3161 assert_eq!(value["document"]["children"][0]["type"], "paragraph");
3162 assert_eq!(
3163 value["document"]["children"][0]["children"][1]["type"],
3164 "tcy"
3165 );
3166 assert_eq!(value["document"]["span"]["endByte"], 43);
3167 }
3168
3169 #[test]
3170 fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
3171 let document = parse_document(
3172 "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
3173 );
3174 assert_eq!(
3175 document.frontmatter.as_ref().unwrap().entries[0].key,
3176 "title"
3177 );
3178 assert_eq!(document.children[0]["type"], "heading");
3179 assert_eq!(document.children[1]["type"], "list");
3180 assert_eq!(document.children[2]["type"], "table");
3181 assert_eq!(document.children[0]["span"]["startByte"], 43);
3182 }
3183
3184 #[test]
3185 fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
3186 let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
3187 assert_eq!(document.children[0]["type"], "blockquote");
3188 assert!(document.children.iter().any(|node| node["type"] == "code"));
3189 }
3190
3191 #[test]
3192 fn lowers_root_flow_markers_without_a_host_markdown_parser() {
3193 let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
3194 assert_eq!(document.children[0]["type"], "paragraph");
3195 assert_eq!(document.children[0]["indent"], 2);
3196 assert_eq!(document.children[1]["type"], "pagebreak");
3197 assert_eq!(document.children[1]["variant"], "right");
3198 assert_eq!(document.children[2]["type"], "blank");
3199 }
3200
3201 #[test]
3202 fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
3203 let document = parse_document("[[em:**重要**]]");
3204 let em = &document.children[0]["children"][0];
3205 assert_eq!(em["type"], "em");
3206 assert_eq!(em["mark"], "﹅");
3207 assert_eq!(em["children"][0]["type"], "strong");
3208 assert_eq!(em["children"][0]["children"][0]["value"], "重要");
3209 }
3210
3211 #[test]
3212 fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
3213 assert!(
3214 markdown_paragraph_children(
3215 "前 [[em:**重要**]] 後",
3216 &serde_json::json!({"startByte": 0, "endByte": 26})
3217 )
3218 .is_some()
3219 );
3220 let document = parse_document("前 [[em:**重要**]] 後");
3221 let children = &document.children[0]["children"];
3222 assert_eq!(children[0]["value"], "前");
3223 assert_eq!(children[1]["value"], " ");
3224 assert_eq!(children[2]["type"], "em");
3225 assert_eq!(children[2]["children"][0]["type"], "strong");
3226 assert_eq!(children[3]["value"], " ");
3227 assert_eq!(children[4]["value"], "後");
3228 assert_eq!(children[2]["span"]["startByte"], 4);
3229 assert_eq!(children[2]["span"]["endByte"], 21);
3230 assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
3231 assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
3232 }
3233
3234 #[test]
3235 fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
3236 let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
3237 let children = &document.children[0]["children"][0]["children"];
3238 assert_eq!(children[0]["type"], "ruby");
3239 assert_eq!(children[1]["type"], "noBreak");
3240 assert_eq!(children[1]["children"][0]["type"], "tcy");
3241 }
3242
3243 #[test]
3244 fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
3245 let document =
3246 parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
3247 let paragraph = &document.children[1];
3248 assert_eq!(paragraph["span"]["startByte"], 7);
3249 assert_eq!(paragraph["children"][0]["type"], "ruby");
3250 assert_eq!(paragraph["children"][0]["base"], "東京");
3251 assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
3252 assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
3253 assert_eq!(paragraph["children"][2]["type"], "em");
3254 assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
3255 assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
3256 assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
3257 assert_eq!(paragraph["children"][4]["identifier"], "n");
3258 }
3259
3260 #[test]
3261 fn renders_a_standalone_html_document_from_rust_ir() {
3262 let html = render_html(
3263 "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
3264 );
3265 assert!(html.starts_with("<!DOCTYPE html>"));
3266 assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
3267 assert!(html.contains("<title>雪女</title>"));
3268 assert!(html.contains("<h1>題</h1>"));
3269 assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
3270 assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
3271 }
3272
3273 #[test]
3274 fn renders_footnote_definitions_in_html() {
3275 let html = render_html("本文[^n]\n\n[^n]: 注の本文");
3276 assert!(html.contains("data-footnotes"));
3277 assert!(html.contains("id=\"user-content-fn-n\""));
3278 assert!(html.contains("注の本文"));
3279 }
3280
3281 #[test]
3282 fn escapes_raw_html_in_the_rust_renderer() {
3283 let html = render_html("<script>alert(1)</script>");
3284 assert!(html.contains("<script>alert(1)</script>"));
3285 assert!(!html.contains("<script>alert(1)</script>"));
3286 }
3287
3288 #[test]
3289 fn serializes_mdi_from_rust_ir() {
3290 let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
3291 assert_eq!(
3292 serialize_mdi(source),
3293 "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
3294 );
3295 }
3296
3297 #[test]
3298 fn renders_plain_text_from_rust_ir() {
3299 assert_eq!(
3300 render_text("# 題\n\n{東京|とうきょう} ^12^"),
3301 "題\n東京 12\n"
3302 );
3303 }
3304
3305 #[test]
3306 fn renders_platform_text_formats_from_rust_ir() {
3307 let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
3308 assert_eq!(
3309 render_text_format(source, TextFormat::Plain, ""),
3310 "題\n東京強調。"
3311 );
3312 assert_eq!(
3313 render_text_format(source, TextFormat::Ruby, ""),
3314 "題\n{東京|とう.きょう}強調。"
3315 );
3316 assert_eq!(
3317 render_text_format(source, TextFormat::Kakuyomu, ""),
3318 "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
3319 );
3320 assert!(
3321 render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は大見出し]")
3322 );
3323 }
3324
3325 #[test]
3326 fn renders_and_serializes_every_public_inline_and_block_variant() {
3327 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";
3328
3329 let html = render_html(source);
3330 for expected in [
3331 "<h2>中見出し</h2>",
3332 "<h3>小見出し</h3>",
3333 "<blockquote><p>引用</p>",
3334 "<ol>",
3335 "<ul>",
3336 "<pre><code class=\"language-rust\">",
3337 "<hr>",
3338 "<table>",
3339 "<a href=\"https://example.test\" title=\"題\">リンク</a>",
3340 "<img src=\"image.png\" alt=\"画像\">",
3341 "<del>削除</del>",
3342 "<code>code</code>",
3343 "<br>",
3344 "mdi-warichu",
3345 "mdi-kern",
3346 "--mdi-em:"●"",
3347 ] {
3348 assert!(html.contains(expected), "HTML contains {expected}");
3349 }
3350
3351 let canonical = serialize_mdi(source);
3352 for expected in [
3353 "[[bottom]]",
3354 "## 中見出し",
3355 "> 引用",
3356 "1. 一",
3357 "- 箇条",
3358 "```rust",
3359 "| 見出し | 値 |",
3360 "[リンク](https://example.test \\題\")",
3361 "",
3362 "~~削除~~",
3363 "`code`",
3364 "[[br]]",
3365 "[[warichu:割書]]",
3366 "[[kern:1em:字]]",
3367 "[[em:●:傍点]]",
3368 ] {
3369 assert!(
3370 canonical.contains(expected),
3371 "canonical MDI contains {expected}"
3372 );
3373 }
3374
3375 let plain = render_text(source);
3376 assert!(plain.contains("画像"));
3377 assert!(plain.contains("巢狀"));
3378
3379 for (name, format) in [
3380 ("txt", TextFormat::Plain),
3381 ("txt-ruby", TextFormat::Ruby),
3382 ("narou", TextFormat::Narou),
3383 ("kakuyomu", TextFormat::Kakuyomu),
3384 ("aozora", TextFormat::Aozora),
3385 ] {
3386 assert_eq!(TextFormat::parse(name), Some(format));
3387 assert!(!render_text_format(source, format, " ").is_empty());
3388 }
3389 assert_eq!(TextFormat::parse("unknown"), None);
3390 }
3391
3392 #[test]
3393 fn packages_an_epub_from_rust_ir() {
3394 let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
3395 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3396 let mut mimetype = String::new();
3397 zip.by_name("mimetype")
3398 .unwrap()
3399 .read_to_string(&mut mimetype)
3400 .unwrap();
3401 assert_eq!(mimetype, "application/epub+zip");
3402 let mut opf = String::new();
3403 zip.by_name("OEBPS/package.opf")
3404 .unwrap()
3405 .read_to_string(&mut opf)
3406 .unwrap();
3407 assert!(opf.contains("<dc:title>Test</dc:title>"));
3408 assert!(opf.contains("page-progression-direction=\"rtl\""));
3409 assert!(opf.contains("chapter-2.xhtml"));
3410 }
3411
3412 #[test]
3413 fn packages_a_docx_from_rust_ir() {
3414 let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
3415 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
3416 let mut document = String::new();
3417 zip.by_name("word/document.xml")
3418 .unwrap()
3419 .read_to_string(&mut document)
3420 .unwrap();
3421 assert!(document.contains("題"));
3422 assert!(document.contains("東京"));
3423 let mut core = String::new();
3424 zip.by_name("docProps/core.xml")
3425 .unwrap()
3426 .read_to_string(&mut core)
3427 .unwrap();
3428 assert!(core.contains("<dc:title>Test</dc:title>"));
3429 }
3430
3431 #[test]
3432 fn renders_pdf_with_an_available_native_chromium() {
3433 let Some(chromium_path) = find_chromium() else {
3434 return;
3435 };
3436 let pdf = render_pdf(
3437 "# 題\n\n{東京|とうきょう}",
3438 &PdfOptions {
3439 chromium_path: Some(chromium_path),
3440 },
3441 )
3442 .unwrap();
3443 assert!(pdf.starts_with(b"%PDF-"));
3444 }
3445
3446 #[test]
3447 fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
3448 for seed in 0..256 {
3449 let source = generated_source(seed);
3450 let output = parse_output(&source);
3451 assert_eq!(output.document.span.start_byte, 0);
3452 assert_eq!(output.document.span.end_byte as usize, source.len());
3453 for child in &output.document.children {
3454 assert_valid_spans(child, &source);
3455 }
3456 for diagnostic in &output.diagnostics {
3457 let span = diagnostic
3458 .span
3459 .expect("parser diagnostics have source spans");
3460 assert!(span.start_byte <= span.end_byte);
3461 assert!((span.end_byte as usize) <= source.len());
3462 }
3463
3464 let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
3465 .expect("every UTF-8 input has a serializable wire result");
3466 assert_eq!(wire["irVersion"], MDI_IR_VERSION);
3467 assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
3468 assert_valid_spans(&wire["document"], &source);
3469
3470 assert!(!render_html_document(&output.document).is_empty());
3474 let canonical = serialize_mdi_document(&output.document);
3475 let reparsed = parse_document(&canonical);
3476 for child in &reparsed.children {
3477 assert_valid_spans(child, &canonical);
3478 }
3479 let _ = render_text_document(&reparsed);
3480 }
3481 }
3482
3483 #[test]
3484 fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
3485 let cases = [
3486 "",
3487 "plain\n",
3488 "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
3489 "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
3490 "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
3491 "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
3492 "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
3493 ];
3494 for source in cases {
3495 let first = serialize_mdi(source);
3496 let second = serialize_mdi(&first);
3497 assert_eq!(
3498 second, first,
3499 "canonical output must stabilize for {source:?}"
3500 );
3501 assert_valid_spans(
3502 &serde_json::to_value(parse_document(&first)).unwrap(),
3503 &first,
3504 );
3505 }
3506 }
3507
3508 #[test]
3509 fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
3510 let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
3511
3512 let epub = render_epub(source).unwrap();
3513 let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
3514 assert_eq!(
3515 epub.by_name("mimetype").unwrap().compression(),
3516 CompressionMethod::Stored,
3517 "EPUB requires its mimetype member to be uncompressed"
3518 );
3519 for path in [
3520 "META-INF/container.xml",
3521 "OEBPS/package.opf",
3522 "OEBPS/nav.xhtml",
3523 "OEBPS/style.css",
3524 "OEBPS/chapter-1.xhtml",
3525 "OEBPS/chapter-2.xhtml",
3526 ] {
3527 assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
3528 }
3529 let mut opf = String::new();
3530 epub.by_name("OEBPS/package.opf")
3531 .unwrap()
3532 .read_to_string(&mut opf)
3533 .unwrap();
3534 assert!(opf.contains("A & < B "quoted""));
3535 assert!(opf.contains("O'Brien & Co."));
3536
3537 let docx = render_docx(source).unwrap();
3538 let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
3539 for path in [
3540 "[Content_Types].xml",
3541 "_rels/.rels",
3542 "docProps/core.xml",
3543 "word/document.xml",
3544 ] {
3545 assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
3546 }
3547 let mut core = String::new();
3548 docx.by_name("docProps/core.xml")
3549 .unwrap()
3550 .read_to_string(&mut core)
3551 .unwrap();
3552 assert!(core.contains("A & < B "quoted""));
3553 let mut document = String::new();
3554 docx.by_name("word/document.xml")
3555 .unwrap()
3556 .read_to_string(&mut document)
3557 .unwrap();
3558 assert!(document.contains("<unsafe>&"));
3559 }
3560
3561 #[test]
3562 fn classifies_every_legacy_block_macro_shape() {
3563 let amount = |value| match classify_block_macro(value) {
3564 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
3565 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
3566 };
3567 assert_eq!(amount(" [[indent:12]] "), Some(12));
3568 assert_eq!(amount("[[bottom]]"), Some(0));
3569 assert_eq!(amount("[[bottom:3]]"), Some(3));
3570 assert_eq!(amount("literal"), None);
3571 assert!(matches!(
3572 classify_block_macro("[[pagebreak]]"),
3573 BlockMacroClass::Pagebreak(None)
3574 ));
3575 assert!(matches!(
3576 classify_block_macro("[[pagebreak:left]]"),
3577 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
3578 ));
3579 assert!(matches!(
3580 classify_block_macro("[[pagebreak:right]]"),
3581 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
3582 ));
3583 for literal in [
3584 "text",
3585 "[[unknown:1]]",
3586 "[[indent:]]",
3587 "[[indent:0]]",
3588 "[[indent:01]]",
3589 "[[indent:x]]",
3590 "[[indent:999999999999999999999999999999]]",
3591 ] {
3592 assert!(matches!(
3593 classify_block_macro(literal),
3594 BlockMacroClass::Literal
3595 ));
3596 }
3597 }
3598
3599 #[test]
3600 fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
3601 let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
3602 assert_eq!(newer.diagnostics.len(), 1);
3603 assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
3604 assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
3605 assert_eq!(
3606 newer.diagnostics[0].span,
3607 newer.document.frontmatter.map(|f| f.span)
3608 );
3609
3610 for source in [
3611 "---\nmdi: '2.0'\n---\n",
3612 "---\nmdi: 3\n---\n",
3613 "---\n- sequence\n- values\n---\n",
3614 "---\n[malformed\n---\n",
3615 ] {
3616 let output = parse_output(source);
3617 assert!(output.diagnostics.is_empty());
3618 assert!(output.document.frontmatter.is_some());
3619 }
3620 }
3621
3622 #[test]
3623 fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
3624 let missing =
3625 std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
3626 let error = render_pdf(
3627 "text",
3628 &PdfOptions {
3629 chromium_path: Some(missing),
3630 },
3631 )
3632 .unwrap_err();
3633 assert!(error.contains("failed to start Chromium"));
3634
3635 let current_exe = std::env::current_exe().unwrap();
3636 let error = render_pdf(
3637 "text",
3638 &PdfOptions {
3639 chromium_path: Some(current_exe),
3640 },
3641 )
3642 .unwrap_err();
3643 assert!(error.contains("Chromium PDF rendering failed"));
3644 }
3645
3646 #[test]
3647 fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
3648 let mut scalar = serde_json::json!(null);
3649 lower_markdown_inside_mdi(&mut scalar, "");
3650 shift_spans(&mut scalar, 10);
3651 annotate_and_lower(&mut scalar, "", false);
3652 inject_block_markers(&mut scalar, &[]);
3653 assert_valid_spans(&scalar, "");
3654
3655 let mut paragraph_with_invalid_span = serde_json::json!({
3656 "type": "paragraph",
3657 "span": {"startByte": 2, "endByte": 3}
3658 });
3659 lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
3660
3661 let mut text_without_value = serde_json::json!({
3662 "type": "text",
3663 "position": {"start": {"offset": 0}, "end": {"offset": 0}}
3664 });
3665 annotate_and_lower(&mut text_without_value, "", false);
3666 let mut text_without_position = serde_json::json!({
3667 "type": "text",
3668 "value": "^12^"
3669 });
3670 annotate_and_lower(&mut text_without_position, "^12^", false);
3671 assert_eq!(text_without_position["children"][0]["type"], "tcy");
3672 let mut text_with_partial_span = serde_json::json!({
3673 "type": "text",
3674 "value": "^12^",
3675 "span": {}
3676 });
3677 annotate_and_lower(&mut text_with_partial_span, "^12^", false);
3678 assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
3679
3680 assert!(markdown_macro_children("not-a-macro", 0).is_none());
3681 assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
3682
3683 let span = SourceSpan {
3684 start_byte: 0,
3685 end_byte: 10,
3686 };
3687 for (is_indent, amount, expected) in [
3688 (true, 2, "[[indent:2]]"),
3689 (false, 0, "[[bottom]]"),
3690 (false, 3, "[[bottom:3]]"),
3691 ] {
3692 let mut output = Vec::new();
3693 let mut pending = Some((span, is_indent, amount));
3694 flush_pending_literal(&mut output, &mut pending);
3695 assert_eq!(output[0]["children"][0]["value"], expected);
3696 }
3697
3698 assert!(matches!(
3699 paragraph(
3700 "text",
3701 Some(PendingBlock::Bottom {
3702 amount: 2,
3703 source: "[[bottom:2]]".to_owned()
3704 })
3705 ),
3706 MdiBlock::Paragraph {
3707 bottom: Some(2),
3708 ..
3709 }
3710 ));
3711 assert!(pending_block("[[unknown:2]]").is_none());
3712 assert!(boten("《《》》").is_none());
3713 assert!(!valid_kern("1.em"));
3714 assert!(!valid_kern("1.2.3em"));
3715 assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
3716
3717 let mut writer = FailAfterWrites {
3718 inner: Cursor::new(Vec::new()),
3719 remaining: 1,
3720 };
3721 writer.flush().unwrap();
3722
3723 let mut formatted = Vec::new();
3724 text_format_block(
3725 &serde_json::json!({"type": "blank"}),
3726 TextFormat::Plain,
3727 "",
3728 &[],
3729 &mut formatted,
3730 );
3731 text_format_block(
3732 &serde_json::json!({"type": "unknown"}),
3733 TextFormat::Plain,
3734 "",
3735 &[],
3736 &mut formatted,
3737 );
3738 assert_eq!(formatted, vec![String::new()]);
3739
3740 assert_eq!(
3741 text_format_inline(
3742 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
3743 TextFormat::Ruby,
3744 &[]
3745 ),
3746 "{字|じ}"
3747 );
3748 assert_eq!(
3749 text_format_inline(
3750 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
3751 TextFormat::Plain,
3752 &[]
3753 ),
3754 "字"
3755 );
3756 assert_eq!(
3757 text_format_inline(
3758 &serde_json::json!({"type":"image", "alt":""}),
3759 TextFormat::Plain,
3760 &[]
3761 ),
3762 "[画像]"
3763 );
3764
3765 let mut html = String::new();
3766 render_html_node(&serde_json::json!({}), &mut html);
3767 render_html_node(
3768 &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
3769 &mut html,
3770 );
3771 render_html_children(&serde_json::json!({"type":"root"}), &mut html);
3772 assert_eq!(html, "ok");
3773
3774 let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
3775 assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
3776 let heading = parse_document("[[indent:2]]\n# heading");
3777 assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
3778 let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
3779 assert_eq!(trailing.children.len(), 2);
3780
3781 let partial_document = Document {
3782 span: SourceSpan::default(),
3783 frontmatter: None,
3784 children: vec![serde_json::json!({
3785 "type": "root",
3786 "children": [{"type":"text", "value":"partial"}]
3787 })],
3788 };
3789 assert!(render_docx_document(&partial_document).is_ok());
3790 }
3791
3792 #[test]
3793 fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
3794 let empty = render_epub("").unwrap();
3795 let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
3796 assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
3797
3798 let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
3799 let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
3800 assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
3801 }
3802
3803 #[test]
3804 fn archive_writers_propagate_failures_from_every_write_stage() {
3805 let document = parse_document(
3806 "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
3807 );
3808
3809 let mut epub_errors = 0;
3810 let mut epub_success = false;
3811 for remaining in 0..256 {
3812 let writer = FailAfterWrites {
3813 inner: Cursor::new(Vec::new()),
3814 remaining,
3815 };
3816 let mut zip = ZipWriter::new(writer);
3817 let result = write_epub_document(&document, &mut zip);
3818 if result.is_err() {
3819 epub_errors += 1;
3820 } else {
3821 epub_success = true;
3822 }
3823 std::mem::forget(zip);
3827 if epub_success {
3828 break;
3829 }
3830 }
3831 assert!(epub_errors > 0);
3832 assert!(epub_success);
3833
3834 let mut docx_errors = 0;
3835 let mut docx_success = false;
3836 for remaining in 0..256 {
3837 let writer = FailAfterWrites {
3838 inner: Cursor::new(Vec::new()),
3839 remaining,
3840 };
3841 let mut zip = ZipWriter::new(writer);
3842 let result = write_docx_document(&document, &mut zip);
3843 if result.is_err() {
3844 docx_errors += 1;
3845 } else {
3846 docx_success = true;
3847 }
3848 std::mem::forget(zip);
3849 if docx_success {
3850 break;
3851 }
3852 }
3853 assert!(docx_errors > 0);
3854 assert!(docx_success);
3855 }
3856}