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::{parse_json, render_docx, render_epub, render_html, render_text, serialize_mdi};
970 use std::slice;
971
972 #[repr(C)]
973 #[derive(Debug, Clone, Copy)]
974 pub struct MdiFfiBuffer {
975 pub data: *mut u8,
976 pub len: usize,
977 }
978
979 #[repr(C)]
980 #[derive(Debug, Clone, Copy)]
981 pub struct MdiFfiResult {
982 pub value: MdiFfiBuffer,
983 pub error: MdiFfiBuffer,
984 }
985
986 fn empty_buffer() -> MdiFfiBuffer {
987 MdiFfiBuffer {
988 data: std::ptr::null_mut(),
989 len: 0,
990 }
991 }
992
993 fn buffer(value: Vec<u8>) -> MdiFfiBuffer {
994 if value.is_empty() {
995 return empty_buffer();
996 }
997 let mut value = value.into_boxed_slice();
998 let result = MdiFfiBuffer {
999 data: value.as_mut_ptr(),
1000 len: value.len(),
1001 };
1002 std::mem::forget(value);
1003 result
1004 }
1005
1006 fn success(value: Vec<u8>) -> MdiFfiResult {
1007 MdiFfiResult {
1008 value: buffer(value),
1009 error: empty_buffer(),
1010 }
1011 }
1012
1013 fn failure(message: impl Into<String>) -> MdiFfiResult {
1014 MdiFfiResult {
1015 value: empty_buffer(),
1016 error: buffer(message.into().into_bytes()),
1017 }
1018 }
1019
1020 fn source<'a>(data: *const u8, len: usize) -> Result<&'a str, String> {
1021 if data.is_null() && len != 0 {
1022 return Err("MDI source pointer is null".to_owned());
1023 }
1024 let bytes = if len == 0 {
1025 &[]
1026 } else {
1027 unsafe { slice::from_raw_parts(data, len) }
1028 };
1029 std::str::from_utf8(bytes).map_err(|_| "MDI source must be valid UTF-8".to_owned())
1030 }
1031
1032 fn string_result(
1033 data: *const u8,
1034 len: usize,
1035 operation: impl FnOnce(&str) -> String,
1036 ) -> MdiFfiResult {
1037 match source(data, len) {
1038 Ok(source) => success(operation(source).into_bytes()),
1039 Err(error) => failure(error),
1040 }
1041 }
1042
1043 #[unsafe(no_mangle)]
1044 pub extern "C" fn mdi_parse_json(data: *const u8, len: usize) -> MdiFfiResult {
1045 string_result(data, len, parse_json)
1046 }
1047 #[unsafe(no_mangle)]
1048 pub extern "C" fn mdi_render_html(data: *const u8, len: usize) -> MdiFfiResult {
1049 string_result(data, len, render_html)
1050 }
1051 #[unsafe(no_mangle)]
1052 pub extern "C" fn mdi_serialize_mdi(data: *const u8, len: usize) -> MdiFfiResult {
1053 string_result(data, len, serialize_mdi)
1054 }
1055 #[unsafe(no_mangle)]
1056 pub extern "C" fn mdi_render_text(data: *const u8, len: usize) -> MdiFfiResult {
1057 string_result(data, len, render_text)
1058 }
1059
1060 fn binary_result(
1061 data: *const u8,
1062 len: usize,
1063 operation: impl FnOnce(&str) -> Result<Vec<u8>, String>,
1064 ) -> MdiFfiResult {
1065 match source(data, len).and_then(operation) {
1066 Ok(value) => success(value),
1067 Err(error) => failure(error),
1068 }
1069 }
1070
1071 #[unsafe(no_mangle)]
1072 pub extern "C" fn mdi_render_epub(data: *const u8, len: usize) -> MdiFfiResult {
1073 binary_result(data, len, render_epub)
1074 }
1075 #[unsafe(no_mangle)]
1076 pub extern "C" fn mdi_render_docx(data: *const u8, len: usize) -> MdiFfiResult {
1077 binary_result(data, len, render_docx)
1078 }
1079
1080 #[unsafe(no_mangle)]
1087 pub unsafe extern "C" fn mdi_free_buffer(buffer: MdiFfiBuffer) {
1088 if !buffer.data.is_null() && buffer.len != 0 {
1089 unsafe {
1090 drop(Vec::from_raw_parts(buffer.data, buffer.len, buffer.len));
1091 }
1092 }
1093 }
1094}
1095
1096pub fn render_html(source: &str) -> String {
1101 render_html_document(&parse_document(source))
1102}
1103
1104pub fn render_html_document(document: &Document) -> String {
1106 let frontmatter = document.frontmatter.as_ref();
1107 let field = |key: &str| {
1108 frontmatter
1109 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
1110 .and_then(|entry| entry.value.as_str())
1111 };
1112 let lang = field("lang").unwrap_or("ja");
1113 let title = field("title")
1114 .map(|title| format!("<title>{}</title>", escape_html(title)))
1115 .unwrap_or_default();
1116 let vertical = matches!(field("writing-mode"), Some("vertical"));
1117 let writing_mode = if vertical {
1118 " style=\"writing-mode: vertical-rl;\""
1119 } else {
1120 ""
1121 };
1122 let mut body = String::new();
1123 let mut footnotes = Vec::new();
1124 for child in &document.children {
1125 if child.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
1126 footnotes.push(child);
1127 } else {
1128 render_html_node(child, &mut body);
1129 }
1130 }
1131 if !footnotes.is_empty() {
1132 body.push_str("<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\" id=\"footnote-label\">Footnotes</h2><ol>");
1133 for (index, footnote) in footnotes.into_iter().enumerate() {
1134 let identifier = footnote
1135 .get("identifier")
1136 .and_then(serde_json::Value::as_str)
1137 .map(str::to_owned)
1138 .unwrap_or_else(|| format!("{}", index + 1));
1139 body.push_str("<li id=\"user-content-fn-");
1140 body.push_str(&escape_html(&identifier));
1141 body.push_str("\">");
1142 render_html_children(footnote, &mut body);
1143 body.push_str(" <a href=\"#user-content-fnref-");
1144 body.push_str(&escape_html(&identifier));
1145 body.push_str("\" data-footnote-backref=\"\" aria-label=\"Back to reference\" class=\"data-footnote-backref\">↩</a>");
1146 body.push_str("</li>");
1147 }
1148 body.push_str("</ol></section>");
1149 }
1150 format!(
1151 "<!DOCTYPE html><html lang=\"{}\"{}><head><meta charset=\"utf-8\">{}<style>{}</style></head><body>{}</body></html>",
1152 escape_html(lang),
1153 writing_mode,
1154 title,
1155 MDI_STYLESHEET,
1156 body
1157 )
1158}
1159
1160pub fn serialize_mdi(source: &str) -> String {
1162 serialize_mdi_document(&parse_document(source))
1163}
1164
1165pub fn serialize_mdi_document(document: &Document) -> String {
1167 let mut output = String::new();
1168 if let Some(frontmatter) = &document.frontmatter {
1169 output.push_str("---\n");
1170 output.push_str(frontmatter.raw.trim_end_matches(['\r', '\n']));
1171 output.push_str("\n---\n\n");
1172 }
1173 for (index, node) in document.children.iter().enumerate() {
1174 if index > 0 && !output.ends_with("\n\n") {
1175 output.push('\n');
1176 }
1177 serialize_block(node, &mut output, "");
1178 }
1179 output
1180}
1181
1182pub fn render_text(source: &str) -> String {
1186 render_text_document(&parse_document(source))
1187}
1188
1189pub fn render_text_document(document: &Document) -> String {
1191 let mut output = String::new();
1192 for node in &document.children {
1193 render_text_node(node, &mut output);
1194 if !output.ends_with('\n') {
1195 output.push('\n');
1196 }
1197 }
1198 output
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1203pub enum TextFormat {
1204 Plain,
1205 Ruby,
1206 Narou,
1207 Kakuyomu,
1208 Aozora,
1209}
1210
1211impl TextFormat {
1212 pub fn parse(value: &str) -> Option<Self> {
1214 match value {
1215 "txt" => Some(Self::Plain),
1216 "txt-ruby" => Some(Self::Ruby),
1217 "narou" => Some(Self::Narou),
1218 "kakuyomu" => Some(Self::Kakuyomu),
1219 "aozora" => Some(Self::Aozora),
1220 _ => None,
1221 }
1222 }
1223}
1224
1225pub fn render_text_format(source: &str, format: TextFormat, indent_prefix: &str) -> String {
1228 let document = parse_document(source);
1229 let mut heading_depths = document
1230 .children
1231 .iter()
1232 .filter(|node| node.get("type").and_then(serde_json::Value::as_str) == Some("heading"))
1233 .filter_map(|node| node.get("depth").and_then(serde_json::Value::as_u64))
1234 .collect::<Vec<_>>();
1235 heading_depths.sort_unstable();
1236 heading_depths.dedup();
1237 let definitions: Vec<&serde_json::Value> = document
1238 .children
1239 .iter()
1240 .filter(|node| {
1241 node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition")
1242 })
1243 .collect();
1244 let mut blocks = Vec::new();
1245 for node in &document.children {
1246 text_format_block(
1247 node,
1248 format,
1249 indent_prefix,
1250 &definitions,
1251 &heading_depths,
1252 &mut blocks,
1253 );
1254 }
1255 if !matches!(format, TextFormat::Plain | TextFormat::Ruby) && !definitions.is_empty() {
1256 blocks.push(String::new());
1257 blocks.push("Footnotes".to_owned());
1258 for (index, definition) in definitions.iter().enumerate() {
1259 let text = children(definition)
1260 .iter()
1261 .filter(|child| {
1262 child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph")
1263 })
1264 .map(|paragraph| text_format_inline_children(paragraph, format, &definitions))
1265 .collect::<Vec<_>>()
1266 .join(" ");
1267 blocks.push(format!("{}. {text}", index + 1));
1268 }
1269 }
1270 if matches!(format, TextFormat::Aozora) && heading_depths.len() > 3 {
1271 blocks.push(String::new());
1272 blocks.push("※小見出しよりもさらに下位の見出しには、注記しませんでした。".to_owned());
1273 }
1274 let output = blocks.join("\n");
1275 if matches!(format, TextFormat::Aozora) {
1276 output.replace('\n', "\r\n")
1277 } else {
1278 output
1279 }
1280}
1281
1282fn text_format_block(
1283 node: &serde_json::Value,
1284 format: TextFormat,
1285 prefix: &str,
1286 definitions: &[&serde_json::Value],
1287 heading_depths: &[u64],
1288 output: &mut Vec<String>,
1289) {
1290 let kind = node
1291 .get("type")
1292 .and_then(serde_json::Value::as_str)
1293 .unwrap_or_default();
1294 match kind {
1295 "footnoteDefinition" | "definition" => {}
1296 "paragraph" => {
1297 let value = text_format_inline_children(node, format, definitions);
1298 let block_prefix = text_format_block_prefix(node, format);
1299 output.push(format!("{prefix}{block_prefix}{value}"));
1300 }
1301 "heading" => {
1302 let value = text_format_inline_children(node, format, definitions);
1303 if matches!(format, TextFormat::Aozora) {
1304 let depth = node
1305 .get("depth")
1306 .and_then(serde_json::Value::as_u64)
1307 .unwrap_or(3);
1308 if let Some(size) = aozora_heading_size(depth, heading_depths) {
1309 let reference = text_format_plain_inline_children(node);
1310 if aozora_needs_range_annotation(node) {
1311 output.push(format!("[#{size}見出し]{value}[#{size}見出し終わり]"));
1312 } else {
1313 output.push(format!("{value}[#「{reference}」は{size}見出し]"));
1314 }
1315 } else {
1316 output.push(value);
1317 }
1318 } else {
1319 output.push(value);
1320 }
1321 }
1322 "list" => {
1323 for (index, item) in children(node).iter().enumerate() {
1324 for child in children(item) {
1325 if child.get("type").and_then(serde_json::Value::as_str) == Some("paragraph") {
1326 let bullet = if node
1327 .get("ordered")
1328 .and_then(serde_json::Value::as_bool)
1329 .unwrap_or(false)
1330 {
1331 format!("{}. ", index + 1)
1332 } else {
1333 "- ".to_owned()
1334 };
1335 output.push(format!(
1336 "{prefix}{bullet}{}",
1337 text_format_inline_children(child, format, definitions)
1338 ));
1339 } else {
1340 text_format_block(
1341 child,
1342 format,
1343 prefix,
1344 definitions,
1345 heading_depths,
1346 output,
1347 );
1348 }
1349 }
1350 }
1351 }
1352 "blockquote" => {
1353 for child in children(node) {
1354 text_format_block(child, format, prefix, definitions, heading_depths, output);
1355 }
1356 }
1357 "code" => output.extend(
1358 node.get("value")
1359 .and_then(serde_json::Value::as_str)
1360 .unwrap_or_default()
1361 .lines()
1362 .map(|line| text_format_literal(line, format)),
1363 ),
1364 "table" => {
1365 for row in children(node) {
1366 output.push(
1367 children(row)
1368 .iter()
1369 .map(|cell| text_format_inline_children(cell, format, definitions))
1370 .collect::<Vec<_>>()
1371 .join("\t"),
1372 );
1373 }
1374 }
1375 "thematicBreak" => output.push("――――――".to_owned()),
1376 "blank" => output.push(String::new()),
1377 "pagebreak" => {
1378 if matches!(format, TextFormat::Aozora) {
1379 let annotation = match node.get("variant").and_then(serde_json::Value::as_str) {
1380 Some("left") => "[#改丁]",
1381 Some("right") => "[#改見開き]",
1382 _ => "[#改ページ]",
1383 };
1384 output.push(annotation.to_owned());
1385 } else {
1386 output.push(String::new());
1387 }
1388 }
1389 _ => {}
1390 }
1391}
1392
1393fn aozora_heading_size(depth: u64, heading_depths: &[u64]) -> Option<&'static str> {
1394 let index = heading_depths
1395 .iter()
1396 .position(|candidate| *candidate == depth)?;
1397 match heading_depths.len() {
1398 1 => Some("中"),
1399 2 => ["大", "中"].get(index).copied(),
1400 _ => ["大", "中", "小"].get(index).copied(),
1401 }
1402}
1403
1404fn text_format_block_prefix(node: &serde_json::Value, format: TextFormat) -> String {
1405 let indent = node
1406 .get("indent")
1407 .and_then(serde_json::Value::as_u64)
1408 .filter(|amount| *amount > 0);
1409 let bottom = node.get("bottom").and_then(serde_json::Value::as_u64);
1410 if matches!(format, TextFormat::Aozora) {
1411 if let Some(amount) = bottom {
1412 return if amount == 0 {
1413 "[#地付き]".to_owned()
1414 } else {
1415 format!("[#地から{}字上げ]", fullwidth_digits(amount))
1416 };
1417 }
1418 return indent
1419 .map(|amount| format!("[#{}字下げ]", fullwidth_digits(amount)))
1420 .unwrap_or_default();
1421 }
1422 indent
1423 .map(|amount| " ".repeat(amount as usize))
1424 .unwrap_or_default()
1425}
1426
1427fn fullwidth_digits(value: u64) -> String {
1428 value
1429 .to_string()
1430 .replace('0', "0")
1431 .replace('1', "1")
1432 .replace('2', "2")
1433 .replace('3', "3")
1434 .replace('4', "4")
1435 .replace('5', "5")
1436 .replace('6', "6")
1437 .replace('7', "7")
1438 .replace('8', "8")
1439 .replace('9', "9")
1440}
1441
1442fn text_format_inline_children(
1443 node: &serde_json::Value,
1444 format: TextFormat,
1445 definitions: &[&serde_json::Value],
1446) -> String {
1447 children(node)
1448 .iter()
1449 .map(|node| text_format_inline(node, format, definitions))
1450 .collect()
1451}
1452fn text_format_inline(
1453 node: &serde_json::Value,
1454 format: TextFormat,
1455 definitions: &[&serde_json::Value],
1456) -> String {
1457 match node
1458 .get("type")
1459 .and_then(serde_json::Value::as_str)
1460 .unwrap_or_default()
1461 {
1462 "text" | "inlineCode" => text_format_literal(
1463 node.get("value")
1464 .and_then(serde_json::Value::as_str)
1465 .unwrap_or_default(),
1466 format,
1467 ),
1468 "tcy" => {
1469 let value = text_format_literal(
1470 node.get("value")
1471 .and_then(serde_json::Value::as_str)
1472 .unwrap_or_default(),
1473 format,
1474 );
1475 if matches!(format, TextFormat::Aozora) {
1476 format!("{value}[#「{value}」は縦中横]")
1477 } else {
1478 value
1479 }
1480 }
1481 "break" => "\n".to_owned(),
1482 "ruby" => {
1483 let base = node
1484 .get("base")
1485 .and_then(serde_json::Value::as_str)
1486 .unwrap_or_default();
1487 let reading = node
1488 .pointer("/ruby/value")
1489 .map(|value| match value {
1490 serde_json::Value::Array(parts) => parts
1491 .iter()
1492 .filter_map(serde_json::Value::as_str)
1493 .collect::<Vec<_>>()
1494 .join(if matches!(format, TextFormat::Ruby) {
1495 "."
1496 } else {
1497 ""
1498 }),
1499 serde_json::Value::String(value) => value.to_owned(),
1500 _ => String::new(),
1501 })
1502 .unwrap_or_default();
1503 match format {
1504 TextFormat::Plain => base.to_owned(),
1505 TextFormat::Ruby => format!("{{{base}|{reading}}}"),
1506 _ => text_format_platform_ruby(base, &reading, format),
1507 }
1508 }
1509 "em" => {
1510 let value = text_format_inline_children(node, format, definitions);
1511 match format {
1512 TextFormat::Aozora if !value.is_empty() && !value.contains('\n') => {
1513 let name = aozora_boten_name(
1514 node.get("mark")
1515 .and_then(serde_json::Value::as_str)
1516 .unwrap_or("﹅"),
1517 );
1518 format!("[#{name}]{value}[#{name}終わり]")
1519 }
1520 TextFormat::Kakuyomu
1521 if !value.is_empty()
1522 && !value.contains('\n')
1523 && !value.contains('《')
1524 && !node_contains_type(node, "ruby") =>
1525 {
1526 format!("《《{value}》》")
1527 }
1528 TextFormat::Narou
1529 if !value.is_empty()
1530 && !value.contains('\n')
1531 && !node_contains_type(node, "ruby") =>
1532 {
1533 value
1534 .graphemes(true)
1535 .map(|character| {
1536 text_format_platform_ruby(character, "・", TextFormat::Narou)
1537 })
1538 .collect()
1539 }
1540 _ => value,
1541 }
1542 }
1543 "image" => node
1544 .get("alt")
1545 .and_then(serde_json::Value::as_str)
1546 .filter(|alt| !alt.is_empty())
1547 .map(|alt| format!("[画像: {}]", text_format_literal(alt, format)))
1548 .unwrap_or_else(|| "[画像]".to_owned()),
1549 "warichu" => {
1550 let value = text_format_inline_children(node, format, definitions);
1551 if matches!(format, TextFormat::Aozora) && !value.is_empty() && !value.contains('\n') {
1552 format!("[#割り注]{value}[#割り注終わり]")
1553 } else {
1554 value
1555 }
1556 }
1557 "footnoteReference" => {
1558 if matches!(format, TextFormat::Plain | TextFormat::Ruby) {
1559 String::new()
1560 } else {
1561 let identifier = node
1562 .get("identifier")
1563 .and_then(serde_json::Value::as_str)
1564 .unwrap_or_default();
1565 let index = definitions
1566 .iter()
1567 .position(|definition| {
1568 definition
1569 .get("identifier")
1570 .and_then(serde_json::Value::as_str)
1571 == Some(identifier)
1572 })
1573 .map(|index| index + 1)
1574 .unwrap_or(0);
1575 if matches!(format, TextFormat::Aozora) {
1576 format!("(注{index})")
1577 } else {
1578 format!("[注{index}]")
1579 }
1580 }
1581 }
1582 _ => text_format_inline_children(node, format, definitions),
1583 }
1584}
1585
1586fn text_format_platform_ruby(base: &str, reading: &str, format: TextFormat) -> String {
1587 let valid = match format {
1588 TextFormat::Narou => {
1589 (1..=10).contains(&base.graphemes(true).count())
1590 && (1..=10).contains(&reading.graphemes(true).count())
1591 && !base.chars().any(narou_ruby_problem_character)
1592 && !reading.chars().any(narou_ruby_problem_character)
1593 }
1594 TextFormat::Kakuyomu => {
1595 (1..=20).contains(&base.graphemes(true).count())
1596 && (1..=50).contains(&reading.graphemes(true).count())
1597 && !base.contains(['\r', '\n'])
1598 && !reading.contains(['\r', '\n'])
1599 && !base.contains(['《', '》'])
1600 && !reading.contains(['《', '》'])
1601 }
1602 TextFormat::Aozora => {
1603 !base.is_empty()
1604 && !reading.is_empty()
1605 && !base.contains(['\r', '\n'])
1606 && !reading.contains(['\r', '\n'])
1607 && !base.chars().any(aozora_reserved_character)
1608 && !reading.chars().any(aozora_reserved_character)
1609 }
1610 TextFormat::Plain | TextFormat::Ruby => false,
1611 };
1612 if valid {
1613 format!("|{base}《{reading}》")
1614 } else {
1615 text_format_literal(base, format)
1616 }
1617}
1618
1619fn narou_ruby_problem_character(character: char) -> bool {
1620 matches!(character, '&' | '"' | '<' | '>')
1621}
1622
1623fn aozora_boten_name(mark: &str) -> &'static str {
1624 match mark {
1625 "﹆" => "白ゴマ傍点",
1626 "●" => "丸傍点",
1627 "○" => "白丸傍点",
1628 "▲" => "黒三角傍点",
1629 "△" => "白三角傍点",
1630 "◎" => "二重丸傍点",
1631 "×" => "ばつ傍点",
1632 _ => "傍点",
1633 }
1634}
1635
1636fn node_contains_type(node: &serde_json::Value, expected: &str) -> bool {
1637 node.get("type").and_then(serde_json::Value::as_str) == Some(expected)
1638 || children(node)
1639 .iter()
1640 .any(|child| node_contains_type(child, expected))
1641}
1642
1643fn text_format_plain_inline_children(node: &serde_json::Value) -> String {
1644 children(node)
1645 .iter()
1646 .map(text_format_plain_inline)
1647 .collect()
1648}
1649
1650fn text_format_plain_inline(node: &serde_json::Value) -> String {
1651 match node
1652 .get("type")
1653 .and_then(serde_json::Value::as_str)
1654 .unwrap_or_default()
1655 {
1656 "text" | "inlineCode" | "tcy" => node
1657 .get("value")
1658 .and_then(serde_json::Value::as_str)
1659 .unwrap_or_default()
1660 .to_owned(),
1661 "ruby" => node
1662 .get("base")
1663 .and_then(serde_json::Value::as_str)
1664 .unwrap_or_default()
1665 .to_owned(),
1666 "break" => "\n".to_owned(),
1667 "image" => node
1668 .get("alt")
1669 .and_then(serde_json::Value::as_str)
1670 .unwrap_or_default()
1671 .to_owned(),
1672 _ => text_format_plain_inline_children(node),
1673 }
1674}
1675
1676fn aozora_needs_range_annotation(node: &serde_json::Value) -> bool {
1677 node_contains_type(node, "em")
1678 || text_format_plain_inline_children(node)
1679 .chars()
1680 .any(aozora_reserved_character)
1681}
1682
1683fn aozora_reserved_character(character: char) -> bool {
1684 matches!(
1685 character,
1686 '《' | '》' | '[' | ']' | '〔' | '〕' | '|' | '#' | '※'
1687 )
1688}
1689
1690fn text_format_literal(value: &str, format: TextFormat) -> String {
1691 match format {
1692 TextFormat::Kakuyomu => value.replace('《', "|《"),
1693 TextFormat::Narou => value.replace('(', "|(").replace('(', "|("),
1694 TextFormat::Aozora => value
1695 .chars()
1696 .map(|character| match character {
1697 '《' => "※[#始め二重山括弧、1-1-52]".to_owned(),
1698 '》' => "※[#終わり二重山括弧、1-1-53]".to_owned(),
1699 '[' => "※[#始め角括弧、1-1-46]".to_owned(),
1700 ']' => "※[#終わり角括弧、1-1-47]".to_owned(),
1701 '〔' => "※[#始めきっこう(亀甲)括弧、1-1-44]".to_owned(),
1702 '〕' => "※[#終わりきっこう(亀甲)括弧、1-1-45]".to_owned(),
1703 '|' => "※[#縦線、1-1-35]".to_owned(),
1704 '#' => "※[#井げた、1-1-84]".to_owned(),
1705 '※' => "※[#米印、1-2-8]".to_owned(),
1706 _ => character.to_string(),
1707 })
1708 .collect(),
1709 TextFormat::Plain | TextFormat::Ruby => value.to_owned(),
1710 }
1711}
1712
1713fn render_text_node(node: &serde_json::Value, out: &mut String) {
1714 match node
1715 .get("type")
1716 .and_then(serde_json::Value::as_str)
1717 .unwrap_or_default()
1718 {
1719 "text" | "inlineCode" | "code" | "html" | "tcy" => out.push_str(
1720 node.get("value")
1721 .and_then(serde_json::Value::as_str)
1722 .unwrap_or_default(),
1723 ),
1724 "ruby" => out.push_str(
1725 node.get("base")
1726 .and_then(serde_json::Value::as_str)
1727 .unwrap_or_default(),
1728 ),
1729 "image" => out.push_str(
1730 node.get("alt")
1731 .and_then(serde_json::Value::as_str)
1732 .unwrap_or_default(),
1733 ),
1734 "break" => out.push('\n'),
1735 "blank" => out.push('\n'),
1736 "pagebreak" => out.push_str("\n\x0C\n"),
1737 "heading" | "paragraph" | "blockquote" | "listItem" | "tableRow" => {
1738 render_text_children(node, out);
1739 out.push('\n');
1740 }
1741 "tableCell" => {
1742 render_text_children(node, out);
1743 out.push('\t');
1744 }
1745 _ => render_text_children(node, out),
1746 }
1747}
1748
1749fn render_text_children(node: &serde_json::Value, out: &mut String) {
1750 for child in children(node) {
1751 render_text_node(child, out);
1752 }
1753}
1754
1755fn serialize_block(node: &serde_json::Value, out: &mut String, prefix: &str) {
1756 let kind = node
1757 .get("type")
1758 .and_then(serde_json::Value::as_str)
1759 .unwrap_or_default();
1760 match kind {
1761 "paragraph" => {
1762 if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64) {
1763 out.push_str(prefix);
1764 out.push_str(&format!("[[indent:{amount}]]\n"));
1765 }
1766 if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
1767 out.push_str(prefix);
1768 if amount == 0 {
1769 out.push_str("[[bottom]]\n");
1770 } else {
1771 out.push_str(&format!("[[bottom:{amount}]]\n"));
1772 }
1773 }
1774 out.push_str(prefix);
1775 serialize_inline_children(node, out);
1776 out.push('\n');
1777 }
1778 "heading" => {
1779 out.push_str(prefix);
1780 out.push_str(
1781 &"#".repeat(
1782 node.get("depth")
1783 .and_then(serde_json::Value::as_u64)
1784 .unwrap_or(1) as usize,
1785 ),
1786 );
1787 out.push(' ');
1788 serialize_inline_children(node, out);
1789 out.push('\n');
1790 }
1791 "blockquote" => {
1792 let mut content = String::new();
1793 for child in children(node) {
1794 serialize_block(child, &mut content, "");
1795 }
1796 for line in content.trim_end_matches('\n').lines() {
1797 out.push_str(prefix);
1798 out.push_str("> ");
1799 out.push_str(line);
1800 out.push('\n');
1801 }
1802 }
1803 "list" => {
1804 let ordered = node
1805 .get("ordered")
1806 .and_then(serde_json::Value::as_bool)
1807 .unwrap_or(false);
1808 let start = node
1809 .get("start")
1810 .and_then(serde_json::Value::as_u64)
1811 .unwrap_or(1);
1812 for (index, item) in children(node).iter().enumerate() {
1813 out.push_str(prefix);
1814 if ordered {
1815 out.push_str(&format!("{}.", start + index as u64));
1816 } else {
1817 out.push('-');
1818 }
1819 out.push(' ');
1820 if let Some(first) = children(item).first() {
1821 serialize_block(first, out, "");
1822 }
1823 for child in children(item).iter().skip(1) {
1824 serialize_block(child, out, " ");
1825 }
1826 }
1827 }
1828 "code" => {
1829 out.push_str(prefix);
1830 out.push_str("```");
1831 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
1832 out.push_str(lang);
1833 }
1834 out.push('\n');
1835 out.push_str(
1836 node.get("value")
1837 .and_then(serde_json::Value::as_str)
1838 .unwrap_or_default(),
1839 );
1840 out.push_str("\n```\n");
1841 }
1842 "thematicBreak" => out.push_str("---\n"),
1843 "blank" => out.push_str("\\\n"),
1844 "pagebreak" => {
1845 out.push_str("[[pagebreak");
1846 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
1847 out.push(':');
1848 out.push_str(variant);
1849 }
1850 out.push_str("]]\n");
1851 }
1852 "table" => serialize_table(node, out),
1853 "html" => {
1854 out.push_str(
1855 node.get("value")
1856 .and_then(serde_json::Value::as_str)
1857 .unwrap_or_default(),
1858 );
1859 out.push('\n');
1860 }
1861 _ => {
1862 serialize_inline(node, out);
1863 out.push('\n');
1864 }
1865 }
1866}
1867
1868fn serialize_table(node: &serde_json::Value, out: &mut String) {
1869 for (row_index, row) in children(node).iter().enumerate() {
1870 out.push('|');
1871 for cell in children(row) {
1872 out.push(' ');
1873 serialize_inline_children(cell, out);
1874 out.push_str(" |");
1875 }
1876 out.push('\n');
1877 if row_index == 0 {
1878 out.push('|');
1879 for _ in children(row) {
1880 out.push_str(" --- |");
1881 }
1882 out.push('\n');
1883 }
1884 }
1885}
1886
1887fn serialize_inline_children(node: &serde_json::Value, out: &mut String) {
1888 for child in children(node) {
1889 serialize_inline(child, out);
1890 }
1891}
1892
1893fn serialize_inline(node: &serde_json::Value, out: &mut String) {
1894 let kind = node
1895 .get("type")
1896 .and_then(serde_json::Value::as_str)
1897 .unwrap_or_default();
1898 match kind {
1899 "text" | "html" => out.push_str(
1900 node.get("value")
1901 .and_then(serde_json::Value::as_str)
1902 .unwrap_or_default(),
1903 ),
1904 "emphasis" => {
1905 out.push('*');
1906 serialize_inline_children(node, out);
1907 out.push('*');
1908 }
1909 "strong" => {
1910 out.push_str("**");
1911 serialize_inline_children(node, out);
1912 out.push_str("**");
1913 }
1914 "delete" => {
1915 out.push_str("~~");
1916 serialize_inline_children(node, out);
1917 out.push_str("~~");
1918 }
1919 "inlineCode" => {
1920 out.push('`');
1921 out.push_str(
1922 node.get("value")
1923 .and_then(serde_json::Value::as_str)
1924 .unwrap_or_default(),
1925 );
1926 out.push('`');
1927 }
1928 "link" => {
1929 out.push('[');
1930 serialize_inline_children(node, out);
1931 out.push_str("](");
1932 out.push_str(
1933 node.get("url")
1934 .and_then(serde_json::Value::as_str)
1935 .unwrap_or_default(),
1936 );
1937 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
1938 out.push_str(" \\");
1939 out.push_str(title);
1940 out.push('\"');
1941 }
1942 out.push(')');
1943 }
1944 "image" => {
1945 out.push_str(";
1952 out.push_str(
1953 node.get("url")
1954 .and_then(serde_json::Value::as_str)
1955 .unwrap_or_default(),
1956 );
1957 out.push(')');
1958 }
1959 "ruby" => {
1960 out.push('{');
1961 out.push_str(
1962 node.get("base")
1963 .and_then(serde_json::Value::as_str)
1964 .unwrap_or_default(),
1965 );
1966 out.push('|');
1967 if let Some(values) = node
1968 .pointer("/ruby/value")
1969 .and_then(serde_json::Value::as_array)
1970 {
1971 for (index, value) in values.iter().enumerate() {
1972 if index > 0 {
1973 out.push('.');
1974 }
1975 out.push_str(value.as_str().unwrap_or_default());
1976 }
1977 } else {
1978 out.push_str(
1979 node.pointer("/ruby/value")
1980 .and_then(serde_json::Value::as_str)
1981 .unwrap_or_default(),
1982 );
1983 }
1984 out.push('}');
1985 }
1986 "tcy" => {
1987 out.push('^');
1988 out.push_str(
1989 node.get("value")
1990 .and_then(serde_json::Value::as_str)
1991 .unwrap_or_default(),
1992 );
1993 out.push('^');
1994 }
1995 "break" => out.push_str("[[br]]"),
1998 "em" => {
1999 out.push_str("[[em:");
2000 let mark = node
2001 .get("mark")
2002 .and_then(serde_json::Value::as_str)
2003 .unwrap_or("﹅");
2004 if mark != "﹅" {
2005 out.push_str(mark);
2006 out.push(':');
2007 }
2008 serialize_inline_children(node, out);
2009 out.push_str("]]");
2010 }
2011 "noBreak" => {
2012 out.push_str("[[no-break:");
2013 serialize_inline_children(node, out);
2014 out.push_str("]]");
2015 }
2016 "warichu" => {
2017 out.push_str("[[warichu:");
2018 serialize_inline_children(node, out);
2019 out.push_str("]]");
2020 }
2021 "kern" => {
2022 out.push_str("[[kern:");
2023 out.push_str(
2024 node.get("amount")
2025 .and_then(serde_json::Value::as_str)
2026 .unwrap_or_default(),
2027 );
2028 out.push(':');
2029 serialize_inline_children(node, out);
2030 out.push_str("]]");
2031 }
2032 "footnoteReference" => {
2033 out.push_str("[^");
2034 out.push_str(
2035 node.get("identifier")
2036 .and_then(serde_json::Value::as_str)
2037 .unwrap_or_default(),
2038 );
2039 out.push(']');
2040 }
2041 _ => serialize_inline_children(node, out),
2042 }
2043}
2044
2045pub(crate) fn children(node: &serde_json::Value) -> &[serde_json::Value] {
2046 node.get("children")
2047 .and_then(serde_json::Value::as_array)
2048 .map(Vec::as_slice)
2049 .unwrap_or(&[])
2050}
2051
2052fn document_frontmatter_field<'a>(document: &'a Document, key: &str) -> Option<&'a str> {
2053 document
2054 .frontmatter
2055 .as_ref()
2056 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2057 .and_then(|entry| entry.value.as_str())
2058}
2059
2060fn default_profile_for_document(document: &Document) -> Result<ResolvedExportProfile, String> {
2061 resolve_export_profile(
2062 &serde_json::Map::new(),
2063 document_frontmatter_field(document, "writing-mode"),
2064 )
2065}
2066
2067fn resolved_profile_for_document(
2068 document: &Document,
2069 profile_json: &str,
2070 require_layout: bool,
2071) -> Result<ResolvedExportProfile, String> {
2072 let value: serde_json::Value = serde_json::from_str(profile_json)
2073 .map_err(|_| "Export profile must be valid JSON".to_owned())?;
2074 let profile = value
2075 .as_object()
2076 .ok_or_else(|| "Export profile must be a JSON object".to_owned())?;
2077 if require_layout
2078 && profile
2079 .get("layout")
2080 .and_then(serde_json::Value::as_object)
2081 .and_then(|layout| layout.get("system"))
2082 .is_none()
2083 {
2084 return Err(
2085 "Configured exports require layout.system: japanese-publisher or word".to_owned(),
2086 );
2087 }
2088 resolve_export_profile(
2089 profile,
2090 document_frontmatter_field(document, "writing-mode"),
2091 )
2092}
2093
2094fn css_value(value: &str) -> String {
2095 let safe = value
2096 .chars()
2097 .filter(|character| !matches!(character, '{' | '}' | '<' | '>' | ';'))
2098 .collect::<String>();
2099 if safe.trim().is_empty() {
2100 "serif".to_owned()
2101 } else {
2102 safe
2103 }
2104}
2105
2106pub 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}";
2109
2110#[derive(Debug, Clone)]
2111pub struct EpubCover {
2112 pub data: Vec<u8>,
2113 pub media_type: String,
2114}
2115
2116pub fn render_epub(source: &str) -> Result<Vec<u8>, String> {
2118 render_epub_document(&parse_document(source))
2119}
2120
2121pub fn render_epub_with_profile(
2123 source: &str,
2124 profile_json: &str,
2125 cover: Option<&EpubCover>,
2126) -> Result<Vec<u8>, String> {
2127 let document = parse_document(source);
2128 let profile = resolved_profile_for_document(&document, profile_json, false)?;
2129 render_epub_document_with_profile(&document, &profile, cover)
2130}
2131
2132pub fn render_epub_document(document: &Document) -> Result<Vec<u8>, String> {
2134 let profile = default_profile_for_document(document)?;
2135 render_epub_document_with_profile(document, &profile, None)
2136}
2137
2138pub fn render_epub_document_with_profile(
2139 document: &Document,
2140 profile: &ResolvedExportProfile,
2141 cover: Option<&EpubCover>,
2142) -> Result<Vec<u8>, String> {
2143 let cursor = Cursor::new(Vec::new());
2144 let mut zip = ZipWriter::new(cursor);
2145 write_epub_document_with_profile(document, &mut zip, profile, cover)?;
2146 zip.finish()
2147 .map(|cursor| cursor.into_inner())
2148 .map_err(|error| error.to_string())
2149}
2150
2151#[cfg(test)]
2152fn write_epub_document<W: Write + Seek>(
2153 document: &Document,
2154 zip: &mut ZipWriter<W>,
2155) -> Result<(), String> {
2156 let profile = default_profile_for_document(document)?;
2157 write_epub_document_with_profile(document, zip, &profile, None)
2158}
2159
2160fn write_epub_document_with_profile<W: Write + Seek>(
2161 document: &Document,
2162 zip: &mut ZipWriter<W>,
2163 profile: &ResolvedExportProfile,
2164 cover: Option<&EpubCover>,
2165) -> Result<(), String> {
2166 let field = |key: &str| {
2167 document
2168 .frontmatter
2169 .as_ref()
2170 .and_then(|frontmatter| frontmatter.entries.iter().find(|entry| entry.key == key))
2171 .and_then(|entry| entry.value.as_str())
2172 };
2173 let metadata = |key: &str| {
2174 profile
2175 .metadata
2176 .get(key)
2177 .and_then(serde_json::Value::as_str)
2178 };
2179 let title = metadata("title")
2180 .or_else(|| field("title"))
2181 .unwrap_or("Untitled");
2182 let author = metadata("author").or_else(|| field("author"));
2183 let publisher = metadata("publisher").or_else(|| field("publisher"));
2184 let date = metadata("date").or_else(|| field("date"));
2185 let language = metadata("language")
2186 .or_else(|| field("lang"))
2187 .unwrap_or("ja");
2188 let identifier = metadata("identifier")
2189 .or_else(|| field("identifier"))
2190 .unwrap_or("urn:mdi:document");
2191 let vertical = profile.typesetting.writing_mode == "vertical";
2192 let modified = epub_modified_timestamp()?;
2193 let chapters = epub_chapters(document, &profile.epub.chapter_split_level);
2194 let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
2195 epub_file(zip, "mimetype", "application/epub+zip", stored)?;
2196 let compressed = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
2197 epub_file(
2198 zip,
2199 "META-INF/container.xml",
2200 "<?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>",
2201 compressed,
2202 )?;
2203 let writing = if vertical {
2204 "writing-mode:vertical-rl;-webkit-writing-mode:vertical-rl;text-orientation:mixed;"
2205 } else {
2206 ""
2207 };
2208 let line_spacing = profile.typesetting.line_spacing.unwrap_or(1.8);
2209 let fullwidth_indent = if profile.typesetting.fullwidth_space_indent {
2210 "--mdi-fullwidth-space-indent:1;"
2211 } else {
2212 ""
2213 };
2214 epub_file(
2215 zip,
2216 "OEBPS/style.css",
2217 &format!(
2218 "body{{font-family:{};font-size:{}pt;{writing}line-height:{line_spacing};margin:1em}}p{{{fullwidth_indent}text-indent:{}em;margin:.3em 0}}{MDI_STYLESHEET}",
2219 css_value(&profile.typesetting.font_family),
2220 profile.typesetting.font_size,
2221 profile.typesetting.text_indent_em,
2222 ),
2223 compressed,
2224 )?;
2225 let nav_items = chapters
2226 .iter()
2227 .enumerate()
2228 .map(|(index, chapter)| {
2229 format!(
2230 "<li><a href=\"chapter-{}.xhtml\">{}</a></li>",
2231 index + 1,
2232 escape_html(&chapter.title)
2233 )
2234 })
2235 .collect::<String>();
2236 epub_file(
2237 zip,
2238 "OEBPS/nav.xhtml",
2239 &epub_xhtml(
2240 "Contents",
2241 language,
2242 &format!("<nav epub:type=\"toc\" id=\"toc\"><ol>{nav_items}</ol></nav>"),
2243 ),
2244 compressed,
2245 )?;
2246 let cover_extension = cover
2247 .map(|cover| match cover.media_type.as_str() {
2248 "image/png" => Ok("png"),
2249 "image/jpeg" => Ok("jpg"),
2250 _ => Err("EPUB cover must be image/png or image/jpeg".to_owned()),
2251 })
2252 .transpose()?;
2253 if let (Some(cover), Some(extension)) = (cover, cover_extension) {
2254 zip.start_file(format!("OEBPS/cover.{extension}"), compressed)
2255 .map_err(|error| error.to_string())?;
2256 zip.write_all(&cover.data)
2257 .map_err(|error| error.to_string())?;
2258 epub_file(
2259 zip,
2260 "OEBPS/cover.xhtml",
2261 &epub_xhtml(
2262 title,
2263 language,
2264 &format!(
2265 "<img src=\"cover.{extension}\" alt=\"{}\"/>",
2266 escape_html(title)
2267 ),
2268 ),
2269 compressed,
2270 )?;
2271 }
2272 for (index, chapter) in chapters.iter().enumerate() {
2273 epub_file(
2274 zip,
2275 &format!("OEBPS/chapter-{}.xhtml", index + 1),
2276 &epub_xhtml(
2277 if chapter.title.is_empty() {
2278 title
2279 } else {
2280 &chapter.title
2281 },
2282 language,
2283 &chapter.html,
2284 ),
2285 compressed,
2286 )?;
2287 }
2288 let cover_manifest = match (cover, cover_extension) {
2289 (Some(cover), Some(extension)) => format!(
2290 "<item id=\"cover-image\" href=\"cover.{extension}\" media-type=\"{}\" properties=\"cover-image\"/><item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>",
2291 cover.media_type
2292 ),
2293 _ => String::new(),
2294 };
2295 let manifest = format!("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/><item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>{cover_manifest}{}", chapters.iter().enumerate().map(|(index, _)| format!("<item id=\"chapter-{}\" href=\"chapter-{}.xhtml\" media-type=\"application/xhtml+xml\"/>", index + 1, index + 1)).collect::<String>());
2296 let chapter_spine = chapters
2297 .iter()
2298 .enumerate()
2299 .map(|(index, _)| format!("<itemref idref=\"chapter-{}\"/>", index + 1))
2300 .collect::<String>();
2301 let spine = format!(
2302 "{}{chapter_spine}",
2303 if cover.is_some() {
2304 "<itemref idref=\"cover\"/>"
2305 } else {
2306 ""
2307 }
2308 );
2309 let creator = author
2310 .map(|author| format!("<dc:creator>{}</dc:creator>", escape_html(author)))
2311 .unwrap_or_default();
2312 let publisher = publisher
2313 .map(|publisher| format!("<dc:publisher>{}</dc:publisher>", escape_html(publisher)))
2314 .unwrap_or_default();
2315 let date = date
2316 .map(|date| format!("<dc:date>{}</dc:date>", escape_html(date)))
2317 .unwrap_or_default();
2318 let progression = if vertical {
2319 " page-progression-direction=\"rtl\""
2320 } else {
2321 ""
2322 };
2323 epub_file(
2324 zip,
2325 "OEBPS/package.opf",
2326 &format!(
2327 "<?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>",
2328 escape_html(identifier),
2329 escape_html(title),
2330 escape_html(language)
2331 ),
2332 compressed,
2333 )?;
2334 Ok(())
2335}
2336
2337pub fn render_docx(source: &str) -> Result<Vec<u8>, String> {
2341 render_docx_document(&parse_document(source))
2342}
2343
2344pub fn render_docx_with_profile(source: &str, profile_json: &str) -> Result<Vec<u8>, String> {
2346 let document = parse_document(source);
2347 let profile = resolved_profile_for_document(&document, profile_json, false)?;
2348 render_docx_document_with_profile(&document, &profile)
2349}
2350
2351pub fn render_docx_document(document: &Document) -> Result<Vec<u8>, String> {
2353 let profile = default_profile_for_document(document)?;
2354 render_docx_document_with_profile(document, &profile)
2355}
2356
2357pub fn render_docx_document_with_profile(
2358 document: &Document,
2359 profile: &ResolvedExportProfile,
2360) -> Result<Vec<u8>, String> {
2361 let cursor = Cursor::new(Vec::new());
2362 let mut zip = ZipWriter::new(cursor);
2363 docx::write(document, profile, &mut zip)?;
2364 zip.finish()
2365 .map(|cursor| cursor.into_inner())
2366 .map_err(|error| error.to_string())
2367}
2368
2369#[cfg(test)]
2370fn write_docx_document<W: Write + Seek>(
2371 document: &Document,
2372 zip: &mut ZipWriter<W>,
2373) -> Result<(), String> {
2374 let profile = default_profile_for_document(document)?;
2375 docx::write(document, &profile, zip)
2376}
2377
2378#[derive(Debug, Clone, Default)]
2381pub struct PdfOptions {
2382 pub chromium_path: Option<PathBuf>,
2383}
2384
2385pub fn render_pdf(source: &str, options: &PdfOptions) -> Result<Vec<u8>, String> {
2388 let chromium = options
2389 .chromium_path
2390 .clone()
2391 .or_else(find_chromium)
2392 .ok_or_else(|| "Chromium executable not found; set PdfOptions.chromium_path".to_owned())?;
2393 let nonce = SystemTime::now()
2394 .duration_since(UNIX_EPOCH)
2395 .map_err(|error| error.to_string())?
2396 .as_nanos();
2397 let directory = std::env::temp_dir().join(format!("mdi-core-{}-{nonce}", std::process::id()));
2398 fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
2399 let html_path = directory.join("document.html");
2400 let pdf_path = directory.join("document.pdf");
2401 let result = (|| {
2402 fs::write(&html_path, render_html(source)).map_err(|error| error.to_string())?;
2403 let output = Command::new(&chromium)
2404 .arg("--headless=new")
2405 .arg("--disable-gpu")
2406 .arg("--no-pdf-header-footer")
2407 .arg(format!("--print-to-pdf={}", pdf_path.display()))
2408 .arg(format!("file://{}", html_path.display()))
2409 .output()
2410 .map_err(|error| {
2411 format!(
2412 "failed to start Chromium at {}: {error}",
2413 chromium.display()
2414 )
2415 })?;
2416 if !output.status.success() {
2417 return Err(format!(
2418 "Chromium PDF rendering failed: {}",
2419 String::from_utf8_lossy(&output.stderr)
2420 ));
2421 }
2422 fs::read(&pdf_path).map_err(|error| error.to_string())
2423 })();
2424 let _ = fs::remove_dir_all(&directory);
2425 result
2426}
2427
2428pub fn find_chromium() -> Option<PathBuf> {
2431 let candidates = [
2432 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
2433 "/Applications/Chromium.app/Contents/MacOS/Chromium",
2434 "/usr/bin/google-chrome",
2435 "/usr/bin/chromium",
2436 "/usr/bin/chromium-browser",
2437 ];
2438 candidates
2439 .iter()
2440 .map(Path::new)
2441 .find(|path| path.is_file())
2442 .map(Path::to_path_buf)
2443}
2444
2445pub(crate) fn escape_xml(value: &str) -> String {
2446 value
2447 .replace('&', "&")
2448 .replace('<', "<")
2449 .replace('>', ">")
2450 .replace('"', """)
2451 .replace('\'', "'")
2452}
2453
2454struct EpubChapter {
2455 title: String,
2456 html: String,
2457 footnote_ids: Vec<String>,
2458}
2459fn epub_chapters(document: &Document, split_level: &str) -> Vec<EpubChapter> {
2460 let split_depth = match split_level {
2461 "h1" => Some(1),
2462 "h2" => Some(2),
2463 "h3" => Some(3),
2464 "none" => None,
2465 _ => Some(1),
2466 };
2467 let mut chapters = vec![EpubChapter {
2468 title: String::new(),
2469 html: String::new(),
2470 footnote_ids: Vec::new(),
2471 }];
2472 let footnote_definitions = document
2473 .children
2474 .iter()
2475 .filter_map(|node| {
2476 if node.get("type").and_then(serde_json::Value::as_str) != Some("footnoteDefinition") {
2477 return None;
2478 }
2479 node.get("identifier")
2480 .and_then(serde_json::Value::as_str)
2481 .map(|identifier| (identifier, node))
2482 })
2483 .collect::<std::collections::HashMap<_, _>>();
2484 for node in &document.children {
2485 if node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteDefinition") {
2486 continue;
2487 }
2488 if node.get("type").and_then(serde_json::Value::as_str) == Some("pagebreak") {
2489 if split_depth.is_some()
2490 && !chapters
2491 .last()
2492 .is_some_and(|chapter| chapter.html.is_empty())
2493 {
2494 chapters.push(EpubChapter {
2495 title: String::new(),
2496 html: String::new(),
2497 footnote_ids: Vec::new(),
2498 });
2499 }
2500 continue;
2501 }
2502 if node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2503 && node.get("depth").and_then(serde_json::Value::as_u64) == split_depth
2504 && !chapters
2505 .last()
2506 .is_some_and(|chapter| chapter.html.is_empty())
2507 {
2508 chapters.push(EpubChapter {
2509 title: String::new(),
2510 html: String::new(),
2511 footnote_ids: Vec::new(),
2512 });
2513 }
2514 let chapter = chapters.last_mut().expect("one chapter exists");
2515 if chapter.title.is_empty()
2516 && node.get("type").and_then(serde_json::Value::as_str) == Some("heading")
2517 {
2518 chapter.title = plain_node_text(node);
2519 }
2520 collect_footnote_references(node, &mut chapter.footnote_ids);
2521 render_html_node(node, &mut chapter.html);
2522 }
2523 let mut chapters: Vec<_> = chapters
2524 .into_iter()
2525 .filter(|chapter| !chapter.html.is_empty())
2526 .collect();
2527 for chapter in &mut chapters {
2528 chapter.footnote_ids.sort();
2529 chapter.footnote_ids.dedup();
2530 if chapter.footnote_ids.is_empty() {
2531 continue;
2532 }
2533 chapter.html.push_str(
2534 "<section data-footnotes=\"\" class=\"footnotes\"><h2 class=\"sr-only\" id=\"footnote-label\">Footnotes</h2><ol>",
2535 );
2536 for identifier in &chapter.footnote_ids {
2537 let Some(definition) = footnote_definitions.get(identifier.as_str()) else {
2538 continue;
2539 };
2540 chapter.html.push_str("<li id=\"user-content-fn-");
2541 chapter.html.push_str(&escape_html(identifier));
2542 chapter.html.push_str("\">");
2543 render_html_children(definition, &mut chapter.html);
2544 chapter.html.push_str(" <a href=\"#user-content-fnref-");
2545 chapter.html.push_str(&escape_html(identifier));
2546 chapter.html.push_str("\" data-footnote-backref=\"\" aria-label=\"Back to reference\" class=\"data-footnote-backref\">↩</a></li>");
2547 }
2548 chapter.html.push_str("</ol></section>");
2549 }
2550 if chapters.is_empty() {
2551 vec![EpubChapter {
2552 title: String::new(),
2553 html: String::new(),
2554 footnote_ids: Vec::new(),
2555 }]
2556 } else {
2557 chapters
2558 }
2559}
2560
2561fn collect_footnote_references(node: &serde_json::Value, identifiers: &mut Vec<String>) {
2562 if node.get("type").and_then(serde_json::Value::as_str) == Some("footnoteReference")
2563 && let Some(identifier) = node.get("identifier").and_then(serde_json::Value::as_str)
2564 {
2565 identifiers.push(identifier.to_owned());
2566 }
2567 for child in children(node) {
2568 collect_footnote_references(child, identifiers);
2569 }
2570}
2571fn plain_node_text(node: &serde_json::Value) -> String {
2572 let mut text = String::new();
2573 render_text_children(node, &mut text);
2574 text
2575}
2576fn epub_xhtml(title: &str, language: &str, body: &str) -> String {
2577 format!(
2578 "<?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>",
2579 escape_html(language),
2580 escape_html(language),
2581 escape_html(title),
2582 body.replace("<br>", "<br/>").replace("<hr>", "<hr/>")
2583 )
2584}
2585
2586#[cfg(not(feature = "wasm"))]
2587fn epub_modified_timestamp() -> Result<String, String> {
2588 OffsetDateTime::now_utc()
2589 .replace_nanosecond(0)
2590 .map_err(|error| error.to_string())?
2591 .format(&Rfc3339)
2592 .map_err(|error| error.to_string())
2593}
2594
2595#[cfg(feature = "wasm")]
2596fn epub_modified_timestamp() -> Result<String, String> {
2597 let value = js_sys::Date::new_0()
2598 .to_iso_string()
2599 .as_string()
2600 .ok_or_else(|| "JavaScript Date did not return an ISO timestamp".to_owned())?;
2601 Ok(value
2602 .find('.')
2603 .map_or(value.clone(), |fraction| format!("{}Z", &value[..fraction])))
2604}
2605fn epub_file<W: Write + Seek>(
2606 zip: &mut ZipWriter<W>,
2607 path: &str,
2608 content: &str,
2609 options: SimpleFileOptions,
2610) -> Result<(), String> {
2611 zip.start_file(path, options)
2612 .map_err(|error| error.to_string())?;
2613 zip.write_all(content.as_bytes())
2614 .map_err(|error| error.to_string())
2615}
2616
2617fn render_html_node(node: &serde_json::Value, out: &mut String) {
2618 let Some(kind) = node.get("type").and_then(serde_json::Value::as_str) else {
2619 return;
2620 };
2621 let children = |out: &mut String| render_html_children(node, out);
2622 match kind {
2623 "text" => out.push_str(&escape_html(
2624 node.get("value")
2625 .and_then(serde_json::Value::as_str)
2626 .unwrap_or_default(),
2627 )),
2628 "root" => children(out),
2629 "paragraph" => {
2630 let class = if node.get("indent").is_some() {
2631 " class=\"mdi-indent\""
2632 } else if node.get("bottom").is_some() {
2633 " class=\"mdi-bottom\""
2634 } else {
2635 ""
2636 };
2637 let style = if let Some(amount) = node.get("indent").and_then(serde_json::Value::as_u64)
2638 {
2639 format!(" style=\"--mdi-indent:{amount};\"")
2640 } else if let Some(amount) = node.get("bottom").and_then(serde_json::Value::as_u64) {
2641 format!(" style=\"--mdi-shift:{amount};\"")
2642 } else {
2643 String::new()
2644 };
2645 out.push_str("<p");
2646 out.push_str(class);
2647 out.push_str(&style);
2648 out.push('>');
2649 children(out);
2650 out.push_str("</p>");
2651 }
2652 "heading" => {
2653 let depth = node
2654 .get("depth")
2655 .and_then(serde_json::Value::as_u64)
2656 .filter(|depth| (1..=6).contains(depth))
2657 .unwrap_or(1);
2658 out.push_str(&format!("<h{depth}>"));
2659 children(out);
2660 out.push_str(&format!("</h{depth}>"));
2661 }
2662 "emphasis" => wrapped(out, "em", children),
2663 "strong" => wrapped(out, "strong", children),
2664 "delete" => wrapped(out, "del", children),
2665 "blockquote" => wrapped(out, "blockquote", children),
2666 "list" => {
2667 let ordered = node
2668 .get("ordered")
2669 .and_then(serde_json::Value::as_bool)
2670 .unwrap_or(false);
2671 let tag = if ordered { "ol" } else { "ul" };
2672 let start = node
2673 .get("start")
2674 .and_then(serde_json::Value::as_u64)
2675 .filter(|start| *start != 1)
2676 .map(|start| format!(" start=\"{start}\""))
2677 .unwrap_or_default();
2678 out.push('<');
2679 out.push_str(tag);
2680 out.push_str(&start);
2681 out.push('>');
2682 children(out);
2683 out.push_str("</");
2684 out.push_str(tag);
2685 out.push('>');
2686 }
2687 "listItem" => wrapped(out, "li", children),
2688 "thematicBreak" => out.push_str("<hr>"),
2689 "break" => out.push_str("<br class=\"mdi-break\"/>"),
2690 "inlineCode" => {
2691 out.push_str("<code>");
2692 out.push_str(&escape_html(
2693 node.get("value")
2694 .and_then(serde_json::Value::as_str)
2695 .unwrap_or_default(),
2696 ));
2697 out.push_str("</code>");
2698 }
2699 "code" => {
2700 out.push_str("<pre><code");
2701 if let Some(lang) = node.get("lang").and_then(serde_json::Value::as_str) {
2702 out.push_str(" class=\"language-");
2703 out.push_str(&escape_html(lang));
2704 out.push('"');
2705 }
2706 out.push('>');
2707 out.push_str(&escape_html(
2708 node.get("value")
2709 .and_then(serde_json::Value::as_str)
2710 .unwrap_or_default(),
2711 ));
2712 out.push_str("</code></pre>");
2713 }
2714 "link" => {
2715 out.push_str("<a href=\"");
2716 out.push_str(&escape_html(
2717 node.get("url")
2718 .and_then(serde_json::Value::as_str)
2719 .unwrap_or_default(),
2720 ));
2721 out.push('"');
2722 if let Some(title) = node.get("title").and_then(serde_json::Value::as_str) {
2723 out.push_str(" title=\"");
2724 out.push_str(&escape_html(title));
2725 out.push('"');
2726 }
2727 out.push('>');
2728 children(out);
2729 out.push_str("</a>");
2730 }
2731 "image" => {
2732 out.push_str("<img src=\"");
2733 out.push_str(&escape_html(
2734 node.get("url")
2735 .and_then(serde_json::Value::as_str)
2736 .unwrap_or_default(),
2737 ));
2738 out.push_str("\" alt=\"");
2739 out.push_str(&escape_html(
2740 node.get("alt")
2741 .and_then(serde_json::Value::as_str)
2742 .unwrap_or_default(),
2743 ));
2744 out.push_str("\">");
2745 }
2746 "table" => wrapped(out, "table", children),
2747 "tableRow" => wrapped(out, "tr", children),
2748 "tableCell" => wrapped(out, "td", children),
2749 "footnoteReference" => {
2750 let identifier = node
2751 .get("identifier")
2752 .and_then(serde_json::Value::as_str)
2753 .unwrap_or_default();
2754 let label = node
2755 .get("label")
2756 .and_then(serde_json::Value::as_str)
2757 .unwrap_or_default();
2758 out.push_str("<sup class=\"footnote-ref\"><a href=\"#user-content-fn-");
2759 out.push_str(&escape_html(identifier));
2760 out.push_str("\" id=\"user-content-fnref-");
2761 out.push_str(&escape_html(identifier));
2762 out.push_str("\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">");
2763 out.push_str(&escape_html(label));
2764 out.push_str("</a></sup>");
2765 }
2766 "footnoteDefinition" | "definition" => {}
2767 "html" => out.push_str(&escape_html(
2768 node.get("value")
2769 .and_then(serde_json::Value::as_str)
2770 .unwrap_or_default(),
2771 )),
2772 "ruby" => render_ruby(node, out),
2773 "tcy" => {
2774 out.push_str("<span class=\"mdi-tcy\">");
2775 out.push_str(&escape_html(
2776 node.get("value")
2777 .and_then(serde_json::Value::as_str)
2778 .unwrap_or_default(),
2779 ));
2780 out.push_str("</span>");
2781 }
2782 "em" => {
2783 let mark = node
2784 .get("mark")
2785 .and_then(serde_json::Value::as_str)
2786 .unwrap_or("﹅");
2787 out.push_str("<span class=\"mdi-em\" style=\"--mdi-em:"");
2788 out.push_str(&escape_css_string(mark));
2789 out.push_str("";\">");
2790 children(out);
2791 out.push_str("</span>");
2792 }
2793 "noBreak" => {
2794 out.push_str("<span class=\"mdi-nobr\">");
2795 children(out);
2796 out.push_str("</span>");
2797 }
2798 "warichu" => {
2799 out.push_str("<span class=\"mdi-warichu\">");
2800 children(out);
2801 out.push_str("</span>");
2802 }
2803 "kern" => {
2804 out.push_str("<span class=\"mdi-kern\" style=\"--mdi-kern:");
2805 out.push_str(&escape_html(
2806 node.get("amount")
2807 .and_then(serde_json::Value::as_str)
2808 .unwrap_or_default(),
2809 ));
2810 out.push_str(";\">");
2811 children(out);
2812 out.push_str("</span>");
2813 }
2814 "blank" => out.push_str("<p class=\"mdi-blank\"></p>"),
2815 "pagebreak" => {
2816 out.push_str("<div class=\"mdi-pagebreak");
2817 if let Some(variant) = node.get("variant").and_then(serde_json::Value::as_str) {
2818 out.push_str(" mdi-pagebreak-");
2819 out.push_str(&escape_html(variant));
2820 }
2821 out.push_str("\" role=\"presentation\"></div>");
2822 }
2823 _ => children(out),
2824 }
2825}
2826
2827fn render_html_children(node: &serde_json::Value, out: &mut String) {
2828 if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) {
2829 for child in children {
2830 render_html_node(child, out);
2831 }
2832 }
2833}
2834
2835fn wrapped(out: &mut String, tag: &str, children: impl FnOnce(&mut String)) {
2836 out.push('<');
2837 out.push_str(tag);
2838 out.push('>');
2839 children(out);
2840 out.push_str("</");
2841 out.push_str(tag);
2842 out.push('>');
2843}
2844
2845fn render_ruby(node: &serde_json::Value, out: &mut String) {
2846 let base = node
2847 .get("base")
2848 .and_then(serde_json::Value::as_str)
2849 .unwrap_or_default();
2850 let reading = node.get("ruby").and_then(|ruby| ruby.get("value"));
2851 out.push_str("<ruby class=\"mdi-ruby\">");
2852 if let Some(parts) = reading.and_then(serde_json::Value::as_array) {
2853 for (base, reading) in base.graphemes(true).zip(parts) {
2854 out.push_str(&escape_html(base));
2855 render_ruby_reading(reading.as_str().unwrap_or_default(), out);
2856 }
2857 } else {
2858 out.push_str(&escape_html(base));
2859 render_ruby_reading(
2860 reading
2861 .and_then(serde_json::Value::as_str)
2862 .unwrap_or_default(),
2863 out,
2864 );
2865 }
2866 out.push_str("</ruby>");
2867}
2868
2869fn render_ruby_reading(reading: &str, out: &mut String) {
2870 out.push_str("<rp>(</rp><rt>");
2871 out.push_str(&escape_html(reading));
2872 out.push_str("</rt><rp>)</rp>");
2873}
2874
2875fn escape_html(value: &str) -> String {
2876 value
2877 .replace('&', "&")
2878 .replace('<', "<")
2879 .replace('>', ">")
2880 .replace('"', """)
2881}
2882fn escape_css_string(value: &str) -> String {
2883 value.replace('\\', "\\\\").replace('"', "\\\"")
2884}
2885
2886pub fn parse_inlines(source: &str) -> Vec<Inline> {
2888 parse_inline_parts(source)
2889 .into_iter()
2890 .map(|(inline, _, _)| inline)
2891 .collect()
2892}
2893
2894fn parse_inline_parts(source: &str) -> Vec<(Inline, usize, usize)> {
2898 let mut out = Vec::new();
2899 let mut text = String::new();
2900 let mut text_start = 0;
2901 let mut index = 0;
2902
2903 while index < source.len() {
2904 let rest = &source[index..];
2905 if rest.starts_with('\\') {
2906 let mut chars = rest.chars();
2907 let slash = chars.next().expect("prefix was checked");
2908 let Some(next) = chars.next() else {
2909 text.push(slash);
2910 index += slash.len_utf8();
2911 continue;
2912 };
2913 if is_escapable(next) {
2914 text.push(next);
2915 } else {
2916 text.push(slash);
2917 text.push(next);
2918 }
2919 index += slash.len_utf8() + next.len_utf8();
2920 continue;
2921 }
2922 if let Some((inline, consumed)) = ruby(rest) {
2923 push_inline_text(&mut out, &mut text, text_start, index);
2924 out.push((inline, index, index + consumed));
2925 index += consumed;
2926 text_start = index;
2927 continue;
2928 }
2929 if let Some((inline, consumed)) = tcy(rest) {
2930 push_inline_text(&mut out, &mut text, text_start, index);
2931 out.push((inline, index, index + consumed));
2932 index += consumed;
2933 text_start = index;
2934 continue;
2935 }
2936 if let Some((inline, consumed)) = boten(rest) {
2937 push_inline_text(&mut out, &mut text, text_start, index);
2938 out.push((inline, index, index + consumed));
2939 index += consumed;
2940 text_start = index;
2941 continue;
2942 }
2943 if let Some((inline, consumed)) = bracket_macro(rest) {
2944 push_inline_text(&mut out, &mut text, text_start, index);
2945 out.push((inline, index, index + consumed));
2946 index += consumed;
2947 text_start = index;
2948 continue;
2949 }
2950 let character = rest.chars().next().expect("index is in bounds");
2951 text.push(character);
2952 index += character.len_utf8();
2953 }
2954 push_inline_text(&mut out, &mut text, text_start, index);
2955 out
2956}
2957
2958#[derive(Clone)]
2959enum PendingBlock {
2960 Indent { amount: u32, source: String },
2961 Bottom { amount: u32, source: String },
2962}
2963
2964fn paragraph(line: &str, pending: Option<PendingBlock>) -> MdiBlock {
2965 let (indent, bottom) = match pending {
2966 Some(PendingBlock::Indent { amount, .. }) => (Some(amount), None),
2967 Some(PendingBlock::Bottom { amount, .. }) => (None, Some(amount)),
2968 None => (None, None),
2969 };
2970 MdiBlock::Paragraph {
2971 inlines: parse_inlines(line),
2972 indent,
2973 bottom,
2974 }
2975}
2976
2977fn flush_pending(blocks: &mut Vec<MdiBlock>, pending: &mut Option<PendingBlock>) {
2978 if let Some(marker) = pending.take() {
2979 let source = match marker {
2982 PendingBlock::Indent { source, .. } | PendingBlock::Bottom { source, .. } => source,
2983 };
2984 blocks.push(paragraph(&source, None));
2985 }
2986}
2987
2988fn is_blank_marker(line: &str) -> bool {
2989 let value = line.trim_end_matches([' ', '\t']);
2990 value == "\\" || value == "<br>" || value == "<br />" || value == "[[blank]]"
2991}
2992
2993fn pagebreak(line: &str) -> Option<Option<PagebreakVariant>> {
2994 match line.trim() {
2995 "[[pagebreak]]" => Some(None),
2996 "[[pagebreak:left]]" => Some(Some(PagebreakVariant::Left)),
2997 "[[pagebreak:right]]" => Some(Some(PagebreakVariant::Right)),
2998 _ => None,
2999 }
3000}
3001
3002fn pending_block(line: &str) -> Option<PendingBlock> {
3003 let value = line.trim();
3004 if value == "[[bottom]]" {
3005 return Some(PendingBlock::Bottom {
3006 amount: 0,
3007 source: value.to_owned(),
3008 });
3009 }
3010 let (kind, amount) = value
3011 .strip_prefix("[[")?
3012 .strip_suffix("]]")?
3013 .split_once(':')?;
3014 if amount.is_empty()
3015 || amount.starts_with('0')
3016 || !amount.bytes().all(|byte| byte.is_ascii_digit())
3017 {
3018 return None;
3019 }
3020 let amount = amount.parse().ok()?;
3021 match kind {
3022 "indent" => Some(PendingBlock::Indent {
3023 amount,
3024 source: value.to_owned(),
3025 }),
3026 "bottom" => Some(PendingBlock::Bottom {
3027 amount,
3028 source: value.to_owned(),
3029 }),
3030 _ => None,
3031 }
3032}
3033
3034fn ruby(value: &str) -> Option<(Inline, usize)> {
3035 if !value.starts_with('{') {
3036 return None;
3037 }
3038 let end = close_unescaped(value, 1, '}')?;
3039 let body = &value[1..end];
3040 let separator = bare_index(body, '|')?;
3041 let base = unescape_ruby(&body[..separator]);
3042 let raw_ruby = &body[separator + 1..];
3043 let ruby = split_ruby(&base, raw_ruby);
3044 Some((Inline::Ruby { base, ruby }, end + 1))
3045}
3046
3047fn split_ruby(base: &str, raw: &str) -> RubyReading {
3048 let segments = split_unescaped(raw, '.');
3049 if segments.len() == 1 {
3050 return RubyReading::Group(unescape_ruby(raw));
3051 }
3052 let segments: Vec<String> = segments.into_iter().map(unescape_ruby).collect();
3053 if segments.len() == base.graphemes(true).count()
3054 && segments.iter().all(|part| !part.is_empty())
3055 {
3056 RubyReading::Split(segments)
3057 } else {
3058 RubyReading::Group(segments.concat())
3059 }
3060}
3061
3062fn tcy(value: &str) -> Option<(Inline, usize)> {
3063 if !value.starts_with('^') {
3064 return None;
3065 }
3066 let closing = value[1..].find('^')? + 1;
3067 let body = &value[1..closing];
3068 if body.is_empty()
3069 || body.chars().count() > 6
3070 || !body
3071 .chars()
3072 .all(|c| c.is_ascii_alphanumeric() || c == '!' || c == '?')
3073 {
3074 return None;
3075 }
3076 Some((Inline::Tcy(body.to_owned()), closing + 1))
3077}
3078
3079fn boten(value: &str) -> Option<(Inline, usize)> {
3080 let prefix = "《《";
3081 if !value.starts_with(prefix) {
3082 return None;
3083 }
3084 let end = close_boten_alias(value)?;
3085 let body = &value[prefix.len()..end];
3086 if body.is_empty()
3087 || body.contains('\n')
3088 || contains_unescaped(body, '《')
3089 || contains_unescaped(body, '》')
3090 {
3091 return None;
3092 }
3093 Some((
3094 Inline::Em {
3095 mark: "﹅".to_owned(),
3096 children: vec![Inline::Text(unescape_mdi(body))],
3097 },
3098 end + "》》".len(),
3099 ))
3100}
3101
3102fn close_boten_alias(value: &str) -> Option<usize> {
3105 let mut index = "《《".len();
3106 while index < value.len() {
3107 let rest = &value[index..];
3108 if rest.starts_with('\\') {
3109 let next = rest.chars().nth(1)?;
3110 index += 1 + next.len_utf8();
3111 } else if rest.starts_with("》》") {
3112 return Some(index);
3113 } else {
3114 index += rest.chars().next()?.len_utf8();
3115 }
3116 }
3117 None
3118}
3119
3120fn bracket_macro(value: &str) -> Option<(Inline, usize)> {
3121 if !value.starts_with("[[") {
3122 return None;
3123 }
3124 if value.starts_with("[[br]]") {
3125 return Some((Inline::Break, "[[br]]".len()));
3126 }
3127 let end = close_macro(value)?;
3128 let body = &value[2..end];
3129 let (name, payload) = body.split_once(':')?;
3130 let children = |content: &str| parse_inlines(content);
3131 let inline = match name {
3132 "no-break" if !payload.is_empty() => Inline::NoBreak(children(payload)),
3133 "warichu" => Inline::Warichu(children(payload)),
3134 "kern" => {
3135 let (amount, content) = payload.split_once(':')?;
3136 if !valid_kern(amount) {
3137 return None;
3138 }
3139 Inline::Kern {
3140 amount: unescape_mdi(amount),
3141 children: children(content),
3142 }
3143 }
3144 "em" => {
3145 let (mark, content) = match bare_index(payload, ':') {
3146 Some(index) => {
3147 let candidate = unescape_mdi(&payload[..index]);
3148 if candidate.graphemes(true).count() == 1
3149 && !candidate
3150 .chars()
3151 .any(|c| c.is_whitespace() || c.is_control())
3152 {
3153 (candidate, &payload[index + 1..])
3154 } else {
3155 ("﹅".to_owned(), payload)
3156 }
3157 }
3158 None => ("﹅".to_owned(), payload),
3159 };
3160 Inline::Em {
3161 mark,
3162 children: children(content),
3163 }
3164 }
3165 _ => return None,
3166 };
3167 Some((inline, end + 2))
3168}
3169
3170fn close_macro(value: &str) -> Option<usize> {
3171 let mut index = 2;
3172 let mut depth = 1;
3173 while index < value.len() {
3174 let rest = &value[index..];
3175 if rest.starts_with('\\') {
3176 index += rest.chars().nth(1)?.len_utf8() + 1;
3177 } else if rest.starts_with("[[") {
3178 depth += 1;
3179 index += 2;
3180 } else if rest.starts_with("]]") {
3181 depth -= 1;
3182 if depth == 0 {
3183 return Some(index);
3184 }
3185 index += 2;
3186 } else {
3187 index += rest.chars().next()?.len_utf8();
3188 }
3189 }
3190 None
3191}
3192
3193fn valid_kern(value: &str) -> bool {
3194 let value = value.strip_suffix("em").unwrap_or("");
3195 let value = value.strip_prefix(['+', '-']).unwrap_or(value);
3196 let mut parts = value.split('.');
3197 let whole = parts.next().unwrap_or("");
3198 let fraction = parts.next();
3199 parts.next().is_none()
3200 && !whole.is_empty()
3201 && whole.bytes().all(|b| b.is_ascii_digit())
3202 && fraction.is_none_or(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
3203}
3204
3205fn close_unescaped(value: &str, start: usize, needle: char) -> Option<usize> {
3206 let mut escaped = false;
3207 for (index, character) in value[start..].char_indices() {
3208 if escaped {
3209 escaped = false;
3210 continue;
3211 }
3212 if character == '\\' {
3213 escaped = true;
3214 continue;
3215 }
3216 if character == needle {
3217 return Some(start + index);
3218 }
3219 if character == '\n' {
3220 return None;
3221 }
3222 }
3223 None
3224}
3225
3226fn bare_index(value: &str, needle: char) -> Option<usize> {
3227 let mut escaped = false;
3228 for (index, character) in value.char_indices() {
3229 if escaped {
3230 escaped = false;
3231 continue;
3232 }
3233 if character == '\\' {
3234 escaped = true;
3235 continue;
3236 }
3237 if character == needle {
3238 return Some(index);
3239 }
3240 }
3241 None
3242}
3243
3244fn contains_unescaped(value: &str, needle: char) -> bool {
3245 bare_index(value, needle).is_some()
3246}
3247
3248fn split_unescaped(value: &str, separator: char) -> Vec<&str> {
3249 let mut parts = Vec::new();
3250 let mut start = 0;
3251 let mut escaped = false;
3252 for (index, character) in value.char_indices() {
3253 if escaped {
3254 escaped = false;
3255 continue;
3256 }
3257 if character == '\\' {
3258 escaped = true;
3259 continue;
3260 }
3261 if character == separator {
3262 parts.push(&value[start..index]);
3263 start = index + character.len_utf8();
3264 }
3265 }
3266 parts.push(&value[start..]);
3267 parts
3268}
3269
3270const ESCAPABLE_MDI: &str = "{}|^[]:《》\\";
3275const ESCAPABLE_RUBY: &str = "{}|^[]:《》\\.";
3276
3277fn unescape_mdi(value: &str) -> String {
3278 unescape(value, ESCAPABLE_MDI)
3279}
3280
3281fn unescape_ruby(value: &str) -> String {
3282 unescape(value, ESCAPABLE_RUBY)
3283}
3284
3285fn unescape(value: &str, allowed: &str) -> String {
3286 let mut out = String::new();
3287 let mut chars = value.chars();
3288 while let Some(character) = chars.next() {
3289 if character == '\\' {
3290 if let Some(next) = chars.next() {
3291 if allowed.contains(next) {
3292 out.push(next);
3293 } else {
3294 out.push(character);
3295 out.push(next);
3296 }
3297 } else {
3298 out.push(character);
3299 }
3300 } else {
3301 out.push(character);
3302 }
3303 }
3304 out
3305}
3306
3307fn is_escapable(character: char) -> bool {
3308 ESCAPABLE_MDI.contains(character)
3309}
3310
3311fn push_inline_text(
3312 out: &mut Vec<(Inline, usize, usize)>,
3313 text: &mut String,
3314 start: usize,
3315 end: usize,
3316) {
3317 if !text.is_empty() {
3318 out.push((Inline::Text(std::mem::take(text)), start, end));
3319 }
3320}
3321
3322#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3330enum BlockMacroClass {
3331 Indent(u32),
3332 Bottom(u32),
3333 Pagebreak(Option<PagebreakVariant>),
3334 Literal,
3335}
3336
3337#[cfg_attr(not(feature = "wasm"), allow(dead_code))]
3338fn classify_block_macro(source: &str) -> BlockMacroClass {
3339 let value = source.trim();
3340 match value {
3341 "[[pagebreak:right]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)),
3342 "[[pagebreak:left]]" => return BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)),
3343 "[[pagebreak]]" => return BlockMacroClass::Pagebreak(None),
3344 "[[bottom]]" => return BlockMacroClass::Bottom(0),
3345 _ => {}
3346 }
3347 if let Some((kind, amount)) = value
3348 .strip_prefix("[[")
3349 .and_then(|rest| rest.strip_suffix("]]"))
3350 .and_then(|inner| inner.split_once(':'))
3351 {
3352 let valid_amount = !amount.is_empty()
3353 && !amount.starts_with('0')
3354 && amount.bytes().all(|b| b.is_ascii_digit());
3355 if valid_amount && let Ok(amount) = amount.parse::<u32>() {
3356 match kind {
3357 "indent" => return BlockMacroClass::Indent(amount),
3358 "bottom" => return BlockMacroClass::Bottom(amount),
3359 _ => {}
3360 }
3361 }
3362 }
3363 BlockMacroClass::Literal
3364}
3365
3366#[cfg(feature = "wasm")]
3369mod wasm {
3370 use super::{
3371 BlockMacroClass, EpubCover, PagebreakVariant, RubyReading, TextFormat,
3372 apply_pdf_profile_json, classify_block_macro, page_size_catalog_json, parse_json,
3373 prepare_chromium_print_profile_json, render_docx, render_docx_with_profile, render_epub,
3374 render_epub_with_profile, render_html, render_text, render_text_format,
3375 resolve_export_profile_json, serialize_mdi, split_ruby, unescape_mdi, unescape_ruby,
3376 };
3377 use wasm_bindgen::prelude::*;
3378
3379 #[wasm_bindgen(js_name = parseMdiSyntaxJson)]
3383 pub fn wasm_parse_mdi_syntax_json(source: &str) -> String {
3384 parse_json(source)
3385 }
3386
3387 #[wasm_bindgen(js_name = renderHtml)]
3389 pub fn wasm_render_html(source: &str) -> String {
3390 render_html(source)
3391 }
3392
3393 #[wasm_bindgen(js_name = resolveExportProfileJson)]
3395 pub fn wasm_resolve_export_profile_json(
3396 profile_json: &str,
3397 source_writing_mode: Option<String>,
3398 require_layout: bool,
3399 ) -> Result<String, JsValue> {
3400 resolve_export_profile_json(profile_json, source_writing_mode.as_deref(), require_layout)
3401 .map_err(|message| JsValue::from_str(&message))
3402 }
3403
3404 #[wasm_bindgen(js_name = pageSizeCatalogJson)]
3405 pub fn wasm_page_size_catalog_json() -> Result<String, JsValue> {
3406 page_size_catalog_json().map_err(|message| JsValue::from_str(&message))
3407 }
3408
3409 #[wasm_bindgen(js_name = applyPdfProfileJson)]
3411 pub fn wasm_apply_pdf_profile_json(html: &str, profile_json: &str) -> Result<String, JsValue> {
3412 apply_pdf_profile_json(html, profile_json).map_err(|message| JsValue::from_str(&message))
3413 }
3414
3415 #[wasm_bindgen(js_name = prepareChromiumPrintProfileJson)]
3417 pub fn wasm_prepare_chromium_print_profile_json(
3418 html: &str,
3419 profile_json: &str,
3420 source_writing_mode: Option<String>,
3421 ) -> Result<String, JsValue> {
3422 prepare_chromium_print_profile_json(html, profile_json, source_writing_mode.as_deref())
3423 .map_err(|message| JsValue::from_str(&message))
3424 }
3425
3426 #[wasm_bindgen(js_name = serializeMdi)]
3428 pub fn wasm_serialize_mdi(source: &str) -> String {
3429 serialize_mdi(source)
3430 }
3431
3432 #[wasm_bindgen(js_name = renderText)]
3434 pub fn wasm_render_text(source: &str) -> String {
3435 render_text(source)
3436 }
3437
3438 #[wasm_bindgen(js_name = renderTextFormat)]
3440 pub fn wasm_render_text_format(
3441 source: &str,
3442 format: &str,
3443 indent_prefix: &str,
3444 ) -> Result<String, JsValue> {
3445 let format = TextFormat::parse(format)
3446 .ok_or_else(|| JsValue::from_str("Unsupported text format"))?;
3447 Ok(render_text_format(source, format, indent_prefix))
3448 }
3449
3450 #[wasm_bindgen(js_name = renderEpub)]
3452 pub fn wasm_render_epub(source: &str) -> Result<Box<[u8]>, JsValue> {
3453 render_epub(source)
3454 .map(Vec::into_boxed_slice)
3455 .map_err(|message| JsValue::from_str(&message))
3456 }
3457
3458 #[wasm_bindgen(js_name = renderEpubWithProfile)]
3460 pub fn wasm_render_epub_with_profile(
3461 source: &str,
3462 profile_json: &str,
3463 cover_data: &[u8],
3464 cover_media_type: Option<String>,
3465 ) -> Result<Box<[u8]>, JsValue> {
3466 let cover = cover_media_type.map(|media_type| EpubCover {
3467 data: cover_data.to_vec(),
3468 media_type,
3469 });
3470 render_epub_with_profile(source, profile_json, cover.as_ref())
3471 .map(Vec::into_boxed_slice)
3472 .map_err(|message| JsValue::from_str(&message))
3473 }
3474
3475 #[wasm_bindgen(js_name = renderDocx)]
3477 pub fn wasm_render_docx(source: &str) -> Result<Box<[u8]>, JsValue> {
3478 render_docx(source)
3479 .map(Vec::into_boxed_slice)
3480 .map_err(|message| JsValue::from_str(&message))
3481 }
3482
3483 #[wasm_bindgen(js_name = renderDocxWithProfile)]
3485 pub fn wasm_render_docx_with_profile(
3486 source: &str,
3487 profile_json: &str,
3488 ) -> Result<Box<[u8]>, JsValue> {
3489 render_docx_with_profile(source, profile_json)
3490 .map(Vec::into_boxed_slice)
3491 .map_err(|message| JsValue::from_str(&message))
3492 }
3493
3494 #[wasm_bindgen(js_name = unescapeMdi)]
3495 pub fn wasm_unescape_mdi(value: &str) -> String {
3496 unescape_mdi(value)
3497 }
3498
3499 #[wasm_bindgen(js_name = unescapeRubyText)]
3500 pub fn wasm_unescape_ruby(value: &str) -> String {
3501 unescape_ruby(value)
3502 }
3503
3504 #[wasm_bindgen(js_name = resolveRuby)]
3506 pub fn wasm_resolve_ruby(base: &str, raw_ruby: &str) -> JsValue {
3507 match split_ruby(base, raw_ruby) {
3508 RubyReading::Group(value) => JsValue::from_str(&value),
3509 RubyReading::Split(parts) => {
3510 let array = js_sys::Array::new();
3511 for part in parts {
3512 array.push(&JsValue::from_str(&part));
3513 }
3514 array.into()
3515 }
3516 }
3517 }
3518
3519 #[wasm_bindgen(js_name = blockMacroKind)]
3520 pub fn wasm_block_macro_kind(source: &str) -> String {
3521 match classify_block_macro(source) {
3522 BlockMacroClass::Indent(_) => "indent",
3523 BlockMacroClass::Bottom(_) => "bottom",
3524 BlockMacroClass::Pagebreak(_) => "pagebreak",
3525 BlockMacroClass::Literal => "literal",
3526 }
3527 .to_owned()
3528 }
3529
3530 #[wasm_bindgen(js_name = blockMacroAmount)]
3532 pub fn wasm_block_macro_amount(source: &str) -> i32 {
3533 match classify_block_macro(source) {
3534 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => amount as i32,
3535 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => -1,
3536 }
3537 }
3538
3539 #[wasm_bindgen(js_name = blockMacroVariant)]
3541 pub fn wasm_block_macro_variant(source: &str) -> String {
3542 match classify_block_macro(source) {
3543 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left)) => "left",
3544 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right)) => "right",
3545 _ => "",
3546 }
3547 .to_owned()
3548 }
3549}
3550
3551#[cfg(test)]
3552mod tests {
3553 use super::*;
3554 use std::io::{Error, Read, SeekFrom};
3555 use zip::ZipArchive;
3556
3557 struct FailAfterWrites {
3558 inner: Cursor<Vec<u8>>,
3559 remaining: usize,
3560 }
3561
3562 impl Write for FailAfterWrites {
3563 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
3564 if self.remaining == 0 {
3565 return Err(Error::other("injected archive write failure"));
3566 }
3567 self.remaining -= 1;
3568 self.inner.write(buffer)
3569 }
3570
3571 fn flush(&mut self) -> std::io::Result<()> {
3572 self.inner.flush()
3573 }
3574 }
3575
3576 impl Seek for FailAfterWrites {
3577 fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
3578 self.inner.seek(position)
3579 }
3580 }
3581
3582 fn assert_valid_spans(value: &serde_json::Value, source: &str) {
3583 let Some(object) = value.as_object() else {
3584 return;
3585 };
3586 if let Some(span) = object.get("span") {
3587 let start = span
3588 .get("startByte")
3589 .and_then(serde_json::Value::as_u64)
3590 .expect("span has startByte") as usize;
3591 let end = span
3592 .get("endByte")
3593 .and_then(serde_json::Value::as_u64)
3594 .expect("span has endByte") as usize;
3595 assert!(start <= end, "span start must not exceed end: {span}");
3596 assert!(
3597 end <= source.len(),
3598 "span must be inside its source: {span}"
3599 );
3600 assert!(
3601 source.is_char_boundary(start),
3602 "span start is a UTF-8 boundary: node={object:?}; source={source:?}"
3603 );
3604 let remainder = &source[start..];
3605 assert!(
3606 source.is_char_boundary(end),
3607 "span end is a UTF-8 boundary: node={object:?}; remainder={:?}",
3608 remainder
3609 );
3610 }
3611 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
3612 for child in children {
3613 assert_valid_spans(child, source);
3614 }
3615 }
3616 }
3617
3618 fn generated_source(mut state: u64) -> String {
3619 if state == 0 {
3623 return "---\nmdi: '3.0'\ntitle: adversarial\n---\n\n{東京|とうきょう}".to_owned();
3624 }
3625 const FRAGMENTS: &[&str] = &[
3626 "東京",
3627 "𠮟る",
3628 "👨👩👧",
3629 " ",
3630 "\\n",
3631 "\\\\",
3632 "{",
3633 "}",
3634 "|",
3635 ".",
3636 "^12^",
3637 "^_^",
3638 "{東京|とう.きょう}",
3639 "[[em:強調]]",
3640 "[[no-break:^12^]]",
3641 "[[kern:wide:x]]",
3642 "[[indent:2]]",
3643 "[[pagebreak:left]]",
3644 "《《傍点》》",
3645 "**strong**",
3646 "`^12^`",
3647 "[^n]",
3648 "\n\n",
3649 "| a | b |\n| - | - |\n| 1 | 2 |\n",
3650 "[link](https://example.test/?a=1&b=2)",
3651 "<script>x</script>",
3652 "\\{",
3653 "\\[",
3654 ];
3655 let mut source = String::new();
3656 for _ in 0..32 {
3657 state ^= state << 13;
3658 state ^= state >> 7;
3659 state ^= state << 17;
3660 source.push_str(FRAGMENTS[(state as usize) % FRAGMENTS.len()]);
3661 }
3662 source
3663 }
3664
3665 #[test]
3666 fn parses_ruby_and_split_ruby() {
3667 assert_eq!(
3668 parse_inlines("{東京|とう.きょう}"),
3669 vec![Inline::Ruby {
3670 base: "東京".into(),
3671 ruby: RubyReading::Split(vec!["とう".into(), "きょう".into()]),
3672 }]
3673 );
3674 assert_eq!(
3675 parse_inlines("{東京|.きょう}"),
3676 vec![Inline::Ruby {
3677 base: "東京".into(),
3678 ruby: RubyReading::Group("きょう".into()),
3679 }]
3680 );
3681 }
3682
3683 #[test]
3684 fn parses_nested_macros_and_escapes() {
3685 assert_eq!(
3686 parse_inlines("[[em:●:a[[no-break:b]]]]"),
3687 vec![Inline::Em {
3688 mark: "●".into(),
3689 children: vec![
3690 Inline::Text("a".into()),
3691 Inline::NoBreak(vec![Inline::Text("b".into())])
3692 ],
3693 }]
3694 );
3695 }
3696
3697 #[test]
3698 fn parses_tcy_and_boten() {
3699 assert_eq!(
3700 parse_inlines("第^12^話《《重要》》"),
3701 vec![
3702 Inline::Text("第".into()),
3703 Inline::Tcy("12".into()),
3704 Inline::Text("話".into()),
3705 Inline::Em {
3706 mark: "﹅".into(),
3707 children: vec![Inline::Text("重要".into())]
3708 },
3709 ]
3710 );
3711 }
3712
3713 #[test]
3714 fn recognises_block_macros() {
3715 assert_eq!(
3716 parse_mdi_syntax("[[indent:2]]\n本文\n[[pagebreak:left]]\n\\"),
3717 MdiSyntaxDocument {
3718 blocks: vec![
3719 MdiBlock::Paragraph {
3720 inlines: vec![Inline::Text("本文".into())],
3721 indent: Some(2),
3722 bottom: None
3723 },
3724 MdiBlock::Pagebreak {
3725 variant: Some(PagebreakVariant::Left)
3726 },
3727 MdiBlock::Blank,
3728 ]
3729 }
3730 );
3731 }
3732
3733 #[test]
3734 fn keeps_invalid_syntax_literal() {
3735 assert_eq!(
3736 parse_inlines("{plain} ^_^ [[kern:wide:text]] [[no-break:]]"),
3737 vec![Inline::Text(
3738 "{plain} ^_^ [[kern:wide:text]] [[no-break:]]".into()
3739 )]
3740 );
3741 }
3742
3743 #[test]
3744 fn recovers_from_malformed_inline_syntax_at_delimiter_boundaries() {
3745 for (source, expected) in [
3749 ("{東京|とうきょう", "{東京|とうきょう"),
3750 ("{東京|とうきょう ^12^", "{東京|とうきょう 12"),
3751 ("^1234567^ ^12^", "^1234567^ 12"),
3752 ("[[no-break:]][[em:強調]]", "[[no-break:]]強調"),
3753 ("[[kern:wide:字]][[warichu:注]]", "[[kern:wide:字]]注"),
3754 ("[[em:未閉", "[[em:未閉"),
3755 ("《《未閉", "《《未閉"),
3756 ("{", "{"),
3757 ("^", "^"),
3758 ("[[", "[["),
3759 ] {
3760 let rendered = render_text(source);
3761 assert_eq!(rendered.trim_end(), expected, "source: {source:?}");
3762 assert!(
3763 parse_output(source).diagnostics.is_empty(),
3764 "source: {source:?}"
3765 );
3766 }
3767 }
3768
3769 #[test]
3770 fn honors_escaped_alias_delimiters_without_consuming_them_as_closers() {
3771 assert_eq!(
3774 parse_inlines("《《a\\》》》"),
3775 vec![Inline::Em {
3776 mark: "﹅".into(),
3777 children: vec![Inline::Text("a》".into())],
3778 }]
3779 );
3780
3781 assert_eq!(
3784 parse_inlines("《《a\\》》"),
3785 vec![Inline::Text("《《a》》".into())]
3786 );
3787 }
3788
3789 #[test]
3790 fn applies_mdi_escapes_once_before_recognition() {
3791 assert_eq!(
3792 parse_inlines(r"\{東京\|とうきょう\} \^12\^ \[\[br\]\] \《《文字\》》"),
3793 vec![Inline::Text(
3794 "{東京|とうきょう} ^12^ [[br]] 《《文字》》".into()
3795 )]
3796 );
3797 }
3798
3799 #[test]
3800 fn unescapes_backslash_itself_but_leaves_non_escapable_pairs_alone() {
3801 assert_eq!(
3802 parse_inlines(r"\\ \n \a \0 \-"),
3803 vec![Inline::Text(r"\ \n \a \0 \-".into())]
3804 );
3805 }
3806
3807 #[test]
3808 fn treats_boten_alias_content_as_plain_text() {
3809 assert_eq!(
3810 parse_inlines(r"《《a\《b》》"),
3811 vec![Inline::Em {
3812 mark: "﹅".into(),
3813 children: vec![Inline::Text("a《b".into())]
3814 }]
3815 );
3816 assert_eq!(
3817 parse_inlines("《《雪》考》"),
3818 vec![Inline::Text("《《雪》考》".into())]
3819 );
3820 }
3821
3822 #[test]
3823 fn falls_back_to_default_boten_mark_for_invalid_mark_parameter() {
3824 assert_eq!(
3825 parse_inlines("[[em:ab:cd]]"),
3826 vec![Inline::Em {
3827 mark: "﹅".into(),
3828 children: vec![Inline::Text("ab:cd".into())]
3829 }]
3830 );
3831 }
3832
3833 #[test]
3834 fn counts_extended_graphemes_for_split_ruby() {
3835 assert_eq!(
3836 parse_inlines("{𠮟る|しか.る}"),
3837 vec![Inline::Ruby {
3838 base: "𠮟る".into(),
3839 ruby: RubyReading::Split(vec!["しか".into(), "る".into()])
3840 }]
3841 );
3842 assert_eq!(
3843 parse_inlines("{👨👩👧|かぞく}"),
3844 vec![Inline::Ruby {
3845 base: "👨👩👧".into(),
3846 ruby: RubyReading::Group("かぞく".into())
3847 }]
3848 );
3849 }
3850
3851 #[test]
3852 fn leaves_unattached_or_stacked_block_macros_literal() {
3853 assert_eq!(
3854 parse_mdi_syntax("[[indent:2]]"),
3855 MdiSyntaxDocument {
3856 blocks: vec![MdiBlock::Paragraph {
3857 inlines: vec![Inline::Text("[[indent:2]]".into())],
3858 indent: None,
3859 bottom: None
3860 }]
3861 }
3862 );
3863 assert_eq!(
3864 parse_mdi_syntax("[[indent:2]]\n[[bottom]]\n本文"),
3865 MdiSyntaxDocument {
3866 blocks: vec![
3867 MdiBlock::Paragraph {
3868 inlines: vec![Inline::Text("[[indent:2]]".into())],
3869 indent: None,
3870 bottom: None
3871 },
3872 MdiBlock::Paragraph {
3873 inlines: vec![Inline::Text("[[bottom]]".into())],
3874 indent: None,
3875 bottom: None
3876 },
3877 MdiBlock::Paragraph {
3878 inlines: vec![Inline::Text("本文".into())],
3879 indent: None,
3880 bottom: None
3881 }
3882 ]
3883 }
3884 );
3885 }
3886
3887 #[test]
3888 fn serializes_the_versioned_binding_contract() {
3889 let value: serde_json::Value =
3890 serde_json::from_str(&parse_json("[[indent:2]]\n第^12^話\n[[pagebreak:right]]"))
3891 .expect("parse output is valid JSON");
3892
3893 assert_eq!(value["irVersion"], "1.0");
3894 assert_eq!(value["syntaxVersion"], "2.0");
3895 assert_eq!(value["capabilities"]["mdi"], true);
3896 assert_eq!(value["capabilities"]["commonMark"], true);
3897 assert_eq!(value["capabilities"]["gfm"], true);
3898 assert_eq!(value["capabilities"]["frontMatter"], true);
3899 assert_eq!(value["capabilities"]["sourceSpans"], true);
3900 assert_eq!(value["diagnostics"], serde_json::json!([]));
3901 assert_eq!(value["document"]["children"][0]["type"], "paragraph");
3902 assert_eq!(
3903 value["document"]["children"][0]["children"][1]["type"],
3904 "tcy"
3905 );
3906 assert_eq!(value["document"]["span"]["endByte"], 43);
3907 }
3908
3909 #[test]
3910 fn parses_commonmark_gfm_frontmatter_and_utf8_byte_spans() {
3911 let document = parse_document(
3912 "---\ntitle: 雪\nwriting-mode: vertical\n---\n\n# 見出し\n\n- [x] {東京|とう.きょう}\n\n| a | b |\n| - | - |\n| 1 | 2 |\n",
3913 );
3914 assert_eq!(
3915 document.frontmatter.as_ref().unwrap().entries[0].key,
3916 "title"
3917 );
3918 assert_eq!(document.children[0]["type"], "heading");
3919 assert_eq!(document.children[1]["type"], "list");
3920 assert_eq!(document.children[2]["type"], "table");
3921 assert_eq!(document.children[0]["span"]["startByte"], 43);
3922 }
3923
3924 #[test]
3925 fn keeps_mdi_looking_text_literal_in_code_and_blockquotes() {
3926 let document = parse_document("> \\n\n`^12^`\n\n```mdi\n{東京|とうきょう}\n```\n");
3927 assert_eq!(document.children[0]["type"], "blockquote");
3928 assert!(document.children.iter().any(|node| node["type"] == "code"));
3929 }
3930
3931 #[test]
3932 fn lowers_root_flow_markers_without_a_host_markdown_parser() {
3933 let document = parse_document("[[indent:2]]\n本文\n[[pagebreak:right]]\n\\\n");
3934 assert_eq!(document.children[0]["type"], "paragraph");
3935 assert_eq!(document.children[0]["indent"], 2);
3936 assert_eq!(document.children[1]["type"], "pagebreak");
3937 assert_eq!(document.children[1]["variant"], "right");
3938 assert_eq!(document.children[2]["type"], "blank");
3939 }
3940
3941 #[test]
3942 fn lets_a_bracket_macro_own_markdown_inline_boundaries() {
3943 let document = parse_document("[[em:**重要**]]");
3944 let em = &document.children[0]["children"][0];
3945 assert_eq!(em["type"], "em");
3946 assert_eq!(em["mark"], "﹅");
3947 assert_eq!(em["children"][0]["type"], "strong");
3948 assert_eq!(em["children"][0]["children"][0]["value"], "重要");
3949 }
3950
3951 #[test]
3952 fn preserves_markdown_and_mdi_boundaries_when_a_macro_is_mixed_with_text() {
3953 assert!(
3954 markdown_paragraph_children(
3955 "前 [[em:**重要**]] 後",
3956 &serde_json::json!({"startByte": 0, "endByte": 26})
3957 )
3958 .is_some()
3959 );
3960 let document = parse_document("前 [[em:**重要**]] 後");
3961 let children = &document.children[0]["children"];
3962 assert_eq!(children[0]["value"], "前");
3963 assert_eq!(children[1]["value"], " ");
3964 assert_eq!(children[2]["type"], "em");
3965 assert_eq!(children[2]["children"][0]["type"], "strong");
3966 assert_eq!(children[3]["value"], " ");
3967 assert_eq!(children[4]["value"], "後");
3968 assert_eq!(children[2]["span"]["startByte"], 4);
3969 assert_eq!(children[2]["span"]["endByte"], 21);
3970 assert_eq!(children[2]["children"][0]["span"]["startByte"], 9);
3971 assert_eq!(children[2]["children"][0]["span"]["endByte"], 19);
3972 }
3973
3974 #[test]
3975 fn recursively_lowers_mdi_inside_a_macro_markdown_payload() {
3976 let document = parse_document("[[em:{東京|とう.きょう}[[no-break:^12^]]]]");
3977 let children = &document.children[0]["children"][0]["children"];
3978 assert_eq!(children[0]["type"], "ruby");
3979 assert_eq!(children[1]["type"], "noBreak");
3980 assert_eq!(children[1]["children"][0]["type"], "tcy");
3981 }
3982
3983 #[test]
3984 fn keeps_utf8_spans_correct_when_mdi_is_followed_by_a_footnote() {
3985 let document =
3986 parse_document("# 題\n\n{東京|とうきょう}と[[em:強調]]。[^n]\n\n[^n]: 注の本文");
3987 let paragraph = &document.children[1];
3988 assert_eq!(paragraph["span"]["startByte"], 7);
3989 assert_eq!(paragraph["children"][0]["type"], "ruby");
3990 assert_eq!(paragraph["children"][0]["base"], "東京");
3991 assert_eq!(paragraph["children"][0]["span"]["startByte"], 7);
3992 assert_eq!(paragraph["children"][0]["span"]["endByte"], 31);
3993 assert_eq!(paragraph["children"][2]["type"], "em");
3994 assert_eq!(paragraph["children"][2]["span"]["startByte"], 34);
3995 assert_eq!(paragraph["children"][2]["span"]["endByte"], 47);
3996 assert_eq!(paragraph["children"][4]["type"], "footnoteReference");
3997 assert_eq!(paragraph["children"][4]["identifier"], "n");
3998 }
3999
4000 #[test]
4001 fn renders_a_standalone_html_document_from_rust_ir() {
4002 let html = render_html(
4003 "---\ntitle: 雪女\nlang: ja\nwriting-mode: vertical\n---\n\n# 題\n\n{東京|とうきょう} ^12^",
4004 );
4005 assert!(html.starts_with("<!DOCTYPE html>"));
4006 assert!(html.contains("<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">"));
4007 assert!(html.contains("<title>雪女</title>"));
4008 assert!(html.contains("<h1>題</h1>"));
4009 assert!(html.contains("<ruby class=\"mdi-ruby\">東京<rp>(</rp><rt>とうきょう</rt>"));
4010 assert!(html.contains("<span class=\"mdi-tcy\">12</span>"));
4011 }
4012
4013 #[test]
4014 fn renders_every_documented_mdi_construct_in_vertical_html() {
4015 let vertical = "---\ntitle: 構文\nlang: ja\nwriting-mode: vertical\n---\n\n";
4016 for (name, source, expected) in [
4017 (
4018 "front matter",
4019 "# 見出し",
4020 "<html lang=\"ja\" style=\"writing-mode: vertical-rl;\">",
4021 ),
4022 (
4023 "group ruby",
4024 "{東京|とうきょう}",
4025 "<ruby class=\"mdi-ruby\">東京",
4026 ),
4027 (
4028 "split ruby",
4029 "{東京|とう.きょう}",
4030 "<ruby class=\"mdi-ruby\"",
4031 ),
4032 ("tate-chu-yoko", "^12^", "<span class=\"mdi-tcy\">12</span>"),
4033 ("default boten", "[[em:傍点]]", "class=\"mdi-em\""),
4034 ("custom boten", "[[em:※:任意]]", "--mdi-em:"※""),
4035 ("no-break", "[[no-break:改行禁止]]", "class=\"mdi-nobr\""),
4036 (
4037 "explicit line break",
4038 "前[[br]]次",
4039 "<br class=\"mdi-break\"/>",
4040 ),
4041 ("blank backslash", "\\", "<p class=\"mdi-blank\"></p>"),
4042 ("blank br", "<br>", "<p class=\"mdi-blank\"></p>"),
4043 ("blank br slash", "<br />", "<p class=\"mdi-blank\"></p>"),
4044 (
4045 "blank legacy macro",
4046 "[[blank]]",
4047 "<p class=\"mdi-blank\"></p>",
4048 ),
4049 ("warichu", "[[warichu:割注]]", "class=\"mdi-warichu\""),
4050 ("kerning", "[[kern:-0.1em:詰め]]", "--mdi-kern:-0.1em"),
4051 ("indent", "[[indent:2]]\n字下げ", "class=\"mdi-indent\""),
4052 (
4053 "bottom alignment",
4054 "[[bottom]]\n地付き",
4055 "class=\"mdi-bottom\"",
4056 ),
4057 ("bottom shift", "[[bottom:2]]\n地付き", "--mdi-shift:2"),
4058 ("page break", "[[pagebreak]]", "class=\"mdi-pagebreak\""),
4059 (
4060 "recto page break",
4061 "[[pagebreak:right]]",
4062 "class=\"mdi-pagebreak mdi-pagebreak-right\"",
4063 ),
4064 (
4065 "verso page break",
4066 "[[pagebreak:left]]",
4067 "class=\"mdi-pagebreak mdi-pagebreak-left\"",
4068 ),
4069 ("footnote", "脚注[^n]\n\n[^n]: 注", "data-footnotes"),
4070 (
4071 "escaped delimiters",
4072 "\\{ \\} \\| \\^ \\[ \\: \\《 \\》",
4073 "{ } | ^ [ : 《 》",
4074 ),
4075 ] {
4076 let html = render_html(&format!("{vertical}{source}"));
4077 assert!(
4078 html.contains(expected),
4079 "missing {name}: {expected}; rendered {html}"
4080 );
4081 }
4082 assert!(render_html(&format!("{vertical}\\")).contains(".mdi-blank{min-block-size:1lh}"));
4083 }
4084
4085 #[test]
4086 fn renders_footnote_definitions_in_html() {
4087 let html = render_html("本文[^n]\n\n[^n]: 注の本文");
4088 assert!(html.contains("data-footnotes"));
4089 assert!(html.contains("id=\"user-content-fn-n\""));
4090 assert!(html.contains("注の本文"));
4091 }
4092
4093 #[test]
4094 fn escapes_raw_html_in_the_rust_renderer() {
4095 let html = render_html("<script>alert(1)</script>");
4096 assert!(html.contains("<script>alert(1)</script>"));
4097 assert!(!html.contains("<script>alert(1)</script>"));
4098 }
4099
4100 #[test]
4101 fn serializes_mdi_from_rust_ir() {
4102 let source = "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]";
4103 assert_eq!(
4104 serialize_mdi(source),
4105 "---\ntitle: 雪\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n"
4106 );
4107 }
4108
4109 #[test]
4110 fn renders_plain_text_from_rust_ir() {
4111 assert_eq!(
4112 render_text("# 題\n\n{東京|とうきょう} ^12^"),
4113 "題\n東京 12\n"
4114 );
4115 }
4116
4117 #[test]
4118 fn renders_platform_text_formats_from_rust_ir() {
4119 let source = "# 題\n\n{東京|とう.きょう}[[em:強調]]。[^n]\n\n[^n]: 注";
4120 assert_eq!(
4121 render_text_format(source, TextFormat::Plain, ""),
4122 "題\n東京強調。"
4123 );
4124 assert_eq!(
4125 render_text_format(source, TextFormat::Ruby, ""),
4126 "題\n{東京|とう.きょう}強調。"
4127 );
4128 assert_eq!(
4129 render_text_format(source, TextFormat::Kakuyomu, ""),
4130 "題\n|東京《とうきょう》《《強調》》。[注1]\n\nFootnotes\n1. 注"
4131 );
4132 assert!(
4133 render_text_format(source, TextFormat::Aozora, "").contains("[#「題」は中見出し]")
4134 );
4135 }
4136
4137 #[test]
4138 fn renders_and_serializes_every_public_inline_and_block_variant() {
4139 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";
4140
4141 let html = render_html(source);
4142 for expected in [
4143 "<h2>中見出し</h2>",
4144 "<h3>小見出し</h3>",
4145 "<blockquote><p>引用</p>",
4146 "<ol>",
4147 "<ul>",
4148 "<pre><code class=\"language-rust\">",
4149 "<hr>",
4150 "<table>",
4151 "<a href=\"https://example.test\" title=\"題\">リンク</a>",
4152 "<img src=\"image.png\" alt=\"画像\">",
4153 "<del>削除</del>",
4154 "<code>code</code>",
4155 "<br class=\"mdi-break\"/>",
4156 "mdi-warichu",
4157 "mdi-kern",
4158 "--mdi-em:"●"",
4159 ] {
4160 assert!(html.contains(expected), "HTML contains {expected}");
4161 }
4162
4163 let canonical = serialize_mdi(source);
4164 for expected in [
4165 "[[bottom]]",
4166 "## 中見出し",
4167 "> 引用",
4168 "1. 一",
4169 "- 箇条",
4170 "```rust",
4171 "| 見出し | 値 |",
4172 "[リンク](https://example.test \\題\")",
4173 "",
4174 "~~削除~~",
4175 "`code`",
4176 "[[br]]",
4177 "[[warichu:割書]]",
4178 "[[kern:1em:字]]",
4179 "[[em:●:傍点]]",
4180 ] {
4181 assert!(
4182 canonical.contains(expected),
4183 "canonical MDI contains {expected}"
4184 );
4185 }
4186
4187 let plain = render_text(source);
4188 assert!(plain.contains("画像"));
4189 assert!(plain.contains("巢狀"));
4190
4191 for (name, format) in [
4192 ("txt", TextFormat::Plain),
4193 ("txt-ruby", TextFormat::Ruby),
4194 ("narou", TextFormat::Narou),
4195 ("kakuyomu", TextFormat::Kakuyomu),
4196 ("aozora", TextFormat::Aozora),
4197 ] {
4198 assert_eq!(TextFormat::parse(name), Some(format));
4199 assert!(!render_text_format(source, format, " ").is_empty());
4200 }
4201 assert_eq!(TextFormat::parse("unknown"), None);
4202 }
4203
4204 #[test]
4205 fn packages_an_epub_from_rust_ir() {
4206 let bytes = render_epub("---\ntitle: Test\nwriting-mode: vertical\n---\n\n# One\n\ntext\n\n[[pagebreak]]\n\n# Two\n\nmore").unwrap();
4207 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4208 let mut mimetype = String::new();
4209 zip.by_name("mimetype")
4210 .unwrap()
4211 .read_to_string(&mut mimetype)
4212 .unwrap();
4213 assert_eq!(mimetype, "application/epub+zip");
4214 let mut opf = String::new();
4215 zip.by_name("OEBPS/package.opf")
4216 .unwrap()
4217 .read_to_string(&mut opf)
4218 .unwrap();
4219 assert!(opf.contains("<dc:title>Test</dc:title>"));
4220 assert!(opf.contains("<meta property=\"dcterms:modified\">"));
4221 assert!(opf.contains("page-progression-direction=\"rtl\""));
4222 assert!(opf.contains("chapter-2.xhtml"));
4223 }
4224
4225 #[test]
4226 fn packages_configured_epub_metadata_cover_chapters_and_local_footnotes() {
4227 let cover = EpubCover {
4228 data: vec![0x89, 0x50, 0x4e, 0x47],
4229 media_type: "image/png".to_owned(),
4230 };
4231 let bytes = render_epub_with_profile(
4232 "# One\n\nnote[^n]\n\n[[pagebreak]]\n\n## Two\n\nmore\n\n[^n]: text",
4233 r#"{
4234 "layout":{"system":"japanese-publisher"},
4235 "metadata":{"title":"Book","author":"Writer","publisher":"Press","identifier":"urn:test","language":"en","date":"2026-07-23"},
4236 "typesetting":{"writingMode":"vertical","fontFamily":"Noto Serif JP","fontSize":11,"lineSpacing":1.5,"textIndentEm":2,"fullwidthSpaceIndent":true},
4237 "pagination":{"gridMode":"typographic"},
4238 "epub":{"chapterSplitLevel":"h2"}
4239 }"#,
4240 Some(&cover),
4241 )
4242 .unwrap();
4243 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4244 let mut opf = String::new();
4245 zip.by_name("OEBPS/package.opf")
4246 .unwrap()
4247 .read_to_string(&mut opf)
4248 .unwrap();
4249 assert!(opf.contains("<dc:title>Book</dc:title>"));
4250 assert!(opf.contains("<dc:creator>Writer</dc:creator>"));
4251 assert!(opf.contains("<dc:publisher>Press</dc:publisher>"));
4252 assert!(opf.contains("<dc:date>2026-07-23</dc:date>"));
4253 assert!(opf.contains("cover-image"));
4254 assert!(opf.contains("page-progression-direction=\"rtl\""));
4255 let mut css = String::new();
4256 zip.by_name("OEBPS/style.css")
4257 .unwrap()
4258 .read_to_string(&mut css)
4259 .unwrap();
4260 assert!(css.contains("font-family:Noto Serif JP"));
4261 assert!(css.contains("font-size:11pt"));
4262 assert!(css.contains("line-height:1.5"));
4263 let mut chapter = String::new();
4264 zip.by_name("OEBPS/chapter-1.xhtml")
4265 .unwrap()
4266 .read_to_string(&mut chapter)
4267 .unwrap();
4268 assert!(chapter.contains("href=\"#user-content-fn-n\""));
4269 assert!(chapter.contains("id=\"user-content-fn-n\""));
4270 assert!(chapter.contains("href=\"#user-content-fnref-n\""));
4271 assert!(zip.by_name("OEBPS/chapter-2.xhtml").is_ok());
4272 assert!(zip.by_name("OEBPS/cover.png").is_ok());
4273 }
4274
4275 #[test]
4276 fn packages_a_docx_from_rust_ir() {
4277 let bytes = render_docx("---\ntitle: Test\n---\n\n# 題\n\n{東京|とうきょう}").unwrap();
4278 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4279 let mut document = String::new();
4280 zip.by_name("word/document.xml")
4281 .unwrap()
4282 .read_to_string(&mut document)
4283 .unwrap();
4284 assert!(document.contains("題"));
4285 assert!(document.contains("東京"));
4286 let mut core = String::new();
4287 zip.by_name("docProps/core.xml")
4288 .unwrap()
4289 .read_to_string(&mut core)
4290 .unwrap();
4291 assert!(core.contains("<dc:title>Test</dc:title>"));
4292 }
4293
4294 #[test]
4295 fn packages_configured_docx_geometry_typography_content_and_book_settings() {
4296 let bytes = render_docx_with_profile(
4297 "# {第一章|だいいっしょう}\n\n本文[^n]\n\n- 一\n- 二\n\n|項目|値|\n|-|-|\n|契約|有効|\n\n[link](https://example.com) ^12^ [[em:圏点]]\n\n[^n]: 脚注",
4298 r#"{
4299 "layout":{"system":"japanese-publisher","marginMode":"mirror","bindingSide":"right","gutter":3},
4300 "metadata":{"title":"契約","author":"MDI"},
4301 "typesetting":{"writingMode":"vertical","fontFamily":"Yu Mincho","fontSize":10.5,"fullwidthSpaceIndent":true},
4302 "pagination":{"pageSize":"A4","landscape":true,"charactersPerLine":40,"linesPerPage":30,"gridMode":"strict","pageNumbers":{"enabled":true,"format":"fraction","position":"top-right"}}
4303 }"#,
4304 )
4305 .unwrap();
4306 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4307 let mut document = String::new();
4308 zip.by_name("word/document.xml")
4309 .unwrap()
4310 .read_to_string(&mut document)
4311 .unwrap();
4312 assert!(document.contains("<w:ruby>"));
4313 assert!(document.contains("<w:lid w:val=\"ja-JP\"/>"));
4314 assert!(document.contains("<w:eastAsianLayout"));
4315 assert!(document.contains("<w:em w:val=\"dot\"/>"));
4316 assert!(document.contains("<w:tbl>"));
4317 assert!(document.contains("<w:footnoteReference w:id=\"1\"/>"));
4318 assert!(document.contains("<w:textDirection w:val=\"tbRl\"/>"));
4319 assert!(document.contains("<w:docGrid w:type=\"linesAndChars\""));
4320 assert!(document.contains("<w:pgSz w:w=\"16838\" w:h=\"11906\"/>"));
4321 assert!(document.contains("<w:hyperlink r:id=\"rId1\">"));
4322
4323 let mut settings = String::new();
4324 zip.by_name("word/settings.xml")
4325 .unwrap()
4326 .read_to_string(&mut settings)
4327 .unwrap();
4328 assert!(settings.contains("<w:mirrorMargins/>"));
4329 assert!(!settings.contains("rtlGutter"));
4330
4331 let mut header = String::new();
4332 zip.by_name("word/header1.xml")
4333 .unwrap()
4334 .read_to_string(&mut header)
4335 .unwrap();
4336 assert!(header.contains("NUMPAGES"));
4337 assert!(header.contains("<w:jc w:val=\"right\"/>"));
4338 assert!(zip.by_name("word/footnotes.xml").is_ok());
4339 }
4340
4341 #[test]
4342 fn configured_docx_rejects_word_limits_and_uses_typographic_spacing() {
4343 let oversized = render_docx_with_profile(
4344 "text",
4345 r#"{"layout":{"system":"word"},"pagination":{"pageSize":"A0"}}"#,
4346 )
4347 .unwrap_err();
4348 assert!(oversized.contains("22-inch maximum"));
4349 let long_font = render_docx_with_profile(
4350 "text",
4351 r#"{"layout":{"system":"word"},"typesetting":{"fontFamily":"12345678901234567890123456789012"}}"#,
4352 )
4353 .unwrap_err();
4354 assert!(long_font.contains("at most 31 characters"));
4355
4356 let bytes = render_docx_with_profile(
4357 "text",
4358 r#"{"layout":{"system":"word"},"typesetting":{"lineSpacing":1.5},"pagination":{"gridMode":"typographic","pageNumbers":{"enabled":false}}}"#,
4359 )
4360 .unwrap();
4361 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4362 let mut document = String::new();
4363 zip.by_name("word/document.xml")
4364 .unwrap()
4365 .read_to_string(&mut document)
4366 .unwrap();
4367 assert!(!document.contains("<w:docGrid"));
4368 assert!(!document.contains("headerReference"));
4369 assert!(!document.contains("footerReference"));
4370 }
4371
4372 #[test]
4373 fn configured_docx_renders_every_supported_block_and_inline_style() {
4374 let source = r#"# Heading
4375
4376> Quote with *italic*, **bold**, and `inline code`.
4377>
4378> ```text
4379> quoted code
4380> ```
4381
4382```rust
4383let first = 1;
4384let second = 2;
4385```
4386
4387---
4388
4389First line [[br]] second line with ~~strike~~, <span>raw</span>, , ,
4390[[warichu:small print]], [[kern:0.1em:spaced]], and {東京|とう.きょう}.
4391
4392[same link](https://example.com) and [same target](https://example.com)
4393"#;
4394 let bytes = render_docx_with_profile(
4395 source,
4396 r#"{
4397 "layout":{"system":"japanese-publisher"},
4398 "typesetting":{"writingMode":"horizontal","fontSize":12},
4399 "pagination":{"charactersPerLine":10,"linesPerPage":10,"gridMode":"strict","pageNumbers":{"enabled":true,"format":"simple","position":"bottom-left"}}
4400 }"#,
4401 )
4402 .unwrap();
4403 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
4404 let mut document = String::new();
4405 zip.by_name("word/document.xml")
4406 .unwrap()
4407 .read_to_string(&mut document)
4408 .unwrap();
4409 for marker in [
4410 "MdiQuote",
4411 "MdiCode",
4412 "MdiThematicBreak",
4413 "<w:i/>",
4414 "<w:b/>",
4415 "<w:strike/>",
4416 "Courier New",
4417 "<w:br/>",
4418 "[Image: cover]",
4419 "[Image]",
4420 "<w:spacing w:val=\"24\"/>",
4421 "<w:ruby>",
4422 "w:charSpace=",
4423 ] {
4424 assert!(document.contains(marker), "missing DOCX marker: {marker}");
4425 }
4426 assert_eq!(document.matches("<w:hyperlink r:id=\"rId1\">").count(), 2);
4427
4428 let mut footer = String::new();
4429 zip.by_name("word/footer1.xml")
4430 .unwrap()
4431 .read_to_string(&mut footer)
4432 .unwrap();
4433 assert!(footer.contains("<w:jc w:val=\"left\"/>"));
4434 assert!(footer.contains("> PAGE <"));
4435 }
4436
4437 #[test]
4438 fn renders_pdf_with_an_available_native_chromium() {
4439 let Some(chromium_path) = find_chromium() else {
4440 return;
4441 };
4442 let pdf = render_pdf(
4443 "# 題\n\n{東京|とうきょう}",
4444 &PdfOptions {
4445 chromium_path: Some(chromium_path),
4446 },
4447 )
4448 .unwrap();
4449 assert!(pdf.starts_with(b"%PDF-"));
4450 }
4451
4452 #[test]
4453 fn adversarial_utf8_corpus_never_escapes_source_spans_or_the_wire_contract() {
4454 for seed in 0..256 {
4455 let source = generated_source(seed);
4456 let output = parse_output(&source);
4457 assert_eq!(output.document.span.start_byte, 0);
4458 assert_eq!(output.document.span.end_byte as usize, source.len());
4459 for child in &output.document.children {
4460 assert_valid_spans(child, &source);
4461 }
4462 for diagnostic in &output.diagnostics {
4463 let span = diagnostic
4464 .span
4465 .expect("parser diagnostics have source spans");
4466 assert!(span.start_byte <= span.end_byte);
4467 assert!((span.end_byte as usize) <= source.len());
4468 }
4469
4470 let wire: serde_json::Value = serde_json::from_str(&parse_json(&source))
4471 .expect("every UTF-8 input has a serializable wire result");
4472 assert_eq!(wire["irVersion"], MDI_IR_VERSION);
4473 assert_eq!(wire["syntaxVersion"], MDI_SPEC_VERSION);
4474 assert_valid_spans(&wire["document"], &source);
4475
4476 assert!(!render_html_document(&output.document).is_empty());
4480 let canonical = serialize_mdi_document(&output.document);
4481 let reparsed = parse_document(&canonical);
4482 for child in &reparsed.children {
4483 assert_valid_spans(child, &canonical);
4484 }
4485 let _ = render_text_document(&reparsed);
4486 }
4487 }
4488
4489 #[test]
4490 fn canonical_serialization_is_idempotent_for_the_supported_syntax_matrix() {
4491 let cases = [
4492 "",
4493 "plain\n",
4494 "---\ntitle: 雪\nmdi: '999.0'\n---\n\n# 題\n\n{東京|とう.きょう} [[em:**重要**]]\n",
4495 "> {東京|とうきょう}\n> \n> - [x] ^12^\n\n[^n]: 注\n\n本文[^n]\n",
4496 "[[indent:2]]\n本文\n\n[[bottom:3]]\n《《傍点》》\n\n[[pagebreak:right]]\n\n\\\n",
4497 "| a | b |\n| --- | --- |\n| {東京|とうきょう} | `^12^` |\n",
4498 "```mdi\n{東京|とうきょう}\n[[em:literal]]\n```\n",
4499 ];
4500 for source in cases {
4501 let first = serialize_mdi(source);
4502 let second = serialize_mdi(&first);
4503 assert_eq!(
4504 second, first,
4505 "canonical output must stabilize for {source:?}"
4506 );
4507 assert_valid_spans(
4508 &serde_json::to_value(parse_document(&first)).unwrap(),
4509 &first,
4510 );
4511 }
4512 }
4513
4514 #[test]
4515 fn archive_exports_have_required_parts_and_escape_untrusted_metadata() {
4516 let source = "---\ntitle: 'A & < B \"quoted\"'\nauthor: 'O''Brien & Co.'\nlang: ja\n---\n\n# 題\n\n<unsafe>&\n\n[[pagebreak]]\n\n# 次\n";
4517
4518 let epub = render_epub(source).unwrap();
4519 let mut epub = ZipArchive::new(Cursor::new(epub)).unwrap();
4520 assert_eq!(
4521 epub.by_name("mimetype").unwrap().compression(),
4522 CompressionMethod::Stored,
4523 "EPUB requires its mimetype member to be uncompressed"
4524 );
4525 for path in [
4526 "META-INF/container.xml",
4527 "OEBPS/package.opf",
4528 "OEBPS/nav.xhtml",
4529 "OEBPS/style.css",
4530 "OEBPS/chapter-1.xhtml",
4531 "OEBPS/chapter-2.xhtml",
4532 ] {
4533 assert!(epub.by_name(path).is_ok(), "EPUB has {path}");
4534 }
4535 let mut opf = String::new();
4536 epub.by_name("OEBPS/package.opf")
4537 .unwrap()
4538 .read_to_string(&mut opf)
4539 .unwrap();
4540 assert!(opf.contains("A & < B "quoted""));
4541 assert!(opf.contains("O'Brien & Co."));
4542
4543 let docx = render_docx(source).unwrap();
4544 let mut docx = ZipArchive::new(Cursor::new(docx)).unwrap();
4545 for path in [
4546 "[Content_Types].xml",
4547 "_rels/.rels",
4548 "docProps/core.xml",
4549 "word/document.xml",
4550 ] {
4551 assert!(docx.by_name(path).is_ok(), "DOCX has {path}");
4552 }
4553 let mut core = String::new();
4554 docx.by_name("docProps/core.xml")
4555 .unwrap()
4556 .read_to_string(&mut core)
4557 .unwrap();
4558 assert!(core.contains("A & < B "quoted""));
4559 let mut document = String::new();
4560 docx.by_name("word/document.xml")
4561 .unwrap()
4562 .read_to_string(&mut document)
4563 .unwrap();
4564 assert!(document.contains("<unsafe>"));
4565 assert!(document.contains("&"));
4566 }
4567
4568 #[test]
4569 fn classifies_every_legacy_block_macro_shape() {
4570 let amount = |value| match classify_block_macro(value) {
4571 BlockMacroClass::Indent(amount) | BlockMacroClass::Bottom(amount) => Some(amount),
4572 BlockMacroClass::Pagebreak(_) | BlockMacroClass::Literal => None,
4573 };
4574 assert_eq!(amount(" [[indent:12]] "), Some(12));
4575 assert_eq!(amount("[[bottom]]"), Some(0));
4576 assert_eq!(amount("[[bottom:3]]"), Some(3));
4577 assert_eq!(amount("literal"), None);
4578 assert!(matches!(
4579 classify_block_macro("[[pagebreak]]"),
4580 BlockMacroClass::Pagebreak(None)
4581 ));
4582 assert!(matches!(
4583 classify_block_macro("[[pagebreak:left]]"),
4584 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Left))
4585 ));
4586 assert!(matches!(
4587 classify_block_macro("[[pagebreak:right]]"),
4588 BlockMacroClass::Pagebreak(Some(PagebreakVariant::Right))
4589 ));
4590 for literal in [
4591 "text",
4592 "[[unknown:1]]",
4593 "[[indent:]]",
4594 "[[indent:0]]",
4595 "[[indent:01]]",
4596 "[[indent:x]]",
4597 "[[indent:999999999999999999999999999999]]",
4598 ] {
4599 assert!(matches!(
4600 classify_block_macro(literal),
4601 BlockMacroClass::Literal
4602 ));
4603 }
4604 }
4605
4606 #[test]
4607 fn reports_version_diagnostics_and_recovers_from_non_mapping_frontmatter() {
4608 let newer = parse_output("---\nmdi: '3.0'\n---\n\ntext");
4609 assert_eq!(newer.diagnostics.len(), 1);
4610 assert_eq!(newer.diagnostics[0].severity, DiagnosticSeverity::Warning);
4611 assert_eq!(newer.diagnostics[0].code, "mdi.version.unsupported");
4612 assert_eq!(
4613 newer.diagnostics[0].span,
4614 newer.document.frontmatter.map(|f| f.span)
4615 );
4616
4617 for source in [
4618 "---\nmdi: '2.0'\n---\n",
4619 "---\nmdi: 3\n---\n",
4620 "---\n- sequence\n- values\n---\n",
4621 "---\n[malformed\n---\n",
4622 ] {
4623 let output = parse_output(source);
4624 assert!(output.diagnostics.is_empty());
4625 assert!(output.document.frontmatter.is_some());
4626 }
4627 }
4628
4629 #[test]
4630 fn pdf_renderer_reports_spawn_and_nonzero_process_errors() {
4631 let missing =
4632 std::env::temp_dir().join(format!("mdi-core-missing-chromium-{}", std::process::id()));
4633 let error = render_pdf(
4634 "text",
4635 &PdfOptions {
4636 chromium_path: Some(missing),
4637 },
4638 )
4639 .unwrap_err();
4640 assert!(error.contains("failed to start Chromium"));
4641
4642 let current_exe = std::env::current_exe().unwrap();
4643 let error = render_pdf(
4644 "text",
4645 &PdfOptions {
4646 chromium_path: Some(current_exe),
4647 },
4648 )
4649 .unwrap_err();
4650 assert!(error.contains("Chromium PDF rendering failed"));
4651 }
4652
4653 #[test]
4654 fn defensive_helpers_handle_partial_ir_and_all_literal_fallbacks() {
4655 let mut scalar = serde_json::json!(null);
4656 lower_markdown_inside_mdi(&mut scalar, "");
4657 shift_spans(&mut scalar, 10);
4658 annotate_and_lower(&mut scalar, "", false);
4659 inject_block_markers(&mut scalar, &[]);
4660 assert_valid_spans(&scalar, "");
4661
4662 let mut paragraph_with_invalid_span = serde_json::json!({
4663 "type": "paragraph",
4664 "span": {"startByte": 2, "endByte": 3}
4665 });
4666 lower_markdown_inside_mdi(&mut paragraph_with_invalid_span, "");
4667
4668 let mut text_without_value = serde_json::json!({
4669 "type": "text",
4670 "position": {"start": {"offset": 0}, "end": {"offset": 0}}
4671 });
4672 annotate_and_lower(&mut text_without_value, "", false);
4673 let mut text_without_position = serde_json::json!({
4674 "type": "text",
4675 "value": "^12^"
4676 });
4677 annotate_and_lower(&mut text_without_position, "^12^", false);
4678 assert_eq!(text_without_position["children"][0]["type"], "tcy");
4679 let mut text_with_partial_span = serde_json::json!({
4680 "type": "text",
4681 "value": "^12^",
4682 "span": {}
4683 });
4684 annotate_and_lower(&mut text_with_partial_span, "^12^", false);
4685 assert_eq!(text_with_partial_span["children"][0]["type"], "tcy");
4686
4687 assert!(markdown_macro_children("not-a-macro", 0).is_none());
4688 assert!(markdown_macro_children("[[em:x]]tail", 0).is_none());
4689
4690 let span = SourceSpan {
4691 start_byte: 0,
4692 end_byte: 10,
4693 };
4694 for (is_indent, amount, expected) in [
4695 (true, 2, "[[indent:2]]"),
4696 (false, 0, "[[bottom]]"),
4697 (false, 3, "[[bottom:3]]"),
4698 ] {
4699 let mut output = Vec::new();
4700 let mut pending = Some((span, is_indent, amount));
4701 flush_pending_literal(&mut output, &mut pending);
4702 assert_eq!(output[0]["children"][0]["value"], expected);
4703 }
4704
4705 assert!(matches!(
4706 paragraph(
4707 "text",
4708 Some(PendingBlock::Bottom {
4709 amount: 2,
4710 source: "[[bottom:2]]".to_owned()
4711 })
4712 ),
4713 MdiBlock::Paragraph {
4714 bottom: Some(2),
4715 ..
4716 }
4717 ));
4718 assert!(pending_block("[[unknown:2]]").is_none());
4719 assert!(boten("《《》》").is_none());
4720 assert!(!valid_kern("1.em"));
4721 assert!(!valid_kern("1.2.3em"));
4722 assert_eq!(unescape_mdi("trailing\\"), "trailing\\");
4723
4724 let mut writer = FailAfterWrites {
4725 inner: Cursor::new(Vec::new()),
4726 remaining: 1,
4727 };
4728 writer.flush().unwrap();
4729
4730 let mut formatted = Vec::new();
4731 text_format_block(
4732 &serde_json::json!({"type": "blank"}),
4733 TextFormat::Plain,
4734 "",
4735 &[],
4736 &[],
4737 &mut formatted,
4738 );
4739 text_format_block(
4740 &serde_json::json!({"type": "unknown"}),
4741 TextFormat::Plain,
4742 "",
4743 &[],
4744 &[],
4745 &mut formatted,
4746 );
4747 assert_eq!(formatted, vec![String::new()]);
4748
4749 assert_eq!(
4750 text_format_inline(
4751 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":"じ"}}),
4752 TextFormat::Ruby,
4753 &[]
4754 ),
4755 "{字|じ}"
4756 );
4757 assert_eq!(
4758 text_format_inline(
4759 &serde_json::json!({"type":"ruby", "base":"字", "ruby":{"value":null}}),
4760 TextFormat::Plain,
4761 &[]
4762 ),
4763 "字"
4764 );
4765 assert_eq!(
4766 text_format_inline(
4767 &serde_json::json!({"type":"image", "alt":""}),
4768 TextFormat::Plain,
4769 &[]
4770 ),
4771 "[画像]"
4772 );
4773
4774 let mut html = String::new();
4775 render_html_node(&serde_json::json!({}), &mut html);
4776 render_html_node(
4777 &serde_json::json!({"type":"unknown", "children":[{"type":"text", "value":"ok"}]}),
4778 &mut html,
4779 );
4780 render_html_children(&serde_json::json!({"type":"root"}), &mut html);
4781 assert_eq!(html, "ok");
4782
4783 let stacked = parse_document("[[indent:2]]\n[[bottom:3]]\ntext");
4784 assert_eq!(stacked.children[0]["children"][0]["value"], "[[indent:2]]");
4785 let heading = parse_document("[[indent:2]]\n# heading");
4786 assert_eq!(heading.children[0]["children"][0]["value"], "[[indent:2]]");
4787 let trailing = parse_document("[[indent:2]]\n[[bottom:3]]");
4788 assert_eq!(trailing.children.len(), 2);
4789
4790 let partial_document = Document {
4791 span: SourceSpan::default(),
4792 frontmatter: None,
4793 children: vec![serde_json::json!({
4794 "type": "root",
4795 "children": [{"type":"text", "value":"partial"}]
4796 })],
4797 };
4798 assert!(render_docx_document(&partial_document).is_ok());
4799 }
4800
4801 #[test]
4802 fn epub_handles_empty_documents_and_heading_driven_chapter_splits() {
4803 let empty = render_epub("").unwrap();
4804 let mut empty = ZipArchive::new(Cursor::new(empty)).unwrap();
4805 assert!(empty.by_name("OEBPS/chapter-1.xhtml").is_ok());
4806
4807 let split = render_epub("intro\n\n# Chapter\n\nbody").unwrap();
4808 let mut split = ZipArchive::new(Cursor::new(split)).unwrap();
4809 assert!(split.by_name("OEBPS/chapter-2.xhtml").is_ok());
4810 }
4811
4812 #[test]
4813 fn archive_writers_propagate_failures_from_every_write_stage() {
4814 let document = parse_document(
4815 "---\ntitle: Failure matrix\nauthor: Test\n---\n\nintro\n\n# Chapter\n\nbody",
4816 );
4817
4818 let mut epub_errors = 0;
4819 let mut epub_success = false;
4820 for remaining in 0..256 {
4821 let writer = FailAfterWrites {
4822 inner: Cursor::new(Vec::new()),
4823 remaining,
4824 };
4825 let mut zip = ZipWriter::new(writer);
4826 let result = write_epub_document(&document, &mut zip);
4827 if result.is_err() {
4828 epub_errors += 1;
4829 } else {
4830 epub_success = true;
4831 }
4832 std::mem::forget(zip);
4836 if epub_success {
4837 break;
4838 }
4839 }
4840 assert!(epub_errors > 0);
4841 assert!(epub_success);
4842
4843 let mut docx_errors = 0;
4844 let mut docx_success = false;
4845 for remaining in 0..256 {
4846 let writer = FailAfterWrites {
4847 inner: Cursor::new(Vec::new()),
4848 remaining,
4849 };
4850 let mut zip = ZipWriter::new(writer);
4851 let result = write_docx_document(&document, &mut zip);
4852 if result.is_err() {
4853 docx_errors += 1;
4854 } else {
4855 docx_success = true;
4856 }
4857 std::mem::forget(zip);
4858 if docx_success {
4859 break;
4860 }
4861 }
4862 assert!(docx_errors > 0);
4863 assert!(docx_success);
4864 }
4865
4866 #[allow(unsafe_code)]
4867 fn ffi_bytes(result: ffi::MdiFfiResult) -> Result<Vec<u8>, String> {
4868 let value = if result.value.len == 0 {
4869 Vec::new()
4870 } else {
4871 unsafe { std::slice::from_raw_parts(result.value.data, result.value.len).to_vec() }
4872 };
4873 let error = if result.error.len == 0 {
4874 None
4875 } else {
4876 Some(unsafe {
4877 std::str::from_utf8(std::slice::from_raw_parts(
4878 result.error.data,
4879 result.error.len,
4880 ))
4881 .unwrap()
4882 .to_owned()
4883 })
4884 };
4885 unsafe {
4886 ffi::mdi_free_buffer(result.value);
4887 ffi::mdi_free_buffer(result.error);
4888 }
4889 error.map_or(Ok(value), Err)
4890 }
4891
4892 #[test]
4893 #[allow(unsafe_code)]
4894 fn c_abi_returns_owned_versioned_wire_data_for_every_export() {
4895 let source = "{東京|とうきょう} ^12^";
4896 let json = String::from_utf8(
4897 ffi_bytes(ffi::mdi_parse_json(source.as_ptr(), source.len())).unwrap(),
4898 )
4899 .unwrap();
4900 assert!(json.contains("\"irVersion\":\"1.0\""));
4901 assert!(json.contains("\"type\":\"ruby\""));
4902
4903 let html = String::from_utf8(
4904 ffi_bytes(ffi::mdi_render_html(source.as_ptr(), source.len())).unwrap(),
4905 )
4906 .unwrap();
4907 assert!(html.contains("<ruby class=\"mdi-ruby\">東京"));
4908 assert_eq!(
4909 String::from_utf8(
4910 ffi_bytes(ffi::mdi_serialize_mdi(source.as_ptr(), source.len())).unwrap()
4911 )
4912 .unwrap(),
4913 "{東京|とうきょう} ^12^\n"
4914 );
4915 assert_eq!(
4916 String::from_utf8(
4917 ffi_bytes(ffi::mdi_render_text(source.as_ptr(), source.len())).unwrap()
4918 )
4919 .unwrap(),
4920 "東京 12\n"
4921 );
4922 assert!(
4923 ffi_bytes(ffi::mdi_render_epub(source.as_ptr(), source.len()))
4924 .unwrap()
4925 .starts_with(b"PK")
4926 );
4927 assert!(
4928 ffi_bytes(ffi::mdi_render_docx(source.as_ptr(), source.len()))
4929 .unwrap()
4930 .starts_with(b"PK")
4931 );
4932
4933 assert!(
4934 ffi_bytes(ffi::mdi_render_text(std::ptr::null(), 0))
4935 .unwrap()
4936 .is_empty()
4937 );
4938 let invalid_utf8 = [0xff];
4939 assert_eq!(
4940 ffi_bytes(ffi::mdi_render_html(
4941 invalid_utf8.as_ptr(),
4942 invalid_utf8.len()
4943 ))
4944 .unwrap_err(),
4945 "MDI source must be valid UTF-8"
4946 );
4947
4948 assert_eq!(
4949 ffi_bytes(ffi::mdi_parse_json(std::ptr::null(), 1)).unwrap_err(),
4950 "MDI source pointer is null"
4951 );
4952 assert_eq!(
4953 ffi_bytes(ffi::mdi_render_epub(std::ptr::null(), 1)).unwrap_err(),
4954 "MDI source pointer is null"
4955 );
4956 }
4957}