#[derive(Debug, Clone)]
pub struct ManuscriptMeta {
pub title: String,
pub contact: String,
pub byline: String,
pub surname: String,
pub word_count: usize,
}
#[derive(Debug, Clone)]
pub struct ManuscriptChapter {
pub title: String,
pub paragraphs: Vec<String>,
}
pub fn round_word_count(n: usize) -> usize {
let step = if n >= 25_000 { 1000 } else { 100 };
((n + step / 2) / step) * step
}
pub fn header_keyword(title: &str) -> String {
let mut words = title.split_whitespace().peekable();
if let Some(first) = words.peek() {
let lc = first.to_lowercase();
if matches!(lc.as_str(), "the" | "a" | "an") {
words.next();
}
}
words
.next()
.map(|w| {
w.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
.to_uppercase()
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "MANUSCRIPT".to_string())
}
pub fn is_scene_break(text: &str) -> bool {
let trimmed = text.trim();
if trimmed == "§" {
return true;
}
let chars: Vec<char> =
trimmed.chars().filter(|c| !c.is_whitespace()).collect();
if chars.len() < 3 {
return false;
}
let first = chars[0];
if !"*-_~#".contains(first) {
return false;
}
chars.iter().all(|c| *c == first)
}
fn escape_typst(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@'
| '=' | '-' | '+' | '/' | '"' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
pub fn build_typst(meta: &ManuscriptMeta, chapters: &[ManuscriptChapter]) -> String {
let keyword = header_keyword(&meta.title);
let rounded = round_word_count(meta.word_count);
let mut s = String::new();
s.push_str("// Standard manuscript format (Shunn). Generated by inkhaven.\n");
s.push_str("#set text(font: (\"Courier New\", \"Courier\"), size: 12pt)\n");
s.push_str("#set par(leading: 1.5em, first-line-indent: 0.5in, justify: false, spacing: 1.5em)\n");
s.push_str("#set page(paper: \"us-letter\", margin: 1in)\n\n");
s.push_str("#[\n");
s.push_str(" #set par(first-line-indent: 0pt, leading: 0.65em, spacing: 0.65em)\n");
for line in meta.contact.lines() {
s.push_str(&format!(" {}\\\n", escape_typst(line.trim())));
}
s.push_str(&format!(
" #place(top + right)[about {} words]\n",
rounded,
));
s.push_str(" #v(3in)\n");
s.push_str(" #align(center)[\n");
s.push_str(&format!(" #upper[{}]\n\n", escape_typst(&meta.title)));
s.push_str(" by\n\n");
s.push_str(&format!(" {}\n", escape_typst(&meta.byline)));
s.push_str(" ]\n");
s.push_str("]\n");
s.push_str("#pagebreak()\n\n");
s.push_str(&format!(
"#set page(header: context {{\n if counter(page).get().first() > 1 {{\n align(right)[{} / {} / #counter(page).display()]\n }}\n}})\n\n",
escape_typst(&meta.surname),
escape_typst(&keyword),
));
for (i, ch) in chapters.iter().enumerate() {
if i > 0 {
s.push_str("#pagebreak()\n");
}
s.push_str("#v(12%)\n");
s.push_str(&format!(
"#align(center)[#upper[{}]]\n",
escape_typst(&ch.title),
));
s.push_str("#v(3em)\n\n");
for para in &ch.paragraphs {
if is_scene_break(para) {
s.push_str("#align(center)[\\#]\n\n");
} else {
s.push_str(&escape_typst(para.trim()));
s.push_str("\n\n");
}
}
}
s.push_str("#align(center)[\\# \\# \\#]\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_short_to_nearest_hundred() {
assert_eq!(round_word_count(3470), 3500);
assert_eq!(round_word_count(3449), 3400);
assert_eq!(round_word_count(50), 100);
assert_eq!(round_word_count(149), 100);
}
#[test]
fn rounds_novel_to_nearest_thousand() {
assert_eq!(round_word_count(82_400), 82_000);
assert_eq!(round_word_count(82_600), 83_000);
assert_eq!(round_word_count(25_000), 25_000);
}
#[test]
fn keyword_drops_leading_article() {
assert_eq!(header_keyword("The Harbor Code"), "HARBOR");
assert_eq!(header_keyword("An Inheritance of Salt"), "INHERITANCE");
assert_eq!(header_keyword("A Quiet Geometry"), "QUIET");
}
#[test]
fn keyword_handles_no_article() {
assert_eq!(header_keyword("Beneath the Slate Roofs"), "BENEATH");
}
#[test]
fn keyword_strips_punctuation() {
assert_eq!(header_keyword("\"Quoted\" Title"), "QUOTED");
}
#[test]
fn keyword_fallback_when_empty() {
assert_eq!(header_keyword(""), "MANUSCRIPT");
assert_eq!(header_keyword("The"), "MANUSCRIPT");
}
#[test]
fn detects_scene_breaks() {
assert!(is_scene_break("* * *"));
assert!(is_scene_break("***"));
assert!(is_scene_break("---"));
assert!(is_scene_break("# # #"));
assert!(is_scene_break("§"));
assert!(is_scene_break("~~~"));
}
#[test]
fn rejects_non_scene_breaks() {
assert!(!is_scene_break("Helena paused."));
assert!(!is_scene_break("--")); assert!(!is_scene_break(""));
assert!(!is_scene_break("a-b-c")); }
#[test]
fn escapes_typst_markup() {
let e = escape_typst("a #b *c* _d_ $e$");
assert!(e.contains("\\#"));
assert!(e.contains("\\*"));
assert!(e.contains("\\_"));
assert!(e.contains("\\$"));
}
fn sample() -> (ManuscriptMeta, Vec<ManuscriptChapter>) {
let meta = ManuscriptMeta {
title: "The Harbor Code".into(),
contact: "Jane Author\n12 Wharf Lane\njane@example.com".into(),
byline: "Jane Author".into(),
surname: "Author".into(),
word_count: 82_417,
};
let chapters = vec![
ManuscriptChapter {
title: "Arrivals".into(),
paragraphs: vec![
"Helena paused at the threshold.".into(),
"* * *".into(),
"Marcus waited below.".into(),
],
},
ManuscriptChapter {
title: "The Wharf".into(),
paragraphs: vec!["The tide had turned.".into()],
},
];
(meta, chapters)
}
#[test]
fn typst_has_shunn_essentials() {
let (m, c) = sample();
let out = build_typst(&m, &c);
assert!(out.contains("Courier"));
assert!(out.contains("leading: 1.5em"));
assert!(out.contains("margin: 1in"));
assert!(out.contains("about 82000 words"));
assert!(out.contains("#upper[The Harbor Code]"));
assert!(out.contains("by"));
assert!(out.contains("Author / HARBOR /"));
assert!(out.contains("#upper[Arrivals]"));
assert!(out.contains("#upper[The Wharf]"));
assert!(out.contains("#pagebreak()"));
assert!(out.contains("#align(center)[\\#]"));
assert!(out.contains("\\# \\# \\#"));
}
#[test]
fn typst_escapes_prose_markup() {
let meta = ManuscriptMeta {
title: "Test".into(),
contact: "X".into(),
byline: "X".into(),
surname: "X".into(),
word_count: 100,
};
let chapters = vec![ManuscriptChapter {
title: "One".into(),
paragraphs: vec!["A #hashtag and *stars* here.".into()],
}];
let out = build_typst(&meta, &chapters);
assert!(out.contains("\\#hashtag"));
assert!(out.contains("\\*stars\\*"));
}
}