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