Skip to main content

setzer_import/
markdown.rs

1//! The Kernsetzer Markdown dialect (M1 extends the M0 subset, see checklist).
2//!
3//! ```text
4//! Title: Der Leuchtturm          <- front matter: `Key: value` lines at the top
5//! Author: Lena Brandt               keys: Title, Subtitle, Author, Lang,
6//! Lang: de                          Publisher, Copyright (or Rights),
7//!                                   Dedication; or a YAML block between
8//!                                   `---` lines (see `front_matter.rs`).
9//!                                   Without `Lang:`, English or German is
10//!                                   detected from the text, as for .docx
11//!
12//! *For Ada, who asked first.*    <- before the first heading: a paragraph
13//!                                   starting like a dedication (For, To, Für,
14//!                                   In memory, Gewidmet, …) is a Dedication
15//!                                   section; up to three more right after it
16//!                                   join it, 300 characters in all. Italic
17//!                                   over a whole paragraph is dropped
18//!
19//! # Chapter title                <- new chapter
20//! # Prolog / # Prologue          <- recognized section kinds (also Epilog, …)
21//! # Interlude {-}                <- an unnumbered chapter (also `{.unnumbered}`,
22//!                                   as in Pandoc): no label, counting skips it
23//! ## Subheading                  <- heading inside the section; `##` is level 1,
24//!                                   `###` level 2, … up to `######`
25//!
26//! > Quoted text, lines joined.   <- block quote; a bare `>` line separates
27//! >                                 paragraphs, a blank line ends the quote
28//! > Second paragraph.
29//!
30//! Paragraphs are separated by blank lines. *Emphasis*, _emphasis_, **strong**,
31//! [small caps]{.smallcaps} (a Pandoc span; other `{…}` attributes are dropped),
32//! 2^nd^, H~2~O (superscript, subscript: no spaces inside, `~~` is text),
33//! [links](https://example.com "title") and <https://example.com>.
34//!
35//! Dear Anna,\                    <- a trailing `\` is a line break (paragraphs,
36//! the house is sold.                quotes, notes); on a last line it is dropped
37//!
38//! :::verse                       <- verse: every line is kept, a trailing `\`
39//! How doth the little crocodile     is allowed; blank lines separate stanzas;
40//!     Improve his shining tail,     ends at `:::` (or, with a warning, before
41//!                                   the next heading or at the end). Leading
42//! How cheerfully he seems to grin,  spaces indent a line: one level per four
43//! :::                               (a tab is four), counted from the least-
44//!                                   indented line, up to six levels
45//!
46//! :::epigraph                    <- epigraph: text joined like a paragraph
47//! Every coast is a rumour\          (blank lines become line breaks); a last
48//! until someone draws it twice.     line starting with a dash is the
49//! — Harbour saying                  attribution, plain text. After a `#`
50//! :::                               heading: the section's epigraph; before
51//!                                   the first heading: an Epigraph section;
52//!                                   elsewhere a block quote. Ends like verse
53//!
54//! ![Map of the island](img/map.png "The island, 1921")
55//!                                <- image on a line of its own: alt text, path
56//!                                   (relative to the manuscript; `<…>` or plain,
57//!                                   spaces allowed), optional "caption"; Pandoc
58//!                                   attributes after it size and place it:
59//!                                   `{width=50%}` (rounded to 5, 10–100),
60//!                                   `{.beside}` + `.outside` `.inside` `.left`
61//!                                   `.right`, `{.page}`, `{.bleed}`, combined
62//!                                   as `{.beside .left width=35%}`; others
63//!                                   are ignored
64//!
65//! Table: Lydia, my love          <- caption (Pandoc): a `Table: text` line
66//! | Day | Miles |                   alone in its paragraph, right above
67//! |:----|------:|                   the table or after one blank line, or
68//! | 1   | 14    |                   the same below it; shown above, and one
69//!                                   above wins over one below. This is a
70//!                                   pipe table: a header row, a delimiter
71//!                                   row, rows; outer pipes optional, `\|`
72//!                                   is text, cells hold emphasis, links and
73//!                                   notes. The delimiter row sets each
74//!                                   column's alignment (`:--` left, `--:`
75//!                                   right, `:-:` centre, `---` left). The
76//!                                   header row is the header, none when all
77//!                                   of it is empty (`| | |`). A short row
78//!                                   is padded; cells beyond the header's
79//!                                   count are left out, and a table with no
80//!                                   text is dropped: `TableSimplified`.
81//!                                   Merged cells cannot be written here
82//!
83//! - Rope, *the long one*,        <- list: `-`, `*` or `+` items, or `1.`/`1)`;
84//!   and the hooks.                  lines joined; after a blank line another
85//! - Matches                         item continues it and an indented line
86//!                                   (2 spaces, tab) is a second paragraph;
87//!   Keep them dry.                  deeper-indented items nest. Starts after
88//!   - in the tin box                a blank line or a block, never inside a
89//! 1. Walk to the pier.              paragraph. Ordered: starts at 1, two items
90//! 2. Wait for the tide.             at least ("3. Oktober …" is text).
91//!                                   Items counting on from another number
92//!                                   ("8.", "9.") are numbered paragraphs,
93//!                                   with a `ListFlattened` warning
94//!
95//! A footnote mark[^1] goes on the word; the definition can be anywhere:
96//!
97//! [^1]: Note text, lines joined; after a blank line,
98//!     an indented line (tab or 4 spaces) starts its second paragraph.
99//!
100//! Ver&shy;legenheit             <- soft hyphen (or U+00AD itself): the
101//!                                   author's own hyphenation point
102//!
103//! <!-- TODO: the year -->        <- HTML comment: never printed, also over
104//!                                   several lines; an unclosed `<!--` is text
105//!
106//! ***                            <- scene break (also `* * *`, `#`, `~~~`)
107//!
108//! \newpage                       <- page break (also `\pagebreak`); dropped at
109//!                                   the start or end of a section
110//! ```
111
112pub(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
128/// Import a manuscript. The parser does no I/O: `files` returns the bytes of
129/// an image path as written in the manuscript (relative to it), or `None`
130/// if there is no such file. Each path is asked for once. Pass `|_| None`
131/// when no files are at hand; images then warn as missing.
132pub 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    // Without a `Lang:` line, the text says (as for .docx).
149    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    // Lines of the paragraph being read, and the line number it starts on.
179    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            // "Kapitel 3" / "Chapter 3: Title": the number is stored as
187            // explicit numbering and the label is generated by the theme.
188            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                // Collapse repeated breaks and drop a break at chapter start.
243                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                // Collapse repeated breaks and drop a break at section start
253                // (sections start on a new page anyway).
254                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, &paras),
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    // A scene or page break as the last block of a chapter is a manuscript
292    // artifact.
293    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
321/// `src` without its HTML comments (`<!-- … -->`, also over several
322/// lines), which never render (CommonMark, Pandoc). The line breaks inside
323/// a comment are kept, so line numbers stay right; a comment on lines of
324/// its own leaves blank lines and so ends a paragraph, as an HTML block
325/// does in CommonMark. An unclosed `<!--` stays text: it would hide the
326/// rest of the book, and as text pre-flight finds it.
327pub(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        // "a <!-- x --> b" keeps one space.
343        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
351/// `src` with `&shy;` as U+00AD, the soft hyphen: an author's own
352/// hyphenation point, which is invisible in most editors, so it is typed as
353/// the HTML entity (Pandoc reads it too). The dialect has no other entities.
354fn soft_hyphens(src: Cow<'_, str>) -> Cow<'_, str> {
355    if src.contains("&shy;") {
356        Cow::Owned(src.replace("&shy;", "\u{AD}"))
357    } else {
358        src
359    }
360}
361
362/// The book body as it is read, with the warnings, footnotes and images
363/// so far.
364struct 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    /// Path as written → asset id (`None` if the image could not be used).
371    images: HashMap<String, Option<String>>,
372    /// Sections made from front matter (the dedication); body text never
373    /// goes into them.
374    front: usize,
375    /// A `Dedication` section made from paragraphs, which a short
376    /// paragraph right after it joins (see [`Self::dedication`]).
377    dedication: Option<usize>,
378}
379
380impl Body<'_> {
381    /// The section body blocks go into: the last one opened by a heading.
382    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    /// Index of [`Self::current`], for warnings.
391    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    /// End the running paragraph, if any; it started on line `start`.
403    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    /// Append a body block to the current section; text before the first
414    /// `#` heading is a dedication or opens an untitled section, with a
415    /// warning.
416    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    /// Put a paragraph before the first heading into a dedication, by the
445    /// .docx rule: one that starts like a dedication ("For …", "Für …",
446    /// "In memory …") opens a `Dedication` section, and short ones right
447    /// after it join it, up to [`DEDICATION_PARAS`] paragraphs and
448    /// [`DEDICATION_CHARS`] characters. Italic over a whole paragraph is
449    /// the dedication's style and dropped. Returns any other block.
450    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    /// Add the items of a list that starts at `start`, not 1, as numbered
499    /// paragraphs (line index, lines), and warn once: the model has no
500    /// start number.
501    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    /// Add an image block, or warn and leave it out if its file cannot be
522    /// used. A path used again shares the asset (and warns only once).
523    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    /// Add a pipe table as a table block: the delimiter row
552    /// gives each column's alignment, the header row is the one header row
553    /// (none when all of it is empty: how GFM writes a table without a
554    /// header), a short row is padded and cells beyond the header's count are
555    /// left out. A table with no text at all is dropped (its caption stays
556    /// a paragraph). Both cases warn with `TableSimplified`, once per table.
557    /// The caption, if any, is the table's caption (inlines).
558    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        // The section is read when the warning is given, after the table (or
564        // its caption) has opened one if the manuscript had no heading yet.
565        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        // A caption above is read before the cells, below after them: the
587        // footnotes are numbered in reading order.
588        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    /// The asset id for an image path, reading the file through `files`.
625    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    /// The verse fence opening at `lines[start]`; returns the index of the
684    /// first line after it. Each line is a verse line, a trailing `\` is
685    /// redundant and dropped, blank lines separate stanzas (one empty line in
686    /// the model, none at the ends). Leading spaces give the indent level
687    /// ([`verse_levels`]).
688    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    /// The epigraph fence opening at `lines[start]`; returns the index of the
722    /// first line after it. Its text is joined like a paragraph (blank lines
723    /// become line breaks: the model holds one run of text); a last line
724    /// starting with a dash is the attribution, as plain text. Right after a
725    /// heading it is that section's epigraph, before the first heading an
726    /// `Epigraph` section of its own; anywhere else it is a block quote with
727    /// the attribution line as its last paragraph.
728    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        // `None` before the first heading, `Some(true)` right after one.
748        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    /// Warn about a fence without its `:::` (see [`fence_body`]).
796    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    /// Warn about footnote marks without a definition in the block just
810    /// added, which started on `line_no`.
811    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    /// Warn about definitions no mark refers to: their text is not in the
829    /// book, so the author must hear about it.
830    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/// Footnote definitions, taken out of the text before the body is read:
865///
866/// ```text
867/// [^1]: The note text, continued on the
868/// next line.
869///
870///     An indented paragraph (tab or 4 spaces) after a blank line
871///     belongs to the same note.
872/// ```
873///
874/// The first definition of a label wins. A mark inside a note is kept as
875/// text: notes on notes do not exist in fiction.
876#[derive(Default)]
877struct Notes {
878    defs: HashMap<String, NoteDef>,
879    /// Labels defined a second time, with the line of that definition.
880    duplicates: Vec<(String, usize)>,
881    /// Marks read since the last [`Body::note_warnings`] that have no
882    /// definition.
883    missing: Vec<String>,
884}
885
886struct NoteDef {
887    line: usize,
888    paras: Vec<String>,
889    used: bool,
890}
891
892impl Notes {
893    /// Take the definitions out of `lines` (which start at line index
894    /// `offset`), leaving blank lines so line numbers stay right.
895    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                    // A blank line ends the note unless an indented line
915                    // follows it.
916                    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    /// The note for a mark, or `None` (recorded as missing).
955    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
971/// `[^label]: text` → (label, text).
972fn 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
988/// Whether a line after a note definition is not part of the note.
989fn ends_note(line: &str) -> bool {
990    starts_block(line) || line.starts_with('>') || note_definition(line).is_some()
991}
992
993/// A `#` heading's text without a trailing Pandoc `{-}` or `{.unnumbered}`,
994/// and whether it had one.
995fn 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
1005/// `## Text` … `###### Text` → (level, text). The level counts from the
1006/// section title (`#`), so `##` is level 1, as for .docx sub-headings.
1007/// Closing hashes (`## Text ##`) are dropped. A line of hashes alone is a
1008/// scene break, and `#######` or `##Text` are paragraph text.
1009fn 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
1029/// The quote starting at `lines[start]` (a `>` line) and the index of the
1030/// first line after it. Consecutive `>` lines join into paragraphs; a bare
1031/// `>` separates them. A text line right after quoted text continues it
1032/// (lazy continuation: hard-wrapped manuscripts often mark only the first
1033/// line). Nested `> >` is flattened; fiction has no use for quotes in quotes.
1034fn 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
1064/// A quoted line without its `>` markers (all of them, for nested quotes).
1065fn 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
1073/// Whether a line starts a block of its own instead of continuing a quote.
1074fn 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
1083/// A list item line, `- text` (also `*`, `+`) or `1. text` (also `1)`).
1084struct ItemLine<'s> {
1085    /// Width of the leading whitespace, a tab counting 4.
1086    indent: usize,
1087    /// The number of an ordered item.
1088    number: Option<u32>,
1089    text: &'s str,
1090}
1091
1092/// `raw` (untrimmed, for its indentation) as a list item. An item needs a
1093/// space after its marker and some text; `* * *` is a scene break.
1094fn 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
1127/// One list item as written: its marker and the lines of its paragraphs.
1128struct RawItem<'s> {
1129    /// Index of the item's first line, which `paras[0][0]` is without
1130    /// its marker.
1131    line: usize,
1132    indent: usize,
1133    number: Option<u32>,
1134    paras: Vec<Vec<&'s str>>,
1135}
1136
1137/// What [`list`] found.
1138enum ListRun<'s> {
1139    Lists(Vec<Block>),
1140    /// An ordered list that starts at `start`, not 1: its items as
1141    /// paragraphs (line index, lines, numbers kept).
1142    Numbered {
1143        start: u32,
1144        paras: Vec<(usize, Vec<&'s str>)>,
1145    },
1146}
1147
1148/// The lists starting at `lines[start]` and the index of the first line
1149/// after them, or `None` if `lines[start]` does not start a list. Item text
1150/// continues on the lines below it (lazily, as in quotes); after a blank
1151/// line, another item continues the list and an indented line (2 spaces or
1152/// a tab) starts a second paragraph of the item. An item indented deeper
1153/// than the one before it starts a list inside that one. An ordered list
1154/// starts at 1 and has a second item: the model numbers from 1, and a
1155/// paragraph opening with a date ("3. Oktober", "1. Mai war …") stays a
1156/// paragraph. Items that count on from another number ("8.", "9.": a list
1157/// resumed after a paragraph) are [`ListRun::Numbered`], so they do not run
1158/// together; the next number is required, so a wrapped line starting with
1159/// a date ("24. Dezember") still continues a paragraph that opens with one.
1160fn 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                // The run ends at the first item at its depth that does not
1200                // count on; the main loop reads on from there.
1201                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
1223/// The items as paragraphs (line index, lines), each first line with its
1224/// marker as written.
1225fn 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
1241/// Whether a line ends a list item instead of continuing its text.
1242fn ends_item(line: &str) -> bool {
1243    starts_block(line) || line.starts_with('>')
1244}
1245
1246/// Nest `items` by indentation: the items after one that are indented
1247/// deeper form a list inside it. A change between ordered and unordered
1248/// items at the same depth starts a new list.
1249fn 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
1276/// Most paragraphs in a dedication before the first heading, and most
1277/// characters in all of them. Shorter than a .docx dedication page (600):
1278/// Markdown has no centring or page to go by.
1279const DEDICATION_PARAS: usize = 4;
1280const DEDICATION_CHARS: usize = 300;
1281
1282/// A table row: its line index and its cells.
1283type TableRow = (usize, Vec<String>);
1284
1285/// A pipe table as written: each column's alignment (from the delimiter
1286/// row, which is never text) and the rows, the header row first.
1287struct PipeTable {
1288    aligns: Vec<ColumnAlign>,
1289    rows: Vec<TableRow>,
1290}
1291
1292/// A Pandoc table caption, the text of a `Table: text` line.
1293#[derive(Clone, Copy)]
1294struct Caption<'a> {
1295    text: &'a str,
1296    /// Written above the table (else below it).
1297    above: bool,
1298    /// The index of the caption's line.
1299    line: usize,
1300}
1301
1302/// The text of a `Table: text` line, if it is one.
1303fn 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
1311/// A caption above a table: the `Table: text` line at `lines[i]`, then the
1312/// table, directly or after one blank line. Returns the caption, the table
1313/// and the index after the table. The line has to start a paragraph (the
1314/// caller checks), so prose cannot turn into a caption by accident.
1315fn 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
1333/// A caption below a table that ends before line `at`: a `Table: text` line
1334/// right there or after one blank line, alone in its paragraph (a blank line,
1335/// the end or a line that starts a block of its own after it) and not the
1336/// caption of the table that follows it.
1337/// Returns the caption and the index after its line.
1338fn 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
1360/// The alignment a delimiter cell (`:--`, `--:`, `:-:`, `---`) says, or
1361/// `None` when the cell is not a delimiter.
1362fn 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
1377/// The pipe table (GFM) starting at `lines[start]` and the index of the
1378/// first line after it, or `None`. A table is a header row, a delimiter row
1379/// with as many cells (`|:--|--:|`, never text), then rows up to a blank
1380/// line, a line without `|` or another block. It may follow a paragraph line
1381/// directly: nothing in prose looks like a delimiter row.
1382fn 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
1408/// Whether a line has a `|` that is not escaped (`\|`).
1409fn is_table_row(line: &str) -> bool {
1410    line.char_indices()
1411        .any(|(i, c)| c == '|' && !line[..i].ends_with('\\'))
1412}
1413
1414/// A table row's cells, trimmed, without the empty ones the outer pipes
1415/// make; `\|` is a `|` in a cell.
1416fn 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
1440/// An image line, `![alt](path "caption")`, split into its parts.
1441struct ImageLine<'s> {
1442    alt: &'s str,
1443    path: &'s str,
1444    caption: Option<&'s str>,
1445    /// The text inside a trailing `{…}`, empty when there is none.
1446    attrs: &'s str,
1447}
1448
1449/// A line that is one image and nothing else. The path may be wrapped in
1450/// `<…>` and may contain spaces (authors write `my map.png`); a Pandoc
1451/// attribute block after it (`{width=50%}`, `{.beside .left}`) is read by
1452/// `image_layout`.
1453fn image_line(line: &str) -> Option<ImageLine<'_>> {
1454    let rest = line.strip_prefix("![")?;
1455    let (alt, rest) = rest.split_once("](")?;
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
1482/// A picture's size and placement from the attributes after it. Reads
1483/// `width=50%` (or `width=50`; rounded to 5, clamped to 10–100; anything
1484/// else is ignored), `.beside` with `.outside`, `.inside`, `.left` or
1485/// `.right`, `.page` and `.bleed`; other attributes are ignored. When
1486/// several placements are named the widest claim wins: bleed, page, beside.
1487/// A side without `.beside` and a width on a bleed picture have no effect
1488/// and are not kept.
1489fn 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/// What a fenced div holds.
1534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1535enum Fence {
1536    Verse,
1537    Epigraph,
1538}
1539
1540/// `:::verse`, also `::: verse`, `:::: Verse ::::` (Pandoc-style fenced
1541/// div), or the same with `epigraph`.
1542fn 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
1557/// The lines (as written, not trimmed) inside the fence opening at
1558/// `lines[start]`, the index of the first line after it, and whether its
1559/// `:::` was found. A fence without its `:::` ends before the next heading,
1560/// or at the end (the caller warns): the rest of the book would otherwise be
1561/// read into it.
1562fn 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
1579/// Columns a verse line is indented by: a space is one, a tab four (so a tab
1580/// is one level), any other white space one.
1581fn 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
1588/// Spaces that make one verse indent level. Four, not two: an editor that
1589/// keeps the previous line's indent, or a hand-wrapped line, leaves one or
1590/// two stray spaces that must not become an indent; four is a tab's width
1591/// and what Gutenberg's plain text uses for Carroll's verse.
1592const SPACES_PER_LEVEL: usize = 4;
1593
1594/// The indent level of each `(leading columns, text)` verse line: the
1595/// columns beyond the least-indented non-blank line (a verse typed with all
1596/// its lines indented is flush, as in a code block), one level per
1597/// [`SPACES_PER_LEVEL`], at most [`MAX_VERSE_INDENT`]. Blank lines are 0.
1598fn 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
1617/// `\newpage` or `\pagebreak` alone on a line: LaTeX, as Pandoc users write
1618/// it (Pandoc's page-break filters read it too).
1619fn is_page_break(line: &str) -> bool {
1620    matches!(line, "\\newpage" | "\\pagebreak")
1621}
1622
1623/// `:::` (or more colons) alone closes a verse fence.
1624fn is_fence_end(line: &str) -> bool {
1625    line.len() >= 3 && line.bytes().all(|b| b == b':')
1626}
1627
1628/// A line without its trailing `\` (a hard line break), and whether it had
1629/// one.
1630fn 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
1637/// The lines of one paragraph joined with spaces; after a line ending in
1638/// `\` comes a `\n` instead, which [`parse_inlines`] makes a line break.
1639/// On the last line the `\` is dropped: there is nothing to break before.
1640fn 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
1652/// Parse `*em*`, `_em_`, `**strong**`, `[small caps]{.smallcaps}`, `^sup^`,
1653/// `~sub~`, links `[text](url)` and `<https://…>`, footnote marks `[^label]`
1654/// and line breaks (`\n`, from [`join_lines`]). Unbalanced markers and marks
1655/// without a definition stay literal.
1656fn 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                // The mark sits on the word: a space typed before it would
1678                // separate the superscript from the word.
1679                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        // A Pandoc span, `[text]{.smallcaps}`; other attributes are dropped
1688        // and the text kept.
1689        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        // A link, `[text](url "title")`; the title is dropped.
1718        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        // An autolink, `<https://…>` or `<mailto:…>`.
1735        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        // `^sup^` and `~sub~` (Pandoc); doubled markers stay text.
1751        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
1800/// The index of the marker that closes `^…^` or `~…~` opened at `open`.
1801/// The text between is not empty and has no whitespace, so `~50 km` and
1802/// `a ~ b` stay text.
1803fn 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
1811/// Whether the `[` at `i` can open a span or a link: not a footnote mark
1812/// `[^…]`, and not `![…]` (an image inside a sentence stays text).
1813fn opens_bracket(chars: &[char], i: usize) -> bool {
1814    chars[i] == '[' && chars.get(i + 1) != Some(&'^') && (i == 0 || chars[i - 1] != '!')
1815}
1816
1817/// The index of the `]` or `)` that closes the `[` or `(` at `open`, past
1818/// nested pairs (`[a [b]]`, `(https://…/Rope_(knot))`).
1819fn 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
1839/// The URL of a link target, `url "title"` or `<url with spaces>`, or
1840/// `None` if there is none.
1841fn 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
1850/// `https://…`, `http://…` or `mailto:…` with no whitespace.
1851fn 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    // Must be followed by a non-space and, for `_`, not inside a word.
1860    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        // For single `*`, a `**` is strong inside the emphasis: skip both
1870        // stars, wherever it stands (`*a **b** c*`).
1871        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    /// Import without any image files.
1896    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        // Lines of one paragraph are joined with a space.
1923        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        // Trailing scene break removed.
1931        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        // Up to four paragraphs; markup inside one is kept.
1969        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        // Not a dedication: long, or after other text, or not at the start.
1990        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        // Strong inside emphasis, also at its end (`***`).
2071        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] ![a](b){.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                // Other spans keep their text; brackets without `{…}` and
2111                // images are not spans.
2112                Inline::text(", unterstrichen leer [sic] ![a](b){.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>, ![bild](b.png)",
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                // Empty text shows the address.
2176                link("https://x.example/a b", vec![t("https://x.example/a b")]),
2177                t(", [kein](), [Text] (nicht), <kein link>, ![bild](b.png)"),
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                // A bare `##` is a scene break; seven hashes or no space
2205                // after them are text.
2206                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                // A `>` line ends the paragraph before it.
2241                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                // A blank line starts a new quote; a scene break is not
2253                // lazily continued; bare `>` lines alone are no quote.
2254                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                // A list never interrupts a paragraph: hard-wrapped text can
2272                // put a spaced dash at the start of a line.
2273                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                // Ordered after unordered is a new list.
2308                Block::list(true, vec![vec![para("Eins")], vec![para("Zwei")]]),
2309                Block::block_quote(vec![para("Zitat")]),
2310                // Ordered lists start at 1 and have two items.
2311                para("3. Oktober ein Schwerkranker wartete auf mich."),
2312                para("1. Mai einen Wagen hatte ich."),
2313                para("Danach."),
2314                // Counting on from 7: numbered paragraphs, with a warning.
2315                para("7. Juli"),
2316                para("8. Juli"),
2317                // A wrapped line starting with a date that is not the next
2318                // number continues the paragraph.
2319                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        // Before the first heading, at the start and the end of a section
2331        // a break is dropped; repeated ones collapse; a quote is not
2332        // lazily continued; inside a line it is text.
2333        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                // The space before a mark is dropped; a mark inside a note
2382                // stays text; a plain line continues the definition.
2383                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        // `[^ y]` is no mark (space in the label); definitions are gone
2424        // from the text, so "Zwei" is empty.
2425        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                // Blank lines at the ends dropped, runs of them make one
2458                // stanza break; `\` and indentation dropped.
2459                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                // A fence is not lazily continued into a quote.
2480                Block::block_quote(vec![para("Zitat")]),
2481                Block::verse(vec![vec![Inline::text("Nun bin ich aber Arzt")]]),
2482                // An empty fence adds nothing; a stray `:::` stays text.
2483                para(":::"),
2484            ]
2485        );
2486    }
2487
2488    /// The indent levels of the first verse block of the first section, and
2489    /// its lines as plain text.
2490    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    // Carroll's "How doth the little crocodile" (Gutenberg #11): the
2504    // stanza the verse tests use, in the source's own four spaces.
2505    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        // The text is the line, trimmed.
2512        assert_eq!(text[1], "Improve his shining tail,");
2513        // A model with indents writes them; one without writes none.
2514        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        // 1-3 stray spaces are no indent, 4-7 one level, 8 two; a tab is
2529        // four spaces; mixing counts columns.
2530        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        // A verse typed with every line indented is flush, like a code
2549        // block; the levels are relative to the block's least-indented line
2550        // (here 2 columns), also across stanzas.
2551        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        // Blank lines (and a line that is only `\`) are stanza gaps, none at
2559        // the ends, and flush; the levels follow the lines kept. Thirty-six
2560        // columns would be nine levels: the model stops at six.
2561        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        // Before the first heading: a section of its own; markup in the
2588        // attribution is dropped.
2589        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        // A blank line is a line break; notes work.
2604        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        // A dash line alone is text; inside the chapter it is a quote.
2622        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                // Emphasis spans a break; a `\` on the last line is dropped.
2684                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![Die *Karte*](img/karte.png \"Der neue Advokat[^q], 1921\")\n\n![](<mein bild.jpeg>){width=50%}\n\n![Nochmal](img/karte.png)\n\n![a](b.png) 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        // Each path is asked for once; the text line is no image.
2736        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![Inline::text("![a](b.png) mit Text")]),
2763            ]
2764        );
2765    }
2766
2767    #[test]
2768    fn image_problems_warn() {
2769        let src = "Title: T\n\n# Eins\n\n![a](fehlt.png)\n\n![b](fehlt.png)\n\n![c](https://example.com/c.png)\n\n![d](alt.tiff)\n\n> Zitat\n![e](gross.png)\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        // A missing path warns once; a URL is not asked for.
2785        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        // Images that cannot be used are left out; an image line is not
2801        // lazily continued into a quote.
2802        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("![a](b.png)"), Some(("a", "b.png", None)));
2816        assert_eq!(
2817            parts("![a]( my file.png  \"Cap \"x\"\" )"),
2818            Some(("a", "my file.png", Some("Cap \"x\"")))
2819        );
2820        assert_eq!(parts("![a](b.png\"x\")"), Some(("a", "b.png\"x\"", None)));
2821        assert_eq!(
2822            parts("![a](<b c.png>){#fig .wide}"),
2823            Some(("a", "b c.png", None))
2824        );
2825        assert_eq!(parts("![a]()"), None);
2826        assert_eq!(parts("![a](b.png) 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    /// The tables of the first section, with the warnings of the import.
2844    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    /// A table's rows as the plain text of each cell.
2858    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        // Part of the header empty: still the header.
2908        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        // Empty cells beyond the header lose nothing: no warning.
2938        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    /// The caption of a table as plain text.
2993    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        // Above, directly or after a blank line; below, the same.
3001        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            // Part of a paragraph, not alone in it.
3020            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            // No text, or no space after the colon.
3024            format!("Table:\n\n{rows}"),
3025            format!("Table:Cap\n\n{rows}"),
3026            // Two blank lines away.
3027            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        // A caption above and one below: the table keeps the first, the
3044        // second is a paragraph.
3045        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        // A line of prose continues the paragraph, so it is no caption.
3080        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}