1pub(crate) mod front_matter;
113
114use std::borrow::Cow;
115use std::collections::HashMap;
116
117use crate::{
118 ImportError, ImportWarning, Imported, MAX_IMAGE_BYTES, WarningDetail, WarningKind, assets,
119 heading,
120};
121use front_matter::FrontMatter;
122use setzer_model::asset::{Asset, ImageFormat};
123use setzer_model::{
124 Block, Book, ColumnAlign, Epigraph, ImagePlacement, ImageSide, Inline, Lang, MAX_VERSE_INDENT,
125 Numbering, Section, SectionKind, Table, TableCell, TableColumn,
126};
127
128pub fn import(
133 src: &str,
134 mut files: impl FnMut(&str) -> Option<Vec<u8>>,
135) -> Result<Imported, ImportError> {
136 let src = soft_hyphens(strip_comments(src));
137 if src.trim().is_empty() {
138 return Err(ImportError::Empty);
139 }
140 let lines: Vec<&str> = src.lines().collect();
141 let FrontMatter {
142 mut meta,
143 lang_given,
144 dedication,
145 end: mut i,
146 warnings,
147 } = front_matter::read(&lines);
148 if !lang_given && let Some(lang) = crate::lang::detect(lines[i..].iter().copied()) {
150 meta.lang = Lang(lang.into());
151 }
152
153 let mut lines = lines;
154 let mut body = Body {
155 sections: Vec::new(),
156 warnings,
157 notes: Notes::collect(&mut lines[i..], i),
158 files: &mut files,
159 assets: Vec::new(),
160 images: HashMap::new(),
161 front: 0,
162 dedication: None,
163 };
164 if let Some(d) = dedication {
165 let blocks = vec![Block::paragraph(body.inlines(&d))];
166 body.sections.push(Section {
167 style: Default::default(),
168 kind: SectionKind::Dedication,
169 title: None,
170 numbering: Numbering::None,
171 epigraph: None,
172 blocks,
173 });
174 body.note_warnings(1);
175 body.front = body.sections.len();
176 }
177
178 let mut para: Vec<&str> = Vec::new();
180 let mut para_start = 0;
181 while i < lines.len() {
182 let line = lines[i].trim();
183 let line_no = i + 1;
184 if let Some(h) = line.strip_prefix("# ") {
185 body.flush(&mut para, para_start);
186 let (h, unnumbered) = unnumbered(h);
189 let info = heading::classify(h);
190 body.sections.push(Section {
191 style: Default::default(),
192 kind: info.kind,
193 numbering: if unnumbered {
194 Numbering::None
195 } else {
196 info.numbering()
197 },
198 title: info.title,
199 epigraph: None,
200 blocks: Vec::new(),
201 });
202 } else if let Some((level, text)) = sub_heading(line) {
203 body.flush(&mut para, para_start);
204 let block = Block::heading(level, body.inlines(text));
205 body.push(block, line_no);
206 } else if let Some(image) = image_line(line) {
207 body.flush(&mut para, para_start);
208 body.image(&image, line_no);
209 } else if let Some(kind) = fence(line) {
210 body.flush(&mut para, para_start);
211 i = match kind {
212 Fence::Verse => body.verse(&lines, i),
213 Fence::Epigraph => body.epigraph(&lines, i),
214 };
215 continue;
216 } else if line.starts_with('>') {
217 body.flush(&mut para, para_start);
218 let (quote, next) = block_quote(&lines, i, &mut body.notes);
219 if let Some(block) = quote {
220 body.push(block, line_no);
221 }
222 i = next;
223 continue;
224 } else if para.is_empty()
225 && let Some((caption, pipe_table, next)) = caption_above(&lines, i)
226 {
227 body.table(&pipe_table, Some(caption));
228 i = next;
229 continue;
230 } else if let Some((pipe_table, mut next)) = table(&lines, i) {
231 body.flush(&mut para, para_start);
232 let caption = caption_below(&lines, next).map(|(caption, after)| {
233 next = after;
234 caption
235 });
236 body.table(&pipe_table, caption);
237 i = next;
238 continue;
239 } else if heading::is_scene_break(line) {
240 body.flush(&mut para, para_start);
241 if let Some(s) = body.current() {
242 if !s.blocks.is_empty()
244 && !matches!(s.blocks.last(), Some(Block::SceneBreak { .. }))
245 {
246 s.blocks.push(Block::scene_break());
247 }
248 }
249 } else if is_page_break(line) {
250 body.flush(&mut para, para_start);
251 if let Some(s) = body.current() {
252 if !s.blocks.is_empty() && !matches!(s.blocks.last(), Some(Block::PageBreak { .. }))
255 {
256 s.blocks.push(Block::page_break());
257 }
258 }
259 } else if para.is_empty()
260 && let Some((run, next)) = list(&lines, i, &mut body.notes)
261 {
262 match run {
263 ListRun::Lists(lists) => {
264 for block in lists {
265 body.push(block, line_no);
266 }
267 }
268 ListRun::Numbered { start, paras } => body.numbered(start, ¶s),
269 }
270 i = next;
271 continue;
272 } else if line.is_empty() {
273 body.flush(&mut para, para_start);
274 } else {
275 if para.is_empty() {
276 para_start = line_no;
277 }
278 para.push(line);
279 }
280 i += 1;
281 }
282 body.flush(&mut para, para_start);
283 body.unused_note_warnings();
284 let Body {
285 mut sections,
286 mut warnings,
287 assets,
288 ..
289 } = body;
290
291 for s in &mut sections {
294 while matches!(
295 s.blocks.last(),
296 Some(Block::SceneBreak { .. } | Block::PageBreak { .. })
297 ) {
298 s.blocks.pop();
299 }
300 }
301
302 if meta.title.is_empty() {
303 warnings.push(ImportWarning {
304 kind: WarningKind::NoTitle,
305 location: 1,
306 section: None,
307 message: "No `Title:` line found; the book has no title.".into(),
308 detail: None,
309 });
310 }
311
312 let mut book = Book::new(meta);
313 book.sections = sections;
314 Ok(Imported {
315 book,
316 assets,
317 warnings,
318 })
319}
320
321pub(crate) fn strip_comments(src: &str) -> Cow<'_, str> {
328 if !src.contains("<!--") {
329 return Cow::Borrowed(src);
330 }
331 let mut out = String::with_capacity(src.len());
332 let mut rest = src;
333 while let Some(open) = rest.find("<!--") {
334 let Some(len) = rest[open + 4..].find("-->") else {
335 break;
336 };
337 let comment = &rest[open..open + 4 + len + 3];
338 out.push_str(&rest[..open]);
339 rest = &rest[open + comment.len()..];
340 let breaks = comment.matches('\n').count();
341 out.extend(std::iter::repeat_n('\n', breaks));
342 if breaks == 0 && out.ends_with([' ', '\t']) {
344 rest = rest.trim_start_matches([' ', '\t']);
345 }
346 }
347 out.push_str(rest);
348 Cow::Owned(out)
349}
350
351fn soft_hyphens(src: Cow<'_, str>) -> Cow<'_, str> {
355 if src.contains("­") {
356 Cow::Owned(src.replace("­", "\u{AD}"))
357 } else {
358 src
359 }
360}
361
362struct Body<'a> {
365 sections: Vec<Section>,
366 warnings: Vec<ImportWarning>,
367 notes: Notes,
368 files: &'a mut dyn FnMut(&str) -> Option<Vec<u8>>,
369 assets: Vec<Asset>,
370 images: HashMap<String, Option<String>>,
372 front: usize,
375 dedication: Option<usize>,
378}
379
380impl Body<'_> {
381 fn current(&mut self) -> Option<&mut Section> {
383 if self.sections.len() > self.front {
384 self.sections.last_mut()
385 } else {
386 None
387 }
388 }
389
390 fn current_index(&self) -> Option<usize> {
392 self.sections
393 .len()
394 .checked_sub(1)
395 .filter(|&i| i >= self.front)
396 }
397
398 fn inlines(&mut self, s: &str) -> Vec<Inline> {
399 parse_inlines(s, &mut self.notes)
400 }
401
402 fn flush(&mut self, para: &mut Vec<&str>, start: usize) {
404 if para.is_empty() {
405 return;
406 }
407 let text = join_lines(para);
408 para.clear();
409 let block = Block::paragraph(self.inlines(&text));
410 self.push(block, start);
411 }
412
413 fn push(&mut self, block: Block, line_no: usize) {
417 match self.current() {
418 Some(s) => s.blocks.push(block),
419 None => {
420 if let Some(block) = self.dedication(block) {
421 self.warnings.push(ImportWarning {
422 kind: WarningKind::TextBeforeFirstChapter,
423 location: line_no,
424 section: Some(self.sections.len()),
425 message: "Text before the first chapter heading was put into an \
426 untitled section."
427 .into(),
428 detail: None,
429 });
430 self.sections.push(Section {
431 style: Default::default(),
432 kind: SectionKind::Other,
433 title: None,
434 numbering: Numbering::None,
435 epigraph: None,
436 blocks: vec![block],
437 });
438 }
439 }
440 }
441 self.note_warnings(line_no);
442 }
443
444 fn dedication(&mut self, block: Block) -> Option<Block> {
451 let Block::Paragraph { content, .. } = &block else {
452 return Some(block);
453 };
454 let chars = |c: &[Inline]| Inline::plain_text(c).trim().chars().count();
455 let len = chars(content);
456 let open = self
457 .dedication
458 .filter(|&d| d + 1 == self.sections.len())
459 .and_then(|d| self.sections.get(d));
460 let joins = open.is_some_and(|s| {
461 let so_far: usize = s
462 .blocks
463 .iter()
464 .map(|b| match b {
465 Block::Paragraph { content, .. } => chars(content),
466 _ => 0,
467 })
468 .sum();
469 s.blocks.len() < DEDICATION_PARAS && so_far + len <= DEDICATION_CHARS
470 });
471 let opens =
472 len <= DEDICATION_CHARS && crate::is_dedication(Inline::plain_text(content).trim());
473 if !joins && !opens {
474 return Some(block);
475 }
476 let para = Block::paragraph(match content.as_slice() {
477 [Inline::Emphasis { content: inner }] => inner.clone(),
478 _ => content.clone(),
479 });
480 match self.sections.last_mut() {
481 Some(s) if joins => s.blocks.push(para),
482 _ => {
483 self.sections.push(Section {
484 style: Default::default(),
485 kind: SectionKind::Dedication,
486 title: None,
487 numbering: Numbering::None,
488 epigraph: None,
489 blocks: vec![para],
490 });
491 self.front = self.sections.len();
492 self.dedication = Some(self.front - 1);
493 }
494 }
495 None
496 }
497
498 fn numbered(&mut self, start: u32, paras: &[(usize, Vec<&str>)]) {
502 for (line, lines) in paras {
503 let block = Block::paragraph(self.inlines(&join_lines(lines)));
504 self.push(block, line + 1);
505 }
506 let Some(&(first, _)) = paras.first() else {
507 return;
508 };
509 self.warnings.push(ImportWarning {
510 kind: WarningKind::ListFlattened,
511 location: first + 1,
512 section: self.current_index(),
513 message: format!(
514 "This numbered list starts at {start}, so its items were kept as paragraphs \
515 with their numbers: lists are numbered from 1."
516 ),
517 detail: None,
518 });
519 }
520
521 fn image(&mut self, image: &ImageLine, line_no: usize) {
524 let id = match self.images.get(image.path) {
525 Some(id) => id.clone(),
526 None => {
527 let id = self.asset(image.path, line_no);
528 self.images.insert(image.path.to_string(), id.clone());
529 id
530 }
531 };
532 let Some(id) = id else {
533 return;
534 };
535 let alt = Inline::plain_text(&parse_inlines(image.alt, &mut Notes::default()));
536 let caption = image
537 .caption
538 .map(str::trim)
539 .filter(|c| !c.is_empty())
540 .map(|c| self.inlines(c));
541 let (width, placement, side) = image_layout(image.attrs);
542 let mut block = Block::image(id, alt.trim(), caption)
543 .with_placement(placement)
544 .with_side(side);
545 if let Some(width) = width {
546 block = block.with_width(width);
547 }
548 self.push(block, line_no);
549 }
550
551 fn table(&mut self, table: &PipeTable, caption: Option<Caption<'_>>) {
559 let Some(&(start, _)) = table.rows.first() else {
560 return;
561 };
562 let width = table.aligns.len();
563 let warn = |section: Option<usize>, message: &str| ImportWarning {
566 kind: WarningKind::TableSimplified,
567 location: start + 1,
568 section,
569 message: message.into(),
570 detail: None,
571 };
572 if table
573 .rows
574 .iter()
575 .all(|(_, cells)| cells.iter().all(String::is_empty))
576 {
577 if let Some(caption) = caption {
578 let block = Block::paragraph(self.inlines(caption.text));
579 self.push(block, caption.line + 1);
580 }
581 let section = self.current_index();
582 self.warnings
583 .push(warn(section, "This table has no text, so it was left out."));
584 return;
585 }
586 let above = caption.filter(|c| c.above).map(|c| self.inlines(c.text));
589 let surplus = table
590 .rows
591 .iter()
592 .any(|(_, cells)| cells.iter().skip(width).any(|c| !c.is_empty()));
593 let header = table.rows[0].1.iter().any(|c| !c.is_empty());
594 let mut rows = Vec::with_capacity(table.rows.len());
595 for (n, (_, cells)) in table.rows.iter().enumerate() {
596 if n == 0 && !header {
597 continue;
598 }
599 rows.push(
600 cells
601 .iter()
602 .take(width)
603 .map(|cell| TableCell::new(self.inlines(cell)))
604 .collect(),
605 );
606 }
607 let columns = table.aligns.iter().map(|&a| TableColumn::new(a)).collect();
608 let mut table_block = Table::new(columns, u32::from(header), rows);
609 let below = caption.filter(|c| !c.above).map(|c| self.inlines(c.text));
610 if let Some(caption) = above.or(below) {
611 table_block = table_block.with_caption(caption);
612 }
613 self.push(Block::table(table_block), start + 1);
614 if surplus {
615 let section = self.current_index();
616 self.warnings.push(warn(
617 section,
618 "Some rows of this table have more cells than its header row; the extra \
619 cells were left out.",
620 ));
621 }
622 }
623
624 fn asset(&mut self, path: &str, line_no: usize) -> Option<String> {
626 let section = self.current_index();
627 let mut warn = |kind, message: String| {
628 self.warnings.push(ImportWarning {
629 kind,
630 location: line_no,
631 section,
632 message,
633 detail: Some(WarningDetail::Image {
634 name: Some(path.to_string()),
635 }),
636 });
637 };
638 if path.contains("://") || path.starts_with("data:") {
639 warn(
640 WarningKind::ImageMissing,
641 format!(
642 "The image `{path}` is a web address. Kernsetzer works offline: save the \
643 image next to the manuscript and use its file name."
644 ),
645 );
646 return None;
647 }
648 let Some(bytes) = (self.files)(path) else {
649 warn(
650 WarningKind::ImageMissing,
651 format!(
652 "The image file `{path}` was not found (paths are relative to the manuscript)."
653 ),
654 );
655 return None;
656 };
657 if bytes.len() as u64 > MAX_IMAGE_BYTES {
658 warn(
659 WarningKind::ImageMissing,
660 format!("The image file `{path}` is too large (over 100 MB)."),
661 );
662 return None;
663 }
664 match ImageFormat::sniff(&bytes) {
665 ImageFormat::Supported { ext, .. } => {
666 let id = assets::unique_id(path, ext, &self.assets);
667 self.assets.push(Asset::new(id.clone(), bytes));
668 Some(id)
669 }
670 ImageFormat::Unsupported(name) => {
671 warn(
672 WarningKind::ImageUnsupported,
673 format!(
674 "The image `{path}` is in {name} format, which cannot be used in a \
675 book. Replace it with a PNG or JPEG."
676 ),
677 );
678 None
679 }
680 }
681 }
682
683 fn verse(&mut self, lines: &[&str], start: usize) -> usize {
689 let line_no = start + 1;
690 let (inner, next, closed) = fence_body(lines, start);
691 let texts: Vec<(usize, &str)> = inner
692 .iter()
693 .map(|raw| (leading_columns(raw), hard_break(raw.trim()).0))
694 .collect();
695 let levels = verse_levels(&texts);
696 let mut verse: Vec<Vec<Inline>> = Vec::new();
697 let mut indents: Vec<u8> = Vec::new();
698 for (&(_, text), level) in texts.iter().zip(levels) {
699 if !text.is_empty() {
700 let content = self.inlines(text);
701 verse.push(content);
702 indents.push(level);
703 } else if verse.last().is_some_and(|l| !l.is_empty()) {
704 verse.push(Vec::new());
705 indents.push(0);
706 }
707 }
708 while verse.last().is_some_and(Vec::is_empty) {
709 verse.pop();
710 indents.pop();
711 }
712 if !verse.is_empty() {
713 self.push(Block::verse(verse).with_indents(indents), line_no);
714 }
715 if !closed {
716 self.fence_not_closed("verse", line_no);
717 }
718 next
719 }
720
721 fn epigraph(&mut self, lines: &[&str], start: usize) -> usize {
729 let line_no = start + 1;
730 let (inner, next, closed) = fence_body(lines, start);
731 let inner: Vec<&str> = inner.into_iter().map(str::trim).collect();
732 let mut paras: Vec<Vec<&str>> = inner
733 .split(|l| l.is_empty())
734 .filter(|p| !p.is_empty())
735 .map(<[&str]>::to_vec)
736 .collect();
737 let mut attribution = None;
738 if paras.iter().map(Vec::len).sum::<usize>() > 1
739 && let Some(last) = paras.last_mut()
740 && let Some(line) = last.pop_if(|l| crate::attribution_text(l).is_some())
741 {
742 attribution = Some(line);
743 if last.is_empty() {
744 paras.pop();
745 }
746 }
747 let at_heading = self
749 .current()
750 .map(|s| s.blocks.is_empty() && s.epigraph.is_none());
751 if at_heading == Some(false) {
752 paras.extend(attribution.map(|a| vec![a]));
753 let blocks: Vec<Block> = paras
754 .iter()
755 .map(|p| Block::paragraph(self.inlines(&join_lines(p))))
756 .collect();
757 if !blocks.is_empty() {
758 self.push(Block::block_quote(blocks), line_no);
759 }
760 } else if !paras.is_empty() {
761 let mut text = Vec::new();
762 for (n, p) in paras.iter().enumerate() {
763 if n > 0 {
764 text.push(Inline::LineBreak);
765 }
766 text.extend(self.inlines(&join_lines(p)));
767 }
768 let attribution = attribution.and_then(crate::attribution_text).map(|a| {
769 let inlines = parse_inlines(a, &mut Notes::default());
770 Inline::plain_text(&inlines).trim().to_string()
771 });
772 let epigraph = Epigraph { text, attribution };
773 match self.current() {
774 Some(s) => s.epigraph = Some(epigraph),
775 None => {
776 self.sections.push(Section {
777 style: Default::default(),
778 kind: SectionKind::Epigraph,
779 title: None,
780 numbering: Numbering::None,
781 epigraph: Some(epigraph),
782 blocks: Vec::new(),
783 });
784 self.front = self.sections.len();
785 }
786 }
787 self.note_warnings(line_no);
788 }
789 if !closed {
790 self.fence_not_closed("epigraph", line_no);
791 }
792 next
793 }
794
795 fn fence_not_closed(&mut self, name: &str, line_no: usize) {
797 self.warnings.push(ImportWarning {
798 kind: WarningKind::VerseNotClosed,
799 location: line_no,
800 section: self.current_index(),
801 detail: Some(WarningDetail::Fence { name: name.into() }),
802 message: format!(
803 "This `:::{name}` has no closing `:::` line, so it was ended before the next \
804 heading. Add `:::` where the {name} ends."
805 ),
806 });
807 }
808
809 fn note_warnings(&mut self, line_no: usize) {
812 let section = self.sections.len().checked_sub(1);
813 for label in self.notes.missing.drain(..) {
814 self.warnings.push(ImportWarning {
815 kind: WarningKind::FootnoteMissing,
816 location: line_no,
817 section,
818 detail: Some(WarningDetail::Footnote {
819 label: label.clone(),
820 }),
821 message: format!(
822 "Footnote [^{label}] has no `[^{label}]: …` line; the mark was kept as text."
823 ),
824 });
825 }
826 }
827
828 fn unused_note_warnings(&mut self) {
831 let mut unused: Vec<(usize, String)> = self
832 .notes
833 .defs
834 .iter()
835 .filter(|(_, d)| !d.used)
836 .map(|(label, d)| (d.line, label.clone()))
837 .collect();
838 unused.sort();
839 for (line, label) in unused {
840 self.warnings.push(unused_note(line, &label, ""));
841 }
842 for (label, line) in std::mem::take(&mut self.notes.duplicates) {
843 self.warnings.push(unused_note(line, &label, " again"));
844 }
845 self.warnings.sort_by_key(|w| w.location);
846 }
847}
848
849fn unused_note(line: usize, label: &str, again: &str) -> ImportWarning {
850 ImportWarning {
851 kind: WarningKind::FootnoteUnused,
852 location: line,
853 section: None,
854 detail: Some(WarningDetail::Footnote {
855 label: label.into(),
856 }),
857 message: format!(
858 "Footnote [^{label}] is defined{again} here, but no [^{label}] mark uses this text; \
859 it is not in the book."
860 ),
861 }
862}
863
864#[derive(Default)]
877struct Notes {
878 defs: HashMap<String, NoteDef>,
879 duplicates: Vec<(String, usize)>,
881 missing: Vec<String>,
884}
885
886struct NoteDef {
887 line: usize,
888 paras: Vec<String>,
889 used: bool,
890}
891
892impl Notes {
893 fn collect(lines: &mut [&str], offset: usize) -> Self {
896 let mut notes = Notes::default();
897 let mut i = 0;
898 while i < lines.len() {
899 let Some((label, first)) = note_definition(lines[i]) else {
900 i += 1;
901 continue;
902 };
903 let line = offset + i + 1;
904 let mut paras = vec![vec![first.trim()]];
905 lines[i] = "";
906 i += 1;
907 while i < lines.len() {
908 let raw = lines[i];
909 let next = raw.trim();
910 let in_para = paras
911 .last()
912 .is_some_and(|p| p.iter().any(|l| !l.is_empty()));
913 if next.is_empty() {
914 let indented = lines
917 .get(i + 1)
918 .is_some_and(|l| is_indented(l) && !l.trim().is_empty());
919 if !indented {
920 break;
921 }
922 paras.push(Vec::new());
923 } else if is_indented(raw) || (in_para && !ends_note(next)) {
924 if let Some(p) = paras.last_mut() {
925 p.push(next);
926 }
927 } else {
928 break;
929 }
930 lines[i] = "";
931 i += 1;
932 }
933 let paras: Vec<String> = paras
934 .iter()
935 .map(|p| join_lines(p).trim().to_string())
936 .filter(|p| !p.is_empty())
937 .collect();
938 if notes.defs.contains_key(label) {
939 notes.duplicates.push((label.to_string(), line));
940 } else {
941 notes.defs.insert(
942 label.to_string(),
943 NoteDef {
944 line,
945 paras,
946 used: false,
947 },
948 );
949 }
950 }
951 notes
952 }
953
954 fn resolve(&mut self, label: &str) -> Option<Vec<Block>> {
956 let Some(def) = self.defs.get_mut(label) else {
957 self.missing.push(label.to_string());
958 return None;
959 };
960 def.used = true;
961 let mut inner = Notes::default();
962 Some(
963 def.paras
964 .iter()
965 .map(|p| Block::paragraph(parse_inlines(p, &mut inner)))
966 .collect(),
967 )
968 }
969}
970
971fn note_definition(line: &str) -> Option<(&str, &str)> {
973 let rest = line.trim_start().strip_prefix("[^")?;
974 let (label, text) = rest.split_once("]:")?;
975 is_note_label(label).then_some((label, text))
976}
977
978fn is_note_label(label: &str) -> bool {
979 !label.is_empty()
980 && label.chars().count() <= 40
981 && !label.contains(|c: char| c.is_whitespace() || matches!(c, '[' | ']' | '^'))
982}
983
984fn is_indented(line: &str) -> bool {
985 line.starts_with('\t') || line.starts_with(" ")
986}
987
988fn ends_note(line: &str) -> bool {
990 starts_block(line) || line.starts_with('>') || note_definition(line).is_some()
991}
992
993fn unnumbered(text: &str) -> (&str, bool) {
996 let t = text.trim_end();
997 for mark in ["{-}", "{.unnumbered}"] {
998 if let Some(rest) = t.strip_suffix(mark) {
999 return (rest.trim_end(), true);
1000 }
1001 }
1002 (text, false)
1003}
1004
1005fn sub_heading(line: &str) -> Option<(u8, &str)> {
1010 let hashes = line.bytes().take_while(|&b| b == b'#').count();
1011 if !(2..=6).contains(&hashes) {
1012 return None;
1013 }
1014 let rest = &line[hashes..];
1015 if !rest.starts_with([' ', '\t']) {
1016 return None;
1017 }
1018 let mut text = rest.trim();
1019 let unclosed = text.trim_end_matches('#');
1020 if unclosed.is_empty() || unclosed.ends_with([' ', '\t']) {
1021 text = unclosed.trim_end();
1022 }
1023 if text.is_empty() {
1024 return None;
1025 }
1026 Some((hashes as u8 - 1, text))
1027}
1028
1029fn block_quote(lines: &[&str], start: usize, notes: &mut Notes) -> (Option<Block>, usize) {
1035 let mut paras: Vec<Vec<&str>> = vec![Vec::new()];
1036 let mut i = start;
1037 while let Some(raw) = lines.get(i) {
1038 let line = raw.trim();
1039 let in_para = paras.last().is_some_and(|p| !p.is_empty());
1040 let text = if line.starts_with('>') {
1041 unquote(line)
1042 } else if !line.is_empty() && in_para && !starts_block(line) {
1043 line
1044 } else {
1045 break;
1046 };
1047 if text.is_empty() {
1048 if in_para {
1049 paras.push(Vec::new());
1050 }
1051 } else if let Some(p) = paras.last_mut() {
1052 p.push(text);
1053 }
1054 i += 1;
1055 }
1056 let blocks: Vec<Block> = paras
1057 .iter()
1058 .filter(|p| !p.is_empty())
1059 .map(|p| Block::paragraph(parse_inlines(&join_lines(p), notes)))
1060 .collect();
1061 ((!blocks.is_empty()).then(|| Block::block_quote(blocks)), i)
1062}
1063
1064fn unquote(line: &str) -> &str {
1066 let mut s = line;
1067 while let Some(rest) = s.strip_prefix('>') {
1068 s = rest.trim_start();
1069 }
1070 s
1071}
1072
1073fn starts_block(line: &str) -> bool {
1075 line.starts_with("# ")
1076 || sub_heading(line).is_some()
1077 || heading::is_scene_break(line)
1078 || fence(line).is_some()
1079 || is_page_break(line)
1080 || image_line(line).is_some()
1081}
1082
1083struct ItemLine<'s> {
1085 indent: usize,
1087 number: Option<u32>,
1089 text: &'s str,
1090}
1091
1092fn item_line(raw: &str) -> Option<ItemLine<'_>> {
1095 let line = raw.trim();
1096 if heading::is_scene_break(line) {
1097 return None;
1098 }
1099 let (number, rest) = match line.strip_prefix(['-', '*', '+']) {
1100 Some(rest) => (None, rest),
1101 None => {
1102 let digits = line.bytes().take_while(u8::is_ascii_digit).count();
1103 if !(1..=3).contains(&digits) {
1104 return None;
1105 }
1106 let rest = line[digits..].strip_prefix(['.', ')'])?;
1107 (line[..digits].parse().ok(), rest)
1108 }
1109 };
1110 if !rest.starts_with([' ', '\t']) || rest.trim().is_empty() {
1111 return None;
1112 }
1113 Some(ItemLine {
1114 indent: indent_width(raw),
1115 number,
1116 text: rest.trim(),
1117 })
1118}
1119
1120fn indent_width(raw: &str) -> usize {
1121 raw.chars()
1122 .take_while(|c| c.is_whitespace())
1123 .map(|c| if c == '\t' { 4 } else { 1 })
1124 .sum()
1125}
1126
1127struct RawItem<'s> {
1129 line: usize,
1132 indent: usize,
1133 number: Option<u32>,
1134 paras: Vec<Vec<&'s str>>,
1135}
1136
1137enum ListRun<'s> {
1139 Lists(Vec<Block>),
1140 Numbered {
1143 start: u32,
1144 paras: Vec<(usize, Vec<&'s str>)>,
1145 },
1146}
1147
1148fn list<'s>(lines: &[&'s str], start: usize, notes: &mut Notes) -> Option<(ListRun<'s>, usize)> {
1161 item_line(lines[start])?;
1162 let mut items: Vec<RawItem> = Vec::new();
1163 let mut i = start;
1164 while let Some(&raw) = lines.get(i) {
1165 let line = raw.trim();
1166 if let Some(item) = item_line(raw) {
1167 items.push(RawItem {
1168 line: i,
1169 indent: item.indent,
1170 number: item.number,
1171 paras: vec![vec![item.text]],
1172 });
1173 } else if line.is_empty() {
1174 let next = lines[i..].iter().find(|l| !l.trim().is_empty());
1175 match next {
1176 Some(n) if item_line(n).is_some() => {}
1177 Some(n) if indent_width(n) >= 2 && !ends_item(n.trim()) => {
1178 if let Some(item) = items.last_mut()
1179 && item.paras.last().is_some_and(|p| !p.is_empty())
1180 {
1181 item.paras.push(Vec::new());
1182 }
1183 }
1184 _ => break,
1185 }
1186 } else if ends_item(line) {
1187 break;
1188 } else if let Some(p) = items.last_mut().and_then(|it| it.paras.last_mut()) {
1189 p.push(line);
1190 }
1191 i += 1;
1192 }
1193 let first = items.first()?;
1194 if let Some(n) = first.number {
1195 let second = items[1..].iter().find(|it| it.indent <= first.indent);
1196 match second.and_then(|it| it.number) {
1197 Some(_) if n == 1 => {}
1198 Some(m) if m == n + 1 => {
1199 let mut expected = n;
1202 let end = items.iter().position(|it| {
1203 if it.indent > first.indent {
1204 return false;
1205 }
1206 let counts = it.number == Some(expected);
1207 expected += 1;
1208 !counts
1209 });
1210 let (run, next) = match end {
1211 Some(k) => (&items[..k], items[k].line),
1212 None => (&items[..], i),
1213 };
1214 let paras = numbered_paras(lines, run);
1215 return Some((ListRun::Numbered { start: n, paras }, next));
1216 }
1217 _ => return None,
1218 }
1219 }
1220 Some((ListRun::Lists(build_lists(&items, notes)), i))
1221}
1222
1223fn numbered_paras<'s>(lines: &[&'s str], items: &[RawItem<'s>]) -> Vec<(usize, Vec<&'s str>)> {
1226 let mut paras = Vec::new();
1227 for item in items {
1228 for (n, p) in item.paras.iter().filter(|p| !p.is_empty()).enumerate() {
1229 let mut p = p.clone();
1230 if n == 0
1231 && let Some(first) = p.first_mut()
1232 {
1233 *first = lines[item.line].trim();
1234 }
1235 paras.push((item.line, p));
1236 }
1237 }
1238 paras
1239}
1240
1241fn ends_item(line: &str) -> bool {
1243 starts_block(line) || line.starts_with('>')
1244}
1245
1246fn build_lists(items: &[RawItem], notes: &mut Notes) -> Vec<Block> {
1250 let mut lists = Vec::new();
1251 let mut i = 0;
1252 while let Some(first) = items.get(i) {
1253 let ordered = first.number.is_some();
1254 let depth = first.indent;
1255 let mut list_items = Vec::new();
1256 while let Some(item) = items.get(i).filter(|it| it.number.is_some() == ordered) {
1257 let mut blocks: Vec<Block> = item
1258 .paras
1259 .iter()
1260 .filter(|p| !p.is_empty())
1261 .map(|p| Block::paragraph(parse_inlines(&join_lines(p), notes)))
1262 .collect();
1263 let end = items[i + 1..]
1264 .iter()
1265 .position(|it| it.indent <= depth)
1266 .map_or(items.len(), |n| i + 1 + n);
1267 blocks.extend(build_lists(&items[i + 1..end], notes));
1268 list_items.push(blocks);
1269 i = end;
1270 }
1271 lists.push(Block::list(ordered, list_items));
1272 }
1273 lists
1274}
1275
1276const DEDICATION_PARAS: usize = 4;
1280const DEDICATION_CHARS: usize = 300;
1281
1282type TableRow = (usize, Vec<String>);
1284
1285struct PipeTable {
1288 aligns: Vec<ColumnAlign>,
1289 rows: Vec<TableRow>,
1290}
1291
1292#[derive(Clone, Copy)]
1294struct Caption<'a> {
1295 text: &'a str,
1296 above: bool,
1298 line: usize,
1300}
1301
1302fn caption_text(line: &str) -> Option<&str> {
1304 let rest = line.trim().strip_prefix("Table:")?;
1305 if !rest.starts_with([' ', '\t']) {
1306 return None;
1307 }
1308 Some(rest.trim()).filter(|text| !text.is_empty())
1309}
1310
1311fn caption_above<'a>(lines: &[&'a str], i: usize) -> Option<(Caption<'a>, PipeTable, usize)> {
1316 let text = caption_text(lines[i])?;
1317 let at = match lines.get(i + 1) {
1318 Some(next) if next.trim().is_empty() => i + 2,
1319 _ => i + 1,
1320 };
1321 let (pipe_table, next) = table(lines, at)?;
1322 Some((
1323 Caption {
1324 text,
1325 above: true,
1326 line: i,
1327 },
1328 pipe_table,
1329 next,
1330 ))
1331}
1332
1333fn caption_below<'a>(lines: &[&'a str], at: usize) -> Option<(Caption<'a>, usize)> {
1339 let at = match lines.get(at) {
1340 Some(line) if line.trim().is_empty() => at + 1,
1341 _ => at,
1342 };
1343 let text = caption_text(lines.get(at)?)?;
1344 match lines.get(at + 1) {
1345 None => {}
1346 Some(next) if next.trim().is_empty() && table(lines, at + 2).is_none() => {}
1347 Some(next) if starts_block(next) || next.starts_with('>') => {}
1348 Some(_) => return None,
1349 }
1350 Some((
1351 Caption {
1352 text,
1353 above: false,
1354 line: at,
1355 },
1356 at + 1,
1357 ))
1358}
1359
1360fn delimiter_align(cell: &str) -> Option<ColumnAlign> {
1363 let left = cell.starts_with(':');
1364 let right = cell.ends_with(':');
1365 let dashes = cell.strip_prefix(':').unwrap_or(cell);
1366 let dashes = dashes.strip_suffix(':').unwrap_or(dashes);
1367 if dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') {
1368 return None;
1369 }
1370 Some(match (left, right) {
1371 (true, true) => ColumnAlign::Center,
1372 (false, true) => ColumnAlign::Right,
1373 _ => ColumnAlign::Left,
1374 })
1375}
1376
1377fn table(lines: &[&str], start: usize) -> Option<(PipeTable, usize)> {
1383 let header = lines.get(start)?.trim();
1384 if !is_table_row(header) {
1385 return None;
1386 }
1387 let delimiter = lines.get(start + 1)?.trim();
1388 let cells = table_cells(header);
1389 let aligns: Vec<ColumnAlign> = table_cells(delimiter)
1390 .iter()
1391 .map(|c| delimiter_align(c))
1392 .collect::<Option<_>>()?;
1393 if !is_table_row(delimiter) || aligns.is_empty() || aligns.len() != cells.len() {
1394 return None;
1395 }
1396 let mut rows = vec![(start, cells)];
1397 let mut i = start + 2;
1398 while let Some(line) = lines.get(i).map(|l| l.trim()) {
1399 if !is_table_row(line) || starts_block(line) || line.starts_with('>') {
1400 break;
1401 }
1402 rows.push((i, table_cells(line)));
1403 i += 1;
1404 }
1405 Some((PipeTable { aligns, rows }, i))
1406}
1407
1408fn is_table_row(line: &str) -> bool {
1410 line.char_indices()
1411 .any(|(i, c)| c == '|' && !line[..i].ends_with('\\'))
1412}
1413
1414fn table_cells(line: &str) -> Vec<String> {
1417 let mut cells = vec![String::new()];
1418 let mut chars = line.chars().peekable();
1419 while let Some(c) = chars.next() {
1420 let escaped = c == '\\' && chars.peek() == Some(&'|');
1421 if c == '|' {
1422 cells.push(String::new());
1423 } else if let Some(cell) = cells.last_mut() {
1424 cell.push(if escaped {
1425 chars.next().unwrap_or(c)
1426 } else {
1427 c
1428 });
1429 }
1430 }
1431 if line.starts_with('|') {
1432 cells.remove(0);
1433 }
1434 if line.ends_with('|') && !line.ends_with("\\|") {
1435 cells.pop();
1436 }
1437 cells.iter().map(|c| c.trim().to_string()).collect()
1438}
1439
1440struct ImageLine<'s> {
1442 alt: &'s str,
1443 path: &'s str,
1444 caption: Option<&'s str>,
1445 attrs: &'s str,
1447}
1448
1449fn image_line(line: &str) -> Option<ImageLine<'_>> {
1454 let rest = line.strip_prefix("?;
1456 let (rest, attrs) = match rest.rfind("){") {
1457 Some(end) if rest.ends_with('}') => (&rest[..=end], &rest[end + 2..rest.len() - 1]),
1458 _ => (rest, ""),
1459 };
1460 let inner = rest.strip_suffix(')')?;
1461 let (path, caption) = match inner.trim_end().strip_suffix('"') {
1462 Some(body) => match body.split_once('"') {
1463 Some((p, c)) if p.ends_with([' ', '\t']) => (p, Some(c)),
1464 _ => (inner, None),
1465 },
1466 None => (inner, None),
1467 };
1468 let path = path.trim();
1469 let path = path
1470 .strip_prefix('<')
1471 .and_then(|p| p.strip_suffix('>'))
1472 .unwrap_or(path)
1473 .trim();
1474 (!path.is_empty()).then_some(ImageLine {
1475 alt,
1476 path,
1477 caption,
1478 attrs,
1479 })
1480}
1481
1482fn image_layout(attrs: &str) -> (Option<u8>, ImagePlacement, ImageSide) {
1490 let (mut width, mut side) = (None, ImageSide::Outside);
1491 let (mut beside, mut page, mut bleed) = (false, false, false);
1492 for attr in attrs.split_whitespace() {
1493 match attr {
1494 ".beside" => beside = true,
1495 ".page" => page = true,
1496 ".bleed" => bleed = true,
1497 ".outside" => side = ImageSide::Outside,
1498 ".inside" => side = ImageSide::Inside,
1499 ".left" => side = ImageSide::Left,
1500 ".right" => side = ImageSide::Right,
1501 _ => {
1502 if let Some(v) = attr.strip_prefix("width=") {
1503 let v = v.trim_matches(['"', '\'']);
1504 let v = v.strip_suffix('%').unwrap_or(v);
1505 if let Ok(pct) = v.parse::<f64>()
1506 && pct.is_finite()
1507 {
1508 let rounded = (pct / 5.0).round() * 5.0;
1509 width = Some(rounded.clamp(10.0, 100.0) as u8);
1510 }
1511 }
1512 }
1513 }
1514 }
1515 let placement = if bleed {
1516 ImagePlacement::Bleed
1517 } else if page {
1518 ImagePlacement::Page
1519 } else if beside {
1520 ImagePlacement::Beside
1521 } else {
1522 ImagePlacement::Inline
1523 };
1524 if placement == ImagePlacement::Bleed {
1525 width = None;
1526 }
1527 if placement != ImagePlacement::Beside {
1528 side = ImageSide::Outside;
1529 }
1530 (width, placement, side)
1531}
1532
1533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1535enum Fence {
1536 Verse,
1537 Epigraph,
1538}
1539
1540fn fence(line: &str) -> Option<Fence> {
1543 let colons = line.bytes().take_while(|&b| b == b':').count();
1544 if colons < 3 {
1545 return None;
1546 }
1547 let name = line[colons..].trim_end_matches(':').trim();
1548 if name.eq_ignore_ascii_case("verse") {
1549 Some(Fence::Verse)
1550 } else if name.eq_ignore_ascii_case("epigraph") {
1551 Some(Fence::Epigraph)
1552 } else {
1553 None
1554 }
1555}
1556
1557fn fence_body<'s>(lines: &[&'s str], start: usize) -> (Vec<&'s str>, usize, bool) {
1563 let mut inner = Vec::new();
1564 let mut i = start + 1;
1565 while let Some(raw) = lines.get(i) {
1566 let line = raw.trim();
1567 if is_fence_end(line) {
1568 return (inner, i + 1, true);
1569 }
1570 if line.starts_with("# ") || sub_heading(line).is_some() {
1571 break;
1572 }
1573 inner.push(*raw);
1574 i += 1;
1575 }
1576 (inner, i, false)
1577}
1578
1579fn leading_columns(line: &str) -> usize {
1582 line.chars()
1583 .take_while(|c| c.is_whitespace())
1584 .map(|c| if c == '\t' { 4 } else { 1 })
1585 .sum()
1586}
1587
1588const SPACES_PER_LEVEL: usize = 4;
1593
1594fn verse_levels(lines: &[(usize, &str)]) -> Vec<u8> {
1599 let least = lines
1600 .iter()
1601 .filter(|(_, text)| !text.is_empty())
1602 .map(|&(cols, _)| cols)
1603 .min()
1604 .unwrap_or(0);
1605 lines
1606 .iter()
1607 .map(|&(cols, text)| {
1608 if text.is_empty() {
1609 return 0;
1610 }
1611 let level = (cols - least) / SPACES_PER_LEVEL;
1612 u8::try_from(level).map_or(MAX_VERSE_INDENT, |l| l.min(MAX_VERSE_INDENT))
1613 })
1614 .collect()
1615}
1616
1617fn is_page_break(line: &str) -> bool {
1620 matches!(line, "\\newpage" | "\\pagebreak")
1621}
1622
1623fn is_fence_end(line: &str) -> bool {
1625 line.len() >= 3 && line.bytes().all(|b| b == b':')
1626}
1627
1628fn hard_break(line: &str) -> (&str, bool) {
1631 match line.strip_suffix('\\') {
1632 Some(text) => (text.trim_end(), true),
1633 None => (line, false),
1634 }
1635}
1636
1637fn join_lines(lines: &[&str]) -> String {
1641 let mut out = String::new();
1642 let mut sep = "";
1643 for line in lines {
1644 let (text, brk) = hard_break(line);
1645 out.push_str(sep);
1646 out.push_str(text);
1647 sep = if brk { "\n" } else { " " };
1648 }
1649 out
1650}
1651
1652fn parse_inlines(s: &str, notes: &mut Notes) -> Vec<Inline> {
1657 let chars: Vec<char> = s.chars().collect();
1658 let mut out = Vec::new();
1659 let mut buf = String::new();
1660 let mut i = 0;
1661 while i < chars.len() {
1662 let c = chars[i];
1663 if c == '\n' {
1664 flush_text(&mut buf, &mut out);
1665 out.push(Inline::LineBreak);
1666 i += 1;
1667 continue;
1668 }
1669 if c == '['
1670 && chars.get(i + 1) == Some(&'^')
1671 && let Some(len) = chars[i + 2..].iter().position(|&c| c == ']')
1672 {
1673 let label: String = chars[i + 2..i + 2 + len].iter().collect();
1674 if is_note_label(&label)
1675 && let Some(blocks) = notes.resolve(&label)
1676 {
1677 let kept = buf.trim_end().len();
1680 buf.truncate(kept);
1681 flush_text(&mut buf, &mut out);
1682 out.push(Inline::Footnote { blocks });
1683 i += len + 3;
1684 continue;
1685 }
1686 }
1687 if opens_bracket(&chars, i)
1690 && let Some(close) = closing_bracket(&chars, i)
1691 && chars.get(close + 1) == Some(&'{')
1692 && let Some(len) = chars[close + 2..].iter().position(|&c| c == '}')
1693 {
1694 let attrs: String = chars[close + 2..close + 2 + len].iter().collect();
1695 let inner: String = chars[i + 1..close].iter().collect();
1696 let content = parse_inlines(&inner, notes);
1697 let small_caps = attrs
1698 .split_whitespace()
1699 .any(|a| a.eq_ignore_ascii_case(".smallcaps"));
1700 if small_caps && !content.is_empty() {
1701 flush_text(&mut buf, &mut out);
1702 out.push(Inline::SmallCaps { content });
1703 } else {
1704 for inline in content {
1705 match inline {
1706 Inline::Text { text } => buf.push_str(&text),
1707 other => {
1708 flush_text(&mut buf, &mut out);
1709 out.push(other);
1710 }
1711 }
1712 }
1713 }
1714 i = close + len + 3;
1715 continue;
1716 }
1717 if opens_bracket(&chars, i)
1719 && let Some(close) = closing_bracket(&chars, i)
1720 && chars.get(close + 1) == Some(&'(')
1721 && let Some(end) = closing_bracket(&chars, close + 1)
1722 && let Some(url) = link_url(&chars[close + 2..end].iter().collect::<String>())
1723 {
1724 let inner: String = chars[i + 1..close].iter().collect();
1725 let mut content = parse_inlines(&inner, notes);
1726 if content.is_empty() {
1727 content.push(Inline::text(url.clone()));
1728 }
1729 flush_text(&mut buf, &mut out);
1730 out.push(Inline::Link { url, content });
1731 i = end + 1;
1732 continue;
1733 }
1734 if c == '<'
1736 && let Some(len) = chars[i + 1..].iter().position(|&c| c == '>')
1737 {
1738 let url: String = chars[i + 1..i + 1 + len].iter().collect();
1739 if is_autolink(&url) {
1740 let text = url.strip_prefix("mailto:").unwrap_or(&url).to_string();
1741 flush_text(&mut buf, &mut out);
1742 out.push(Inline::Link {
1743 url,
1744 content: vec![Inline::text(text)],
1745 });
1746 i += len + 2;
1747 continue;
1748 }
1749 }
1750 if c == '^' || c == '~' {
1752 if chars.get(i + 1) == Some(&c) {
1753 buf.extend([c, c]);
1754 i += 2;
1755 continue;
1756 }
1757 if let Some(end) = script_end(&chars, i) {
1758 flush_text(&mut buf, &mut out);
1759 let inner: String = chars[i + 1..end].iter().collect();
1760 let content = parse_inlines(&inner, notes);
1761 out.push(if c == '^' {
1762 Inline::Superscript { content }
1763 } else {
1764 Inline::Subscript { content }
1765 });
1766 i = end + 1;
1767 continue;
1768 }
1769 }
1770 let strong = c == '*' && chars.get(i + 1) == Some(&'*');
1771 if strong {
1772 if let Some(end) = find_closing(&chars, i + 2, &['*', '*']) {
1773 flush_text(&mut buf, &mut out);
1774 let inner: String = chars[i + 2..end].iter().collect();
1775 out.push(Inline::Strong {
1776 content: parse_inlines(&inner, notes),
1777 });
1778 i = end + 2;
1779 continue;
1780 }
1781 } else if (c == '*' || c == '_')
1782 && opens_emphasis(&chars, i)
1783 && let Some(end) = find_closing(&chars, i + 1, &[c])
1784 {
1785 flush_text(&mut buf, &mut out);
1786 let inner: String = chars[i + 1..end].iter().collect();
1787 out.push(Inline::Emphasis {
1788 content: parse_inlines(&inner, notes),
1789 });
1790 i = end + 1;
1791 continue;
1792 }
1793 buf.push(c);
1794 i += 1;
1795 }
1796 flush_text(&mut buf, &mut out);
1797 out
1798}
1799
1800fn script_end(chars: &[char], open: usize) -> Option<usize> {
1804 let marker = chars[open];
1805 let len = chars[open + 1..]
1806 .iter()
1807 .position(|&c| c == marker || c.is_whitespace())?;
1808 (len > 0 && chars[open + 1 + len] == marker).then_some(open + 1 + len)
1809}
1810
1811fn opens_bracket(chars: &[char], i: usize) -> bool {
1814 chars[i] == '[' && chars.get(i + 1) != Some(&'^') && (i == 0 || chars[i - 1] != '!')
1815}
1816
1817fn closing_bracket(chars: &[char], open: usize) -> Option<usize> {
1820 let (opening, closing) = if chars[open] == '(' {
1821 ('(', ')')
1822 } else {
1823 ('[', ']')
1824 };
1825 let mut depth = 0usize;
1826 for (j, &c) in chars.iter().enumerate().skip(open) {
1827 if c == opening {
1828 depth += 1;
1829 } else if c == closing {
1830 depth -= 1;
1831 if depth == 0 {
1832 return Some(j);
1833 }
1834 }
1835 }
1836 None
1837}
1838
1839fn link_url(target: &str) -> Option<String> {
1842 let target = target.trim();
1843 let url = match target.strip_prefix('<') {
1844 Some(rest) => rest.split_once('>')?.0.trim(),
1845 None => target.split_whitespace().next()?,
1846 };
1847 (!url.is_empty()).then(|| url.to_string())
1848}
1849
1850fn is_autolink(url: &str) -> bool {
1852 ["https://", "http://", "mailto:"]
1853 .iter()
1854 .any(|p| url.len() > p.len() && url.starts_with(p))
1855 && !url.contains(char::is_whitespace)
1856}
1857
1858fn opens_emphasis(chars: &[char], i: usize) -> bool {
1859 let next_ok = chars.get(i + 1).is_some_and(|n| !n.is_whitespace());
1861 let prev_ok = i == 0 || !chars[i - 1].is_alphanumeric() || chars[i] == '*';
1862 next_ok && prev_ok
1863}
1864
1865fn find_closing(chars: &[char], from: usize, marker: &[char]) -> Option<usize> {
1866 let n = marker.len();
1867 let mut j = from;
1868 while j + n <= chars.len() {
1869 if n == 1 && marker[0] == '*' && chars[j] == '*' && chars.get(j + 1) == Some(&'*') {
1872 j += 2;
1873 continue;
1874 }
1875 if chars[j..j + n] == *marker && j > from && !chars[j - 1].is_whitespace() {
1876 return Some(j);
1877 }
1878 j += 1;
1879 }
1880 None
1881}
1882
1883fn flush_text(buf: &mut String, out: &mut Vec<Inline>) {
1884 if !buf.is_empty() {
1885 out.push(Inline::Text {
1886 text: std::mem::take(buf),
1887 });
1888 }
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894
1895 fn import(src: &str) -> Result<Imported, ImportError> {
1897 super::import(src, |_| None)
1898 }
1899
1900 const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
1901
1902 const SAMPLE: &str = "Title: Ein Landarzt\nAuthor: Franz Kafka\nLang: de\nDedication: Für M.\n\n# Prolog\n\nIch war in *großer* Verlegenheit:\neine dringende Reise stand mir bevor.\n\n***\n\nDanach.\n\n# Auf der Galerie\n\nErster **Absatz**.\n\n* * *\n";
1903
1904 #[test]
1905 fn imports_structure() {
1906 let Imported { book, warnings, .. } = import(SAMPLE).unwrap();
1907 assert!(warnings.is_empty(), "{warnings:?}");
1908 assert_eq!(book.metadata.title, "Ein Landarzt");
1909 assert_eq!(book.metadata.lang.primary(), "de");
1910 let kinds: Vec<_> = book.sections.iter().map(|s| s.kind).collect();
1911 assert_eq!(
1912 kinds,
1913 vec![
1914 SectionKind::Dedication,
1915 SectionKind::Prologue,
1916 SectionKind::Chapter
1917 ]
1918 );
1919 let prolog = &book.sections[1];
1920 assert_eq!(prolog.blocks.len(), 3);
1921 assert_eq!(prolog.blocks[1], Block::scene_break());
1922 let Block::Paragraph { content, .. } = &prolog.blocks[0] else {
1924 panic!()
1925 };
1926 assert_eq!(
1927 Inline::plain_text(content),
1928 "Ich war in großer Verlegenheit: eine dringende Reise stand mir bevor."
1929 );
1930 assert_eq!(book.sections[2].blocks.len(), 1);
1932 }
1933
1934 #[test]
1935 fn text_before_first_chapter_stays_out_of_the_dedication() {
1936 let src = "Title: T\nDedication: Für A.\nGenre: Roman\n\n***\n\n# Eins\n\nB.\n";
1937 let Imported { book, warnings, .. } = import(src).unwrap();
1938 let kinds: Vec<_> = book.sections.iter().map(|s| s.kind).collect();
1939 assert_eq!(
1940 kinds,
1941 [
1942 SectionKind::Dedication,
1943 SectionKind::Other,
1944 SectionKind::Chapter
1945 ]
1946 );
1947 assert_eq!(book.sections[0].blocks.len(), 1);
1948 assert_eq!(
1949 book.sections[1].blocks,
1950 vec![Block::paragraph(vec![Inline::text("Genre: Roman")])]
1951 );
1952 let got: Vec<_> = warnings
1953 .iter()
1954 .map(|w| (w.kind, w.location, w.section))
1955 .collect();
1956 assert_eq!(got, [(WarningKind::TextBeforeFirstChapter, 3, Some(1))]);
1957 }
1958
1959 #[test]
1960 fn dedication_before_first_heading() {
1961 let src = "Title: T\n\nIn memory of *A.*\n\nund B.\n\nC.\n\nD.\n\nE.\n\n# Eins\n\nX.\n";
1962 let Imported { book, warnings, .. } = import(src).unwrap();
1963 let shape: Vec<_> = book
1964 .sections
1965 .iter()
1966 .map(|s| (s.kind, s.blocks.len()))
1967 .collect();
1968 assert_eq!(
1970 shape,
1971 [
1972 (SectionKind::Dedication, 4),
1973 (SectionKind::Other, 1),
1974 (SectionKind::Chapter, 1)
1975 ]
1976 );
1977 assert_eq!(
1978 book.sections[0].blocks[0],
1979 Block::paragraph(vec![
1980 Inline::text("In memory of "),
1981 Inline::Emphasis {
1982 content: vec![Inline::text("A.")]
1983 },
1984 ])
1985 );
1986 let got: Vec<_> = warnings.iter().map(|w| (w.kind, w.location)).collect();
1987 assert_eq!(got, [(WarningKind::TextBeforeFirstChapter, 11)]);
1988
1989 let long = format!("For {}.", "very ".repeat(60));
1991 for src in [
1992 format!("Title: T\n\n{long}\n\n# Eins\n"),
1993 "Title: T\n\nVorab.\n\nFür A.\n\n# Eins\n".to_string(),
1994 "Title: T\n\n# Eins\n\nFür A.\n".to_string(),
1995 ] {
1996 let book = import(&src).unwrap().book;
1997 assert!(
1998 book.sections
1999 .iter()
2000 .all(|s| s.kind != SectionKind::Dedication),
2001 "{src}"
2002 );
2003 }
2004 }
2005
2006 #[test]
2007 fn explicit_chapter_numbers_are_stripped() {
2008 let src = "Title: T\n\n# Kapitel 3\n\nA.\n\n# Chapter 4: The Storm\n\nB.\n\n# Zweites Kapitel\n\nC.\n\n# Arrival\n\nD.\n";
2009 let book = import(src).unwrap().book;
2010 let got: Vec<_> = book
2011 .sections
2012 .iter()
2013 .map(|s| (s.title.as_deref(), s.numbering.clone()))
2014 .collect();
2015 assert_eq!(
2016 got,
2017 vec![
2018 (None, Numbering::Explicit(3)),
2019 (Some("The Storm"), Numbering::Explicit(4)),
2020 (None, Numbering::Explicit(2)),
2021 (Some("Arrival"), Numbering::Auto),
2022 ]
2023 );
2024 }
2025
2026 #[test]
2027 fn unnumbered_chapters() {
2028 let src = "Title: T\n\n# Arrival\n\nA.\n\n# Interlude {-}\n\nB.\n\n# Kapitel 3 {.unnumbered}\n\nC.\n\n# Departure\n\nD.\n";
2029 let book = import(src).unwrap().book;
2030 let got: Vec<_> = book
2031 .sections
2032 .iter()
2033 .map(|s| (s.title.as_deref(), s.numbering.clone()))
2034 .collect();
2035 assert_eq!(
2036 got,
2037 vec![
2038 (Some("Arrival"), Numbering::Auto),
2039 (Some("Interlude"), Numbering::None),
2040 (None, Numbering::None),
2041 (Some("Departure"), Numbering::Auto),
2042 ]
2043 );
2044 }
2045
2046 #[test]
2047 fn inline_emphasis() {
2048 let v = parse_inlines(
2049 "a *b* and _c_ and **d** but snake_case_word and 2 * 3",
2050 &mut Notes::default(),
2051 );
2052 assert_eq!(
2053 v,
2054 vec![
2055 Inline::text("a "),
2056 Inline::Emphasis {
2057 content: vec![Inline::text("b")]
2058 },
2059 Inline::text(" and "),
2060 Inline::Emphasis {
2061 content: vec![Inline::text("c")]
2062 },
2063 Inline::text(" and "),
2064 Inline::Strong {
2065 content: vec![Inline::text("d")]
2066 },
2067 Inline::text(" but snake_case_word and 2 * 3"),
2068 ]
2069 );
2070 let strong = |t: &str| Inline::Strong {
2072 content: vec![Inline::text(t)],
2073 };
2074 assert_eq!(
2075 parse_inlines("*a **b** c* *d **e***", &mut Notes::default()),
2076 vec![
2077 Inline::Emphasis {
2078 content: vec![Inline::text("a "), strong("b"), Inline::text(" c")]
2079 },
2080 Inline::text(" "),
2081 Inline::Emphasis {
2082 content: vec![Inline::text("d "), strong("e")]
2083 },
2084 ]
2085 );
2086 }
2087
2088 #[test]
2089 fn small_caps_spans() {
2090 let v = parse_inlines(
2091 "Ein [Schild]{.smallcaps} und [*Mehr* [x]]{ #id .SmallCaps }, [unterstrichen]{.underline} [leer]{}[]{.smallcaps} [sic] {.c} [a]{.smallcaps",
2092 &mut Notes::default(),
2093 );
2094 assert_eq!(
2095 v,
2096 vec![
2097 Inline::text("Ein "),
2098 Inline::SmallCaps {
2099 content: vec![Inline::text("Schild")]
2100 },
2101 Inline::text(" und "),
2102 Inline::SmallCaps {
2103 content: vec![
2104 Inline::Emphasis {
2105 content: vec![Inline::text("Mehr")]
2106 },
2107 Inline::text(" [x]"),
2108 ]
2109 },
2110 Inline::text(", unterstrichen leer [sic] {.c} [a]{.smallcaps"),
2113 ]
2114 );
2115 }
2116
2117 #[test]
2118 fn superscript_and_subscript() {
2119 let v = parse_inlines(
2120 "H~2~O, 1^st^ und 2^*nd*^; ~50 km, a ~ b, ~~weg~~, x^^, [^n] ^ende",
2121 &mut Notes::default(),
2122 );
2123 let t = Inline::text;
2124 assert_eq!(
2125 v,
2126 vec![
2127 t("H"),
2128 Inline::Subscript {
2129 content: vec![t("2")]
2130 },
2131 t("O, 1"),
2132 Inline::Superscript {
2133 content: vec![t("st")]
2134 },
2135 t(" und 2"),
2136 Inline::Superscript {
2137 content: vec![Inline::Emphasis {
2138 content: vec![t("nd")]
2139 }]
2140 },
2141 t("; ~50 km, a ~ b, ~~weg~~, x^^, [^n] ^ende"),
2142 ]
2143 );
2144 }
2145
2146 #[test]
2147 fn links() {
2148 let v = parse_inlines(
2149 "Siehe [die *Seite*](https://example.com/Tau_(Knoten) \"Titel\"), <https://example.com>, <mailto:a@example.com>, [](<https://x.example/a b>), [kein](), [Text] (nicht), <kein link>, ",
2150 &mut Notes::default(),
2151 );
2152 let t = Inline::text;
2153 let link = |url: &str, content| Inline::Link {
2154 url: url.into(),
2155 content,
2156 };
2157 assert_eq!(
2158 v,
2159 vec![
2160 t("Siehe "),
2161 link(
2162 "https://example.com/Tau_(Knoten)",
2163 vec![
2164 t("die "),
2165 Inline::Emphasis {
2166 content: vec![t("Seite")]
2167 }
2168 ]
2169 ),
2170 t(", "),
2171 link("https://example.com", vec![t("https://example.com")]),
2172 t(", "),
2173 link("mailto:a@example.com", vec![t("a@example.com")]),
2174 t(", "),
2175 link("https://x.example/a b", vec![t("https://x.example/a b")]),
2177 t(", [kein](), [Text] (nicht), <kein link>, "),
2178 ]
2179 );
2180 }
2181
2182 #[test]
2183 fn sub_headings() {
2184 let src = "Title: T\n\n# Kapitel 1\n\nA.\n\n## Am *Hafen*\n\nB.\n\n### Tiefer ###\n\n## C#\n\n##\n\nC.\n\n####### sieben\n\n##kein\n";
2185 let Imported { book, warnings, .. } = import(src).unwrap();
2186 assert!(warnings.is_empty(), "{warnings:?}");
2187 assert_eq!(book.sections.len(), 1);
2188 assert_eq!(
2189 book.sections[0].blocks,
2190 vec![
2191 Block::paragraph(vec![Inline::text("A.")]),
2192 Block::heading(
2193 1,
2194 vec![
2195 Inline::text("Am "),
2196 Inline::Emphasis {
2197 content: vec![Inline::text("Hafen")]
2198 }
2199 ]
2200 ),
2201 Block::paragraph(vec![Inline::text("B.")]),
2202 Block::heading(2, vec![Inline::text("Tiefer")]),
2203 Block::heading(1, vec![Inline::text("C#")]),
2204 Block::scene_break(),
2207 Block::paragraph(vec![Inline::text("C.")]),
2208 Block::paragraph(vec![Inline::text("####### sieben")]),
2209 Block::paragraph(vec![Inline::text("##kein")]),
2210 ]
2211 );
2212 }
2213
2214 #[test]
2215 fn sub_heading_before_first_chapter() {
2216 let Imported { book, warnings, .. } = import("## Vorab\n\n# Eins\n\nA.\n").unwrap();
2217 assert_eq!(book.sections.len(), 2);
2218 assert_eq!(book.sections[0].kind, SectionKind::Other);
2219 assert_eq!(
2220 book.sections[0].blocks,
2221 vec![Block::heading(1, vec![Inline::text("Vorab")])]
2222 );
2223 let kinds: Vec<_> = warnings.iter().map(|w| w.kind).collect();
2224 assert_eq!(
2225 kinds,
2226 vec![WarningKind::TextBeforeFirstChapter, WarningKind::NoTitle]
2227 );
2228 assert_eq!(warnings[0].location, 1);
2229 }
2230
2231 #[test]
2232 fn block_quotes() {
2233 let src = "Title: T\n\n# Eins\n\nVorher.\n> Ich war in großer\n>Verlegenheit:\neine dringende Reise.\n>\n> > Zweiter *Absatz*.\n\n> Gut genug.\n***\n>\n>\n\nDanach.\n";
2234 let Imported { book, warnings, .. } = import(src).unwrap();
2235 assert!(warnings.is_empty(), "{warnings:?}");
2236 let para = |t: &str| Block::paragraph(vec![Inline::text(t)]);
2237 assert_eq!(
2238 book.sections[0].blocks,
2239 vec![
2240 para("Vorher."),
2242 Block::block_quote(vec![
2243 para("Ich war in großer Verlegenheit: eine dringende Reise."),
2244 Block::paragraph(vec![
2245 Inline::text("Zweiter "),
2246 Inline::Emphasis {
2247 content: vec![Inline::text("Absatz")]
2248 },
2249 Inline::text("."),
2250 ]),
2251 ]),
2252 Block::block_quote(vec![para("Gut genug.")]),
2255 Block::scene_break(),
2256 para("Danach."),
2257 ]
2258 );
2259 }
2260
2261 #[test]
2262 fn lists() {
2263 let src = "Title: T\n\n# Eins\n\nWärme und Geruch\n- wie von Pferden kam hervor.\n\n- Erstes *Ding*,\nweiter.\n* Zweites[^n]\n\n- Drittes\n\n Zweiter Absatz.\n - innen\n + auch innen\n\t- tiefer\n- Viertes\n1. Eins\n2) Zwei\n> Zitat\n\n3. Oktober ein Schwerkranker wartete auf mich.\n\n1. Mai einen Wagen hatte ich.\n\nDanach.\n\n7. Juli\n8. Juli\n\n3. Oktober wartete er auf mich, und am\n24. Dezember schwieg er.\n\n[^n]: Note.\n";
2264 let Imported { book, warnings, .. } = import(src).unwrap();
2265 let got: Vec<_> = warnings.iter().map(|w| (w.kind, w.location)).collect();
2266 assert_eq!(got, [(WarningKind::ListFlattened, 29)]);
2267 let para = |t: &str| Block::paragraph(vec![Inline::text(t)]);
2268 assert_eq!(
2269 book.sections[0].blocks,
2270 vec![
2271 para("Wärme und Geruch - wie von Pferden kam hervor."),
2274 Block::list(
2275 false,
2276 vec![
2277 vec![Block::paragraph(vec![
2278 Inline::text("Erstes "),
2279 Inline::Emphasis {
2280 content: vec![Inline::text("Ding")]
2281 },
2282 Inline::text(", weiter."),
2283 ])],
2284 vec![Block::paragraph(vec![
2285 Inline::text("Zweites"),
2286 Inline::Footnote {
2287 blocks: vec![para("Note.")]
2288 },
2289 ])],
2290 vec![
2291 para("Drittes"),
2292 para("Zweiter Absatz."),
2293 Block::list(
2294 false,
2295 vec![
2296 vec![para("innen")],
2297 vec![
2298 para("auch innen"),
2299 Block::list(false, vec![vec![para("tiefer")]]),
2300 ],
2301 ]
2302 ),
2303 ],
2304 vec![para("Viertes")],
2305 ]
2306 ),
2307 Block::list(true, vec![vec![para("Eins")], vec![para("Zwei")]]),
2309 Block::block_quote(vec![para("Zitat")]),
2310 para("3. Oktober ein Schwerkranker wartete auf mich."),
2312 para("1. Mai einen Wagen hatte ich."),
2313 para("Danach."),
2314 para("7. Juli"),
2316 para("8. Juli"),
2317 para("3. Oktober wartete er auf mich, und am 24. Dezember schwieg er."),
2320 ]
2321 );
2322 }
2323
2324 #[test]
2325 fn page_breaks() {
2326 let src = "Title: T\n\n\\newpage\n\n# Eins\n\n\\pagebreak\nA.\n\\newpage\n\\newpage\n> Zitat\n\\newpage\nB. \\newpage\n\n***\n\\pagebreak\n\n# Zwei\n";
2327 let Imported { book, warnings, .. } = import(src).unwrap();
2328 assert!(warnings.is_empty(), "{warnings:?}");
2329 let para = |t: &str| Block::paragraph(vec![Inline::text(t)]);
2330 assert_eq!(
2334 book.sections[0].blocks,
2335 vec![
2336 para("A."),
2337 Block::page_break(),
2338 Block::block_quote(vec![para("Zitat")]),
2339 Block::page_break(),
2340 para("B. \\newpage"),
2341 ]
2342 );
2343 assert!(book.sections[1].blocks.is_empty());
2344 }
2345
2346 #[test]
2347 fn quote_stops_before_heading() {
2348 let book = import("Title: T\n\n# Eins\n\n> Zitat\n## Unter\n# Zwei\n\nB.\n")
2349 .unwrap()
2350 .book;
2351 assert_eq!(
2352 book.sections[0].blocks,
2353 vec![
2354 Block::block_quote(vec![Block::paragraph(vec![Inline::text("Zitat")])]),
2355 Block::heading(1, vec![Inline::text("Unter")]),
2356 ]
2357 );
2358 assert_eq!(book.sections.len(), 2);
2359 }
2360
2361 #[test]
2362 fn footnotes() {
2363 let src = "Title: T\n\n# Eins\n\nIch bin kein Weltverbesserer [^r], *und lasse ihn liegen[^w]*.\n\n> Noch standen treu die Pferde[^r].\n\n[^r]: Dort sitzt er\nTage und *Jahre*.\n\n Nun lebt er\n nicht mehr lange.\n\n[^w]: Mit [^r] darin.\nDanach.\n";
2364 let Imported { book, warnings, .. } = import(src).unwrap();
2365 assert!(warnings.is_empty(), "{warnings:?}");
2366 let rain = Inline::Footnote {
2367 blocks: vec![
2368 Block::paragraph(vec![
2369 Inline::text("Dort sitzt er Tage und "),
2370 Inline::Emphasis {
2371 content: vec![Inline::text("Jahre")],
2372 },
2373 Inline::text("."),
2374 ]),
2375 Block::paragraph(vec![Inline::text("Nun lebt er nicht mehr lange.")]),
2376 ],
2377 };
2378 assert_eq!(
2379 book.sections[0].blocks,
2380 vec![
2381 Block::paragraph(vec![
2384 Inline::text("Ich bin kein Weltverbesserer"),
2385 rain.clone(),
2386 Inline::text(", "),
2387 Inline::Emphasis {
2388 content: vec![
2389 Inline::text("und lasse ihn liegen"),
2390 Inline::Footnote {
2391 blocks: vec![Block::paragraph(vec![Inline::text(
2392 "Mit [^r] darin. Danach."
2393 )])],
2394 },
2395 ],
2396 },
2397 Inline::text("."),
2398 ]),
2399 Block::block_quote(vec![Block::paragraph(vec![
2400 Inline::text("Noch standen treu die Pferde"),
2401 rain,
2402 Inline::text("."),
2403 ])]),
2404 ]
2405 );
2406 }
2407
2408 #[test]
2409 fn footnote_problems_warn() {
2410 let src = "Title: T\n\n# Eins\n\nA.\n\nB[^x] und [^ y].\n\n# Zwei\n\n[^a]: Ungenutzt.\n\n[^x]: Ja.\n\n[^x]: Doppelt.\n";
2411 let Imported { book, warnings, .. } = import(src).unwrap();
2412 let got: Vec<_> = warnings
2413 .iter()
2414 .map(|w| (w.kind, w.location, w.section))
2415 .collect();
2416 assert_eq!(
2417 got,
2418 vec![
2419 (WarningKind::FootnoteUnused, 11, None),
2420 (WarningKind::FootnoteUnused, 15, None),
2421 ]
2422 );
2423 assert_eq!(
2426 Inline::plain_text(match &book.sections[0].blocks[1] {
2427 Block::Paragraph { content, .. } => content,
2428 _ => panic!(),
2429 }),
2430 "B und [^ y]."
2431 );
2432 assert!(book.sections[1].blocks.is_empty());
2433
2434 let Imported { warnings, .. } = import("Title: T\n\n# Eins\n\nA.\n\nB[^9].\n").unwrap();
2435 let got: Vec<_> = warnings
2436 .iter()
2437 .map(|w| (w.kind, w.location, w.section))
2438 .collect();
2439 assert_eq!(got, vec![(WarningKind::FootnoteMissing, 7, Some(0))]);
2440 assert!(
2441 warnings[0].message.contains("[^9]"),
2442 "{}",
2443 warnings[0].message
2444 );
2445 }
2446
2447 #[test]
2448 fn verse() {
2449 let src = "Title: T\n\n# Eins\n\nVorher.\n:::verse\n\nDer *Wind* geht,\\\n das Meer[^m] steht.\n\n\nEine trübe Stallaterne schwankte drin an einem Seil\n\n:::\n> Zitat\n::: Verse :::\nNun bin ich aber Arzt\n::::\n\n:::verse\n\n:::\n:::\n\n[^m]: Das Dienstmädchen stand neben mir.\n";
2450 let Imported { book, warnings, .. } = import(src).unwrap();
2451 assert!(warnings.is_empty(), "{warnings:?}");
2452 let para = |t: &str| Block::paragraph(vec![Inline::text(t)]);
2453 assert_eq!(
2454 book.sections[0].blocks,
2455 vec![
2456 para("Vorher."),
2457 Block::verse(vec![
2460 vec![
2461 Inline::text("Der "),
2462 Inline::Emphasis {
2463 content: vec![Inline::text("Wind")]
2464 },
2465 Inline::text(" geht,"),
2466 ],
2467 vec![
2468 Inline::text("das Meer"),
2469 Inline::Footnote {
2470 blocks: vec![para("Das Dienstmädchen stand neben mir.")]
2471 },
2472 Inline::text(" steht."),
2473 ],
2474 vec![],
2475 vec![Inline::text(
2476 "Eine trübe Stallaterne schwankte drin an einem Seil"
2477 )],
2478 ]),
2479 Block::block_quote(vec![para("Zitat")]),
2481 Block::verse(vec![vec![Inline::text("Nun bin ich aber Arzt")]]),
2482 para(":::"),
2484 ]
2485 );
2486 }
2487
2488 fn verse_levels_of(fence: &str) -> (Vec<u8>, Vec<String>) {
2491 let src = format!("Title: T\n\n# Eins\n\n:::verse\n{fence}:::\n");
2492 let Imported { book, warnings, .. } = import(&src).unwrap();
2493 assert!(warnings.is_empty(), "{warnings:?}");
2494 let b = &book.sections[0].blocks[0];
2495 let Block::Verse { lines, .. } = b else {
2496 panic!("not verse: {b:?}");
2497 };
2498 let levels = (0..lines.len()).map(|i| b.verse_indent(i)).collect();
2499 let text = lines.iter().map(|l| Inline::plain_text(l)).collect();
2500 (levels, text)
2501 }
2502
2503 const CROC: &str = "“How doth the little crocodile\n Improve his shining tail,\nAnd pour the waters of the Nile\n On every golden scale!\n";
2506
2507 #[test]
2508 fn verse_indents_one_level_per_four_spaces() {
2509 let (levels, text) = verse_levels_of(CROC);
2510 assert_eq!(levels, [0, 1, 0, 1]);
2511 assert_eq!(text[1], "Improve his shining tail,");
2513 let src = format!("Title: T\n\n# Eins\n\n:::verse\n{CROC}:::\n");
2515 let b = &import(&src).unwrap().book.sections[0].blocks[0];
2516 assert_eq!(
2517 serde_json::to_value(b).unwrap()["indents"],
2518 serde_json::json!([0, 1, 0, 1])
2519 );
2520 let flat = "“How doth the little crocodile\nAnd pour the waters of the Nile\n";
2521 let src = format!("Title: T\n\n# Eins\n\n:::verse\n{flat}:::\n");
2522 let b = &import(&src).unwrap().book.sections[0].blocks[0];
2523 assert!(serde_json::to_value(b).unwrap().get("indents").is_none());
2524 }
2525
2526 #[test]
2527 fn verse_indent_steps_tabs_and_stray_spaces() {
2528 let lines = [
2531 "“How doth the little crocodile",
2532 " Improve his shining tail,",
2533 " And pour the waters of the Nile",
2534 " On every golden scale!",
2535 " “How cheerfully he seems to grin,",
2536 " How neatly spread his claws,",
2537 " And welcome little fishes in",
2538 "\tWith gently smiling jaws!”",
2539 "\t\tHow doth the little crocodile",
2540 " \tImprove his shining tail,",
2541 ];
2542 let (levels, _) = verse_levels_of(&(lines.join("\n") + "\n"));
2543 assert_eq!(levels, [0, 0, 0, 0, 1, 1, 2, 1, 2, 1]);
2544 }
2545
2546 #[test]
2547 fn verse_indent_is_counted_from_the_least_indented_line() {
2548 let fence = " “How doth the little crocodile\n Improve his shining tail,\n And pour the waters of the Nile\n On every golden scale!\n\n “How cheerfully he seems to grin,\n";
2552 let (levels, _) = verse_levels_of(fence);
2553 assert_eq!(levels, [0, 1, 0, 1, 0, 1]);
2554 }
2555
2556 #[test]
2557 fn verse_indent_blank_lines_ends_and_the_deepest_level() {
2558 let deep = " ".repeat(36);
2562 let fence = format!(
2563 "\n “How doth the little crocodile\\\nAnd pour the waters of the Nile\n \\\n\n\n{deep}On every golden scale!\n\n"
2564 );
2565 let (levels, text) = verse_levels_of(&fence);
2566 assert_eq!(text.len(), 4, "{text:?}");
2567 assert!(text[2].is_empty());
2568 assert_eq!(levels, [1, 0, 0, MAX_VERSE_INDENT]);
2569 }
2570
2571 #[test]
2572 fn epigraphs() {
2573 let src = "Title: T\n\n:::epigraph\nVorn *steht* es.\n— Jemand, *Buch*\n:::\n\n# Teil 1\n\n::: Epigraph :::\nOhne Zuschreibung,\\\nin zwei Zeilen.\n\nArmer Junge, dir ist nicht zu helfen[^n].\n:::\n\n# Eins\n\n:::epigraph\n— Nur ein Strich\n:::\n\nA.\n\n:::epigraph\nSo ist es.\n-- Wer\n:::\n\n# Zwei\n\n:::epigraph\nOffen\n\n[^n]: Note.\n";
2574 let Imported { book, warnings, .. } = import(src).unwrap();
2575 let text = |t: &str| Inline::text(t);
2576 let para = |t: &str| Block::paragraph(vec![text(t)]);
2577 let kinds: Vec<_> = book.sections.iter().map(|s| s.kind).collect();
2578 assert_eq!(
2579 kinds,
2580 [
2581 SectionKind::Epigraph,
2582 SectionKind::Part,
2583 SectionKind::Chapter,
2584 SectionKind::Chapter
2585 ]
2586 );
2587 assert_eq!(
2590 book.sections[0].epigraph,
2591 Some(Epigraph {
2592 text: vec![
2593 text("Vorn "),
2594 Inline::Emphasis {
2595 content: vec![text("steht")]
2596 },
2597 text(" es."),
2598 ],
2599 attribution: Some("Jemand, Buch".into()),
2600 })
2601 );
2602 assert!(book.sections[0].blocks.is_empty());
2603 assert_eq!(
2605 book.sections[1].epigraph,
2606 Some(Epigraph {
2607 text: vec![
2608 text("Ohne Zuschreibung,"),
2609 Inline::LineBreak,
2610 text("in zwei Zeilen."),
2611 Inline::LineBreak,
2612 text("Armer Junge, dir ist nicht zu helfen"),
2613 Inline::Footnote {
2614 blocks: vec![para("Note.")]
2615 },
2616 text("."),
2617 ],
2618 attribution: None,
2619 })
2620 );
2621 let eins = &book.sections[2];
2623 assert_eq!(
2624 eins.epigraph,
2625 Some(Epigraph {
2626 text: vec![text("— Nur ein Strich")],
2627 attribution: None,
2628 })
2629 );
2630 assert_eq!(
2631 eins.blocks,
2632 vec![
2633 para("A."),
2634 Block::block_quote(vec![para("So ist es."), para("-- Wer")]),
2635 ]
2636 );
2637 assert_eq!(
2638 book.sections[3].epigraph.as_ref().map(|e| &e.text),
2639 Some(&vec![text("Offen")])
2640 );
2641 let got: Vec<_> = warnings
2642 .iter()
2643 .map(|w| (w.kind, w.location, w.section))
2644 .collect();
2645 assert_eq!(got, [(WarningKind::VerseNotClosed, 32, Some(3))]);
2646 assert!(warnings[0].message.contains("`:::epigraph`"));
2647 }
2648
2649 #[test]
2650 fn verse_not_closed() {
2651 let src = "Title: T\n\n# Eins\n\n:::verse\nZeile\n\n# Zwei\n\nA.\n\n:::verse\nEnde\n";
2652 let Imported { book, warnings, .. } = import(src).unwrap();
2653 let got: Vec<_> = warnings
2654 .iter()
2655 .map(|w| (w.kind, w.location, w.section))
2656 .collect();
2657 assert_eq!(
2658 got,
2659 vec![
2660 (WarningKind::VerseNotClosed, 5, Some(0)),
2661 (WarningKind::VerseNotClosed, 12, Some(1)),
2662 ]
2663 );
2664 assert_eq!(book.sections.len(), 2);
2665 assert_eq!(
2666 book.sections[0].blocks,
2667 vec![Block::verse(vec![vec![Inline::text("Zeile")]])]
2668 );
2669 assert_eq!(
2670 book.sections[1].blocks[1],
2671 Block::verse(vec![vec![Inline::text("Ende")]])
2672 );
2673 }
2674
2675 #[test]
2676 fn hard_line_breaks() {
2677 let src = "Title: T\n\n# Eins\n\nLiebe *Anna,\\\ndas Haus* ist \\\nverkauft.\\\n\n> Gruß \\\n> Lena\n\nB[^n].\n\n[^n]: Eins\\\nzwei.\n";
2678 let Imported { book, warnings, .. } = import(src).unwrap();
2679 assert!(warnings.is_empty(), "{warnings:?}");
2680 assert_eq!(
2681 book.sections[0].blocks,
2682 vec![
2683 Block::paragraph(vec![
2685 Inline::text("Liebe "),
2686 Inline::Emphasis {
2687 content: vec![
2688 Inline::text("Anna,"),
2689 Inline::LineBreak,
2690 Inline::text("das Haus"),
2691 ]
2692 },
2693 Inline::text(" ist"),
2694 Inline::LineBreak,
2695 Inline::text("verkauft."),
2696 ]),
2697 Block::block_quote(vec![Block::paragraph(vec![
2698 Inline::text("Gruß"),
2699 Inline::LineBreak,
2700 Inline::text("Lena"),
2701 ])]),
2702 Block::paragraph(vec![
2703 Inline::text("B"),
2704 Inline::Footnote {
2705 blocks: vec![Block::paragraph(vec![
2706 Inline::text("Eins"),
2707 Inline::LineBreak,
2708 Inline::text("zwei."),
2709 ])]
2710 },
2711 Inline::text("."),
2712 ]),
2713 ]
2714 );
2715 }
2716
2717 #[test]
2718 fn images() {
2719 let src = "Title: T\n\n# Eins\n\nVorher.\n\n\n{width=50%}\n\n\n\n mit Text\n\n[^q]: Archiv.\n";
2720 let mut asked = Vec::new();
2721 let Imported {
2722 book,
2723 assets,
2724 warnings,
2725 } = super::import(src, |p| {
2726 asked.push(p.to_string());
2727 match p {
2728 "img/karte.png" => Some(PNG.to_vec()),
2729 "mein bild.jpeg" => Some(vec![0xFF, 0xD8, 0xFF, 0xE0]),
2730 _ => None,
2731 }
2732 })
2733 .unwrap();
2734 assert!(warnings.is_empty(), "{warnings:?}");
2735 assert_eq!(asked, ["img/karte.png", "mein bild.jpeg"]);
2737 let ids: Vec<_> = assets
2738 .iter()
2739 .map(|a| (a.id.as_str(), a.media_type.as_str()))
2740 .collect();
2741 assert_eq!(
2742 ids,
2743 [("karte.png", "image/png"), ("mein_bild.jpg", "image/jpeg")]
2744 );
2745 assert_eq!(
2746 book.sections[0].blocks,
2747 vec![
2748 Block::paragraph(vec![Inline::text("Vorher.")]),
2749 Block::image(
2750 "karte.png",
2751 "Die Karte",
2752 Some(vec![
2753 Inline::text("Der neue Advokat"),
2754 Inline::Footnote {
2755 blocks: vec![Block::paragraph(vec![Inline::text("Archiv.")])]
2756 },
2757 Inline::text(", 1921"),
2758 ])
2759 ),
2760 Block::image("mein_bild.jpg", "", None).with_width(50),
2761 Block::image("karte.png", "Nochmal", None),
2762 Block::paragraph(vec mit Text")]),
2763 ]
2764 );
2765 }
2766
2767 #[test]
2768 fn image_problems_warn() {
2769 let src = "Title: T\n\n# Eins\n\n\n\n\n\n\n\n\n\n> Zitat\n\n";
2770 let Imported {
2771 book,
2772 assets,
2773 warnings,
2774 } = super::import(src, |p| match p {
2775 "alt.tiff" => Some(b"II*\0rest".to_vec()),
2776 "gross.png" => Some(vec![0; MAX_IMAGE_BYTES as usize + 1]),
2777 _ => None,
2778 })
2779 .unwrap();
2780 let got: Vec<_> = warnings
2781 .iter()
2782 .map(|w| (w.kind, w.location, w.section))
2783 .collect();
2784 assert_eq!(
2786 got,
2787 vec![
2788 (WarningKind::ImageMissing, 5, Some(0)),
2789 (WarningKind::ImageMissing, 9, Some(0)),
2790 (WarningKind::ImageUnsupported, 11, Some(0)),
2791 (WarningKind::ImageMissing, 14, Some(0)),
2792 ]
2793 );
2794 assert!(
2795 warnings[2].message.contains("TIFF"),
2796 "{}",
2797 warnings[2].message
2798 );
2799 assert!(assets.is_empty());
2800 assert_eq!(
2803 book.sections[0].blocks,
2804 vec![Block::block_quote(vec![Block::paragraph(vec![
2805 Inline::text("Zitat")
2806 ])])]
2807 );
2808 }
2809
2810 #[test]
2811 fn image_line_parts() {
2812 fn parts(l: &str) -> Option<(&str, &str, Option<&str>)> {
2813 image_line(l).map(|i| (i.alt, i.path, i.caption))
2814 }
2815 assert_eq!(parts(""), Some(("a", "b.png", None)));
2816 assert_eq!(
2817 parts(""),
2818 Some(("a", "my file.png", Some("Cap \"x\"")))
2819 );
2820 assert_eq!(parts(""), Some(("a", "b.png\"x\"", None)));
2821 assert_eq!(
2822 parts("{#fig .wide}"),
2823 Some(("a", "b c.png", None))
2824 );
2825 assert_eq!(parts("![a]()"), None);
2826 assert_eq!(parts(" text"), None);
2827 assert_eq!(parts("[a](b.png)"), None);
2828 }
2829
2830 #[test]
2831 fn comments_keep_line_numbers() {
2832 let src = "Title: T\n\n<!--\nEine Notiz\n-->\n# Eins\n\nA <!-- x -->B[^9].\n";
2833 let Imported { book, warnings, .. } = import(src).unwrap();
2834 assert_eq!(book.sections.len(), 1);
2835 let got: Vec<_> = warnings.iter().map(|w| (w.kind, w.location)).collect();
2836 assert_eq!(got, [(WarningKind::FootnoteMissing, 8)]);
2837 assert_eq!(
2838 book.sections[0].blocks,
2839 vec![Block::paragraph(vec![Inline::text("A B[^9].")])]
2840 );
2841 }
2842
2843 fn pipe_tables(src: &str) -> (Vec<Table>, Vec<ImportWarning>) {
2845 let Imported { book, warnings, .. } = import(&format!("Title: T\n\n# C\n\n{src}")).unwrap();
2846 let tables = book.sections[0]
2847 .blocks
2848 .iter()
2849 .filter_map(|b| match b {
2850 Block::Table(t) => Some(t.clone()),
2851 _ => None,
2852 })
2853 .collect();
2854 (tables, warnings)
2855 }
2856
2857 fn texts(t: &Table) -> Vec<Vec<String>> {
2859 t.rows
2860 .iter()
2861 .map(|r| r.iter().map(|c| Inline::plain_text(&c.content)).collect())
2862 .collect()
2863 }
2864
2865 fn aligns(t: &Table) -> Vec<ColumnAlign> {
2866 t.columns.iter().map(|c| c.align).collect()
2867 }
2868
2869 #[test]
2870 fn a_pipe_table_is_a_table_with_alignment_and_a_header_row() {
2871 let (tables, warnings) = pipe_tables(
2872 "| A | B | C | D |\n|:--|--:|:-:|---|\n| 1 | 2 | 3 | 4 |\n| 5 | 6 | 7 | 8 |\n",
2873 );
2874 assert!(warnings.is_empty(), "{warnings:?}");
2875 let [t] = tables.as_slice() else { panic!() };
2876 use ColumnAlign::{Center, Left, Right};
2877 assert_eq!(aligns(t), [Left, Right, Center, Left]);
2878 assert_eq!(t.header_rows, 1);
2879 assert_eq!(texts(t)[0], ["A", "B", "C", "D"]);
2880 assert_eq!(texts(t)[2], ["5", "6", "7", "8"]);
2881 assert_eq!(t.caption, None);
2882 assert!(
2883 t.rows
2884 .iter()
2885 .flatten()
2886 .all(|c| c.colspan == 1 && c.rowspan == 1 && c.align.is_none())
2887 );
2888 }
2889
2890 #[test]
2891 fn a_table_without_outer_pipes_and_with_a_single_dash_delimiter() {
2892 let (tables, warnings) = pipe_tables("A | B\n-: | :-\n1 | 2\n");
2893 assert!(warnings.is_empty(), "{warnings:?}");
2894 let [t] = tables.as_slice() else { panic!() };
2895 assert_eq!(aligns(t), [ColumnAlign::Right, ColumnAlign::Left]);
2896 assert_eq!(texts(t), [["A", "B"], ["1", "2"]]);
2897 }
2898
2899 #[test]
2900 fn an_empty_header_row_is_no_header() {
2901 let (tables, warnings) = pipe_tables("| | |\n|:-|-:|\n| a | b |\n| c | d |\n");
2902 assert!(warnings.is_empty(), "{warnings:?}");
2903 let [t] = tables.as_slice() else { panic!() };
2904 assert_eq!(t.header_rows, 0);
2905 assert_eq!(texts(t), [["a", "b"], ["c", "d"]]);
2906 assert_eq!(aligns(t), [ColumnAlign::Left, ColumnAlign::Right]);
2907 let (tables, _) = pipe_tables("| a | |\n|-|-|\n| | b |\n");
2909 assert_eq!(tables[0].header_rows, 1);
2910 assert_eq!(texts(&tables[0]), [["a", ""], ["", "b"]]);
2911 }
2912
2913 #[test]
2914 fn a_header_alone_is_a_table() {
2915 let (tables, warnings) = pipe_tables("| a | b |\n|-|-|\n");
2916 assert!(warnings.is_empty(), "{warnings:?}");
2917 assert_eq!(tables[0].header_rows, 1);
2918 assert_eq!(texts(&tables[0]), [["a", "b"]]);
2919 }
2920
2921 #[test]
2922 fn short_rows_are_padded_and_long_rows_are_cut_with_a_warning() {
2923 let (tables, warnings) =
2924 pipe_tables("| a | b | c |\n|-|-|-|\n| 1 |\n| 2 | 3 |\n| 4 | 5 | 6 | 7 |\n");
2925 let [t] = tables.as_slice() else { panic!() };
2926 assert_eq!(
2927 texts(t),
2928 [
2929 ["a", "b", "c"],
2930 ["1", "", ""],
2931 ["2", "3", ""],
2932 ["4", "5", "6"]
2933 ]
2934 );
2935 let got: Vec<_> = warnings.iter().map(|w| (w.kind, w.location)).collect();
2936 assert_eq!(got, [(WarningKind::TableSimplified, 5)]);
2937 let (_, warnings) = pipe_tables("| a |\n|-|\n| 1 | |\n");
2939 assert!(warnings.is_empty(), "{warnings:?}");
2940 }
2941
2942 #[test]
2943 fn a_table_with_no_text_is_dropped_with_a_warning() {
2944 let (tables, warnings) = pipe_tables("Before.\n\n| | |\n|-|-|\n| | |\n\nAfter.\n");
2945 assert!(tables.is_empty());
2946 let got: Vec<_> = warnings.iter().map(|w| (w.kind, w.location)).collect();
2947 assert_eq!(got, [(WarningKind::TableSimplified, 7)]);
2948 }
2949
2950 #[test]
2951 fn cells_hold_inlines_an_escaped_pipe_and_a_footnote() {
2952 let (tables, warnings) = pipe_tables(
2953 "| a \\| b | *c* |\n|-|-|\n| **d**[^1] | [e](https://x.org) |\n\n[^1]: Note.\n",
2954 );
2955 assert!(warnings.is_empty(), "{warnings:?}");
2956 let t = &tables[0];
2957 assert_eq!(texts(t)[0], ["a | b", "c"]);
2958 assert!(matches!(
2959 t.rows[0][1].content.as_slice(),
2960 [Inline::Emphasis { .. }]
2961 ));
2962 assert!(matches!(
2963 t.rows[1][0].content.as_slice(),
2964 [Inline::Strong { .. }, Inline::Footnote { .. }]
2965 ));
2966 assert!(matches!(
2967 t.rows[1][1].content.as_slice(),
2968 [Inline::Link { .. }]
2969 ));
2970 }
2971
2972 #[test]
2973 fn a_footnote_mark_in_a_dropped_cell_is_an_unused_note() {
2974 let (tables, warnings) = pipe_tables("| a |\n|-|\n| 1 | x[^1] |\n\n[^1]: Note.\n");
2975 assert_eq!(texts(&tables[0]), [["a"], ["1"]]);
2976 let kinds: Vec<_> = warnings.iter().map(|w| w.kind).collect();
2977 assert!(kinds.contains(&WarningKind::TableSimplified), "{kinds:?}");
2978 assert!(kinds.contains(&WarningKind::FootnoteUnused), "{kinds:?}");
2979 }
2980
2981 #[test]
2982 fn a_one_column_table_is_a_table_and_prose_is_not() {
2983 let (tables, warnings) = pipe_tables("| a |\n|---|\n| b |\n");
2984 assert!(warnings.is_empty(), "{warnings:?}");
2985 assert_eq!(texts(&tables[0]), [["a"], ["b"]]);
2986 let (tables, _) = pipe_tables("A line with a | pipe.\nAnother | one.\n");
2987 assert!(tables.is_empty());
2988 let (tables, _) = pipe_tables("| no delimiter |\n| row here |\n");
2989 assert!(tables.is_empty());
2990 }
2991
2992 fn caption(t: &Table) -> Option<String> {
2994 t.caption.as_deref().map(Inline::plain_text)
2995 }
2996
2997 #[test]
2998 fn a_table_caption_above_or_below() {
2999 let rows = "| a |\n|-|\n| 1 |\n";
3000 for src in [
3002 format!("Table: Cap\n{rows}"),
3003 format!("Table: Cap\n\n{rows}"),
3004 format!("{rows}Table: Cap\n"),
3005 format!("{rows}\nTable: Cap\n"),
3006 format!("{rows}\nTable: Cap\n\nAfter.\n"),
3007 ] {
3008 let (tables, warnings) = pipe_tables(&src);
3009 assert!(warnings.is_empty(), "{src:?}: {warnings:?}");
3010 assert_eq!(caption(&tables[0]).as_deref(), Some("Cap"), "{src:?}");
3011 assert_eq!(texts(&tables[0]), [["a"], ["1"]]);
3012 }
3013 }
3014
3015 #[test]
3016 fn a_caption_line_that_is_not_a_caption_stays_a_paragraph() {
3017 let rows = "| a |\n|-|\n| 1 |\n";
3018 for src in [
3019 format!("Some prose\nTable: Cap\n\n{rows}"),
3021 format!("Some prose\nTable: Cap\n{rows}"),
3022 format!("{rows}Table: Cap\nmore of the paragraph\n"),
3023 format!("Table:\n\n{rows}"),
3025 format!("Table:Cap\n\n{rows}"),
3026 format!("Table: Cap\n\n\n{rows}"),
3028 format!("{rows}\n\nTable: Cap\n"),
3029 ] {
3030 let (tables, _) = pipe_tables(&src);
3031 assert_eq!(caption(&tables[0]), None, "{src:?}");
3032 }
3033 let (tables, _) = pipe_tables("Table: Cap\n\nJust prose.\n");
3034 assert!(tables.is_empty());
3035 }
3036
3037 #[test]
3038 fn a_caption_between_two_tables_belongs_to_the_one_below() {
3039 let src = "| a |\n|-|\n| 1 |\n\nTable: Middle\n\n| b |\n|-|\n| 2 |\n";
3040 let (tables, _) = pipe_tables(src);
3041 assert_eq!(caption(&tables[0]), None);
3042 assert_eq!(caption(&tables[1]).as_deref(), Some("Middle"));
3043 let (tables, _) = pipe_tables("Table: One\n| a |\n|-|\n| 1 |\nTable: Two\n");
3046 assert_eq!(caption(&tables[0]).as_deref(), Some("One"));
3047 }
3048
3049 #[test]
3050 fn a_caption_is_inlines_and_may_hold_a_footnote() {
3051 let (tables, warnings) =
3052 pipe_tables("Table: *Cap*[^1]\n\n| a |\n|-|\n| 1 |\n\n[^1]: Note.\n");
3053 assert!(warnings.is_empty(), "{warnings:?}");
3054 let c = tables[0].caption.as_deref().unwrap();
3055 assert!(matches!(
3056 c,
3057 [Inline::Emphasis { .. }, Inline::Footnote { .. }]
3058 ));
3059 }
3060
3061 #[test]
3062 fn the_caption_of_a_table_with_no_text_stays_a_paragraph() {
3063 let Imported { book, warnings, .. } =
3064 import("Title: T\n\n# C\n\nTable: Cap\n| |\n|-|\n| |\n").unwrap();
3065 assert_eq!(
3066 book.sections[0].blocks,
3067 vec![Block::paragraph(vec![Inline::text("Cap")])]
3068 );
3069 assert_eq!(warnings.len(), 1);
3070 }
3071
3072 #[test]
3073 fn a_caption_below_ends_at_a_line_that_starts_a_block() {
3074 let rows = "| a |\n|-|\n| 1 |\n";
3075 for next in ["## Heading", "> A quote", "* * *", "\\newpage"] {
3076 let (tables, _) = pipe_tables(&format!("{rows}Table: Cap\n{next}\n"));
3077 assert_eq!(caption(&tables[0]).as_deref(), Some("Cap"), "{next:?}");
3078 }
3079 let (tables, _) = pipe_tables(&format!("{rows}Table: Cap\nprose\n"));
3081 assert_eq!(caption(&tables[0]), None);
3082 }
3083
3084 #[test]
3085 fn a_table_before_the_first_heading_warns_in_its_section() {
3086 let Imported { warnings, .. } = import("Title: T\n\n| a |\n|-|\n| 1 | x |\n").unwrap();
3087 let w = warnings
3088 .iter()
3089 .find(|w| w.kind == WarningKind::TableSimplified)
3090 .expect("TableSimplified");
3091 assert!(w.section.is_some(), "{warnings:?}");
3092 }
3093
3094 #[test]
3095 fn a_dropped_table_puts_its_caption_at_the_caption_line() {
3096 let Imported { warnings, .. } = import("Title: T\n\nTable: Cap\n| |\n|-|\n| |\n").unwrap();
3097 let w = warnings
3098 .iter()
3099 .find(|w| w.kind == WarningKind::TextBeforeFirstChapter)
3100 .expect("TextBeforeFirstChapter");
3101 assert_eq!(w.location, 3, "{warnings:?}");
3102 }
3103
3104 #[test]
3105 fn empty_is_error() {
3106 assert!(import(" \n").is_err());
3107 assert!(import("<!-- nur eine Notiz -->\n").is_err());
3108 }
3109}