inkhaven 1.3.8

Inkhaven — TUI literary work editor for Typst books
//! 1.2.19+ X.1 — standard manuscript-format export.
//!
//! Emits a submission-ready document in **Shunn standard
//! manuscript format** — the layout agents + editors
//! expect: a monospace, double-spaced typst document with
//! a title page (contact top-left, rounded word count
//! top-right, title a third of the way down), a running
//! `Surname / KEYWORD / page` header, each chapter
//! starting a fresh page, paragraph indents, and scene
//! breaks as a centred `#`.
//!
//! The reader-facing exports (ePub, audiobook) target
//! consumption; this targets submission.  It compiles to
//! PDF through typst like every other inkhaven output.
//!
//! The pure pieces — word-count rounding, the header
//! keyword, scene-break detection, and the typst assembly
//! — are unit-tested; producing the PDF is a thin typst
//! call on the self-contained `.typ`.

/// Book-level metadata for the title page + header.
#[derive(Debug, Clone)]
pub struct ManuscriptMeta {
    pub title: String,
    /// Legal name + address block for the title-page
    /// contact corner (newline-separated lines).
    pub contact: String,
    /// Byline / pen name shown under the title.
    pub byline: String,
    /// Surname for the running header.
    pub surname: String,
    /// Exact manuscript word count (rounded for display).
    pub word_count: usize,
}

/// One chapter: title + its paragraphs (plain prose, in
/// order; a scene-break paragraph is its marker line).
#[derive(Debug, Clone)]
pub struct ManuscriptChapter {
    pub title: String,
    pub paragraphs: Vec<String>,
}

/// Round a word count the Shunn way: nearest 100 for
/// shorter work, nearest 1000 for novels (≥ 25 000).
pub fn round_word_count(n: usize) -> usize {
    let step = if n >= 25_000 { 1000 } else { 100 };
    ((n + step / 2) / step) * step
}

/// Derive the running-header keyword from the title: drop
/// a leading article, take the first significant word,
/// uppercase, strip punctuation.  "The Harbor Code" →
/// `HARBOR`; "An Inheritance of Salt" → `INHERITANCE`.
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())
}

/// True when a paragraph is *only* a scene-break marker:
/// 3+ copies of one of `*`, `-`, `_`, `~`, `#` (internal
/// single spaces allowed, so `* * *` and `***` both
/// match) or a lone `§`.  Rejects typst headings
/// (`= Foo`) and mixed content (`***bold***`).
///
/// The single home for scene-break detection: the
/// manuscript exporter renders a match as a centred `#`,
/// and the editor (`crate::tui::app`) uses the same
/// function for scene-break navigation.
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)
}

/// Escape the characters typst treats as markup so prose
/// renders literally.
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
}

/// Build the self-contained Shunn-format typst document.
/// Pure.
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();

    // ── global formatting: monospace, double-spaced ──
    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");

    // ── title page (no header, flush-left contact) ───
    s.push_str("#[\n");
    s.push_str("  #set par(first-line-indent: 0pt, leading: 0.65em, spacing: 0.65em)\n");
    // Contact block, top-left.
    for line in meta.contact.lines() {
        s.push_str(&format!("  {}\\\n", escape_typst(line.trim())));
    }
    // Word count, top-right.
    s.push_str(&format!(
        "  #place(top + right)[about {} words]\n",
        rounded,
    ));
    // Title, ~1/3 down, centred.
    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");

    // ── running header from page 2 on ────────────────
    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),
    ));

    // ── chapters ─────────────────────────────────────
    for (i, ch) in chapters.iter().enumerate() {
        if i > 0 {
            s.push_str("#pagebreak()\n");
        }
        // Chapter heading ~1/3 down the page, centred.
        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");
            }
        }
    }

    // ── end marker ───────────────────────────────────
    s.push_str("#align(center)[\\# \\# \\#]\n");

    s
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── round_word_count ──────────────────────────────

    #[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);
    }

    // ── header_keyword ────────────────────────────────

    #[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");
    }

    // ── is_scene_break ────────────────────────────────

    #[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("--")); // only 2
        assert!(!is_scene_break(""));
        assert!(!is_scene_break("a-b-c")); // mixed
    }

    // ── escape_typst ──────────────────────────────────

    #[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("\\$"));
    }

    // ── build_typst structure ─────────────────────────

    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);
        // Monospace + double-space + margins.
        assert!(out.contains("Courier"));
        assert!(out.contains("leading: 1.5em"));
        assert!(out.contains("margin: 1in"));
        // Title page: rounded word count + title + byline.
        assert!(out.contains("about 82000 words"));
        assert!(out.contains("#upper[The Harbor Code]"));
        assert!(out.contains("by"));
        // Running header keyword + surname.
        assert!(out.contains("Author / HARBOR /"));
        // Chapter headings + pagebreak between them.
        assert!(out.contains("#upper[Arrivals]"));
        assert!(out.contains("#upper[The Wharf]"));
        assert!(out.contains("#pagebreak()"));
        // Scene break rendered as centred #.
        assert!(out.contains("#align(center)[\\#]"));
        // End marker.
        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\\*"));
    }
}