1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
//! Shared typst-prose helpers for the reader-exports.
//!
//! The ePub and audiobook exporters both pre-process a
//! paragraph body the same way before their format-
//! specific rendering: drop a leading `= …` org-title
//! heading, then split the remainder into blank-line-
//! separated blocks. Before 1.2.20 each carried its own
//! identical copy; this is now the one home.
//!
//! The book-assembly path (`crate::assemble`) keeps its
//! own heading strip — it matches any `=`-prefixed line
//! (including `==` section headings) and is whitespace-
//! aware, because it serves typst compilation rather than
//! reader-export prose.
/// Drop a leading level-1 `= …` heading (the org chapter
/// title) and the blank lines that follow it. A `==`
/// subheading is kept — it's content, not the title.
/// Bodies without a leading `= ` heading pass through
/// unchanged.
pub(crate) fn strip_leading_heading(body: &str) -> String {
let mut lines = body.lines();
if let Some(first) = lines.clone().next() {
if first.trim_start().starts_with("= ") {
lines.next();
let rest: Vec<&str> = lines.collect();
return rest.join("\n").trim_start_matches('\n').to_string();
}
}
body.to_string()
}
/// Split prose into blank-line-separated blocks, each a
/// paragraph. Leading/trailing blanks and runs of blank
/// lines collapse to block boundaries; whitespace-only
/// blocks are dropped.
pub(crate) fn split_blocks(s: &str) -> Vec<String> {
let mut blocks = Vec::new();
let mut cur = String::new();
for line in s.lines() {
if line.trim().is_empty() {
if !cur.trim().is_empty() {
blocks.push(std::mem::take(&mut cur));
}
} else {
if !cur.is_empty() {
cur.push('\n');
}
cur.push_str(line);
}
}
if !cur.trim().is_empty() {
blocks.push(cur);
}
blocks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_drops_leading_equals_heading() {
let body = "= 001. Approach\n\nHelena paused.";
assert_eq!(strip_leading_heading(body), "Helena paused.");
}
#[test]
fn strip_keeps_body_without_heading() {
let body = "Just prose here.";
assert_eq!(strip_leading_heading(body), "Just prose here.");
}
#[test]
fn strip_keeps_subheadings() {
// `==` is a subheading, not the org title — keep it.
let body = "== A scene\n\nProse.";
assert_eq!(strip_leading_heading(body), "== A scene\n\nProse.");
}
#[test]
fn split_blocks_separates_on_blank_lines() {
let s = "Para one.\nstill one.\n\n\nPara two.\n";
assert_eq!(
split_blocks(s),
vec![
"Para one.\nstill one.".to_string(),
"Para two.".to_string()
]
);
}
#[test]
fn split_blocks_empty_input_is_empty() {
assert!(split_blocks("").is_empty());
assert!(split_blocks(" \n\n ").is_empty());
}
}