perfectstar2k 0.2.0

A modern TUI homage to WordStar & WordPerfect for DOS — a real writing tool built on WordStar's touch-typist command language and long-hand-page metaphor.
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Standard-manuscript-format (SMF) RTF export (`^KM`).
//!
//! Renders the document to the format literary agents and editors expect for
//! fiction submissions (per William Shunn's canonical guide): 12pt serif,
//! double-spaced, 1-inch margins, first-line-indented paragraphs with no blank
//! line between them, left-aligned, chapters (`#` headings) on fresh pages,
//! `*italic*`/`**bold**` rendered as real emphasis (not literal asterisks),
//! straight quotes/dashes upgraded to typographic ones.
//!
//! RTF is plain-ASCII, brace-delimited control words, so the whole file is
//! generated by hand with no dependencies. Unlike `^KE` (`export_clean`),
//! this strips Markdown markup and produces submission-ready formatting.

use crate::buffer::Buffer;
use crate::markdown::{self, MdKind};

/// The serif body font a manuscript export uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManuscriptFont {
    TimesNewRoman,
    Courier,
}

impl ManuscriptFont {
    /// The `\fN` index in the RTF font table (see `render`'s header).
    fn font_index(self) -> u8 {
        match self {
            ManuscriptFont::TimesNewRoman => 0,
            ManuscriptFont::Courier => 1,
        }
    }
}

/// Per-character emphasis, tracked so we can emit `\b`/`\i` transitions only
/// at run boundaries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Emphasis {
    bold: bool,
    italic: bool,
    /// Inline `code`: rendered as plain text, but exempt from smart typography
    /// so literal characters (quotes, dashes) survive verbatim.
    code: bool,
}

impl Emphasis {
    const PLAIN: Emphasis = Emphasis {
        bold: false,
        italic: false,
        code: false,
    };
}

const BLANK_LINES_BEFORE_CHAPTER: usize = 9;

/// Render the whole document as SMF RTF.
pub fn render(buf: &Buffer, font: ManuscriptFont) -> String {
    let f = font.font_index();
    let mut out = String::new();
    out.push_str("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\n");
    out.push_str(
        "{\\fonttbl{\\f0\\froman\\fcharset0 Times New Roman;}\
         {\\f1\\fmodern\\fcharset0 Courier New;}}\n",
    );
    out.push_str("\\viewkind4\\uc1\n");
    out.push_str("\\margl1440\\margr1440\\margt1440\\margb1440\n");

    // Full paragraph reset, restated for every paragraph, so nothing depends
    // on inherited state across `\page` breaks.
    let body_para = format!("\\pard\\plain\\f{f}\\fs24\\ql\\sl480\\slmult1\\fi720 ");

    // Whether we've emitted any paragraph yet — the first `#` heading must not
    // be preceded by a `\page` (that would produce a blank leading page).
    let mut first = true;

    for line in 0..buf.len_lines() {
        let text = buf.line_text(line);

        // Note-to-self lines never reach a reader (matches `export_clean`).
        if text.trim_start().starts_with("..") {
            continue;
        }
        // Blank lines are paragraph separators in the source — never emit an
        // empty manuscript paragraph for them.
        if text.trim().is_empty() {
            continue;
        }

        if let Some((level, title)) = markdown::heading_level(&text) {
            if level == 1 {
                if !first {
                    out.push_str("\\page\n");
                }
                for _ in 0..BLANK_LINES_BEFORE_CHAPTER {
                    out.push_str(&format!("\\pard\\plain\\f{f}\\fs24\\sl480\\slmult1\\par\n"));
                }
                out.push_str(&format!("\\pard\\plain\\qc\\b\\f{f}\\fs24 "));
                out.push_str(&escape_rtf(&title));
                out.push_str("\\b0\\par\n");
                first = false;
                continue;
            }
            // Sub-headings (## and deeper): a bold, indented body paragraph —
            // no page break, no centering.
            out.push_str(&body_para);
            out.push_str("\\b ");
            out.push_str(&escape_rtf(&title));
            out.push_str("\\b0\\par\n");
            first = false;
            continue;
        }

        // Ordinary body paragraph.
        out.push_str(&body_para);
        out.push_str(&render_line(&text));
        out.push_str("\\par\n");
        first = false;
    }

    out.push('}');
    out
}

/// Render one body line: strip Markdown markers, apply smart typography, and
/// emit `\b`/`\i` emphasis runs with every character RTF-escaped.
fn render_line(text: &str) -> String {
    let chars: Vec<char> = text.chars().collect();

    // Tag each source char with its emphasis; Marker spans (the literal
    // `**`/`*`/backtick/`#` punctuation) are dropped entirely.
    let mut tagged: Vec<(char, Emphasis)> = Vec::with_capacity(chars.len());
    let spans = markdown::scan_line(text);
    for (i, &c) in chars.iter().enumerate() {
        let mut emph = Emphasis::PLAIN;
        let mut is_marker = false;
        for &(s, e, kind) in &spans {
            if i >= s && i < e {
                match kind {
                    MdKind::Marker => is_marker = true,
                    MdKind::Bold => emph.bold = true,
                    MdKind::Italic => emph.italic = true,
                    MdKind::Code => emph.code = true,
                    MdKind::Heading => {}
                }
            }
        }
        if !is_marker {
            tagged.push((c, emph));
        }
    }

    let typed = smart_typography(&tagged);

    // Emit, tracking the active emphasis so control words appear only at run
    // boundaries.
    let mut out = String::new();
    let mut active = Emphasis::PLAIN;
    for &(c, emph) in &typed {
        if emph.bold != active.bold {
            out.push_str(if emph.bold { "\\b " } else { "\\b0 " });
        }
        if emph.italic != active.italic {
            out.push_str(if emph.italic { "\\i " } else { "\\i0 " });
        }
        active = emph;
        out.push_str(&escape_char(c));
    }
    // Close any still-open emphasis at end of paragraph.
    if active.bold {
        out.push_str("\\b0 ");
    }
    if active.italic {
        out.push_str("\\i0 ");
    }
    out
}

/// Upgrade straight ASCII punctuation to typographic characters, carrying each
/// replacement's emphasis from the first source char it consumes. Characters
/// tagged `code` are passed through untouched.
fn smart_typography(tagged: &[(char, Emphasis)]) -> Vec<(char, Emphasis)> {
    let mut out: Vec<(char, Emphasis)> = Vec::with_capacity(tagged.len());
    let mut i = 0;
    while i < tagged.len() {
        let (c, emph) = tagged[i];

        if emph.code {
            out.push((c, emph));
            i += 1;
            continue;
        }

        match c {
            '-' if run_len(tagged, i, '-') >= 2 => {
                // `--` (or more) → em dash.
                out.push(('\u{2014}', emph));
                i += run_len(tagged, i, '-');
            }
            '.' if run_len(tagged, i, '.') >= 3 => {
                // `...` → ellipsis (exactly three; extra dots stay literal).
                out.push(('\u{2026}', emph));
                i += 3;
            }
            '"' => {
                let open = i == 0 || opens_quote(tagged[i - 1].0);
                out.push((if open { '\u{201C}' } else { '\u{201D}' }, emph));
                i += 1;
            }
            '\'' => {
                let open = i == 0 || opens_quote(tagged[i - 1].0);
                out.push((if open { '\u{2018}' } else { '\u{2019}' }, emph));
                i += 1;
            }
            _ => {
                out.push((c, emph));
                i += 1;
            }
        }
    }
    out
}

/// Whether a preceding character means the next quote opens (rather than
/// closes / is an apostrophe).
fn opens_quote(prev: char) -> bool {
    prev.is_whitespace() || matches!(prev, '(' | '[' | '{' | '\u{2014}' | '\u{2013}')
}

/// Length of the run of `target` starting at `i`.
fn run_len(tagged: &[(char, Emphasis)], i: usize, target: char) -> usize {
    let mut n = 0;
    while i + n < tagged.len() && tagged[i + n].0 == target {
        n += 1;
    }
    n
}

/// RTF-escape one already-typography-processed character.
fn escape_char(c: char) -> String {
    match c {
        '\\' => "\\\\".to_string(),
        '{' => "\\{".to_string(),
        '}' => "\\}".to_string(),
        '\t' => "\\tab ".to_string(),
        c if (c as u32) < 0x80 => c.to_string(),
        // NOTE: astral characters (>= U+10000) would require a UTF-16
        // surrogate pair of `\uN` escapes. They never occur in English prose
        // manuscripts, so we degrade to a single `?` rather than implement
        // surrogate emission.
        c if (c as u32) >= 0x10000 => "?".to_string(),
        c => {
            let cp = c as u32;
            // `\uN` takes a *signed* 16-bit integer; values above 0x7FFF are
            // written as their negative two's-complement form.
            let signed = if cp > 0x7FFF {
                cp as i32 - 0x10000
            } else {
                cp as i32
            };
            // Exactly one ASCII fallback char follows (matches `\uc1`).
            format!("\\u{signed} {}", ascii_fallback(c))
        }
    }
}

/// A single ASCII stand-in for a non-ASCII char, for readers that ignore
/// `\uN`. One character only, to keep `\uc1` skip counting correct.
fn ascii_fallback(c: char) -> char {
    match c {
        '\u{2018}' | '\u{2019}' => '\'',
        '\u{201C}' | '\u{201D}' => '"',
        '\u{2014}' | '\u{2013}' => '-',
        '\u{2026}' => '.',
        _ => '?',
    }
}

/// Escape a whole plain string (used for heading titles, which carry no
/// inline emphasis or smart-typography of their own — a title is verbatim).
fn escape_rtf(text: &str) -> String {
    let mut out = String::new();
    for c in text.chars() {
        out.push_str(&escape_char(c));
    }
    out
}

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

    fn buf(text: &str) -> Buffer {
        let mut b = Buffer::open(None).expect("empty buffer");
        b.insert(0, text);
        b
    }

    #[test]
    fn escapes_backslash_and_braces() {
        assert_eq!(escape_rtf("a\\b{c}d"), "a\\\\b\\{c\\}d");
    }

    #[test]
    fn escapes_curly_quote_with_single_fallback() {
        // U+2019 = 8217, fallback a single apostrophe.
        assert_eq!(escape_char('\u{2019}'), "\\u8217 '");
        // U+201C = 8220, fallback a single double-quote.
        assert_eq!(escape_char('\u{201C}'), "\\u8220 \"");
        // Em dash U+2014 = 8212, single hyphen fallback.
        assert_eq!(escape_char('\u{2014}'), "\\u8212 -");
    }

    #[test]
    fn astral_char_degrades_to_question_mark() {
        assert_eq!(escape_char('\u{1F600}'), "?");
    }

    #[test]
    fn plain_ascii_passes_through() {
        assert_eq!(escape_rtf("Hello, world."), "Hello, world.");
    }

    #[test]
    fn bold_and_italic_become_control_words_markers_gone() {
        let line = render_line("a **b** and *c*");
        // No literal asterisks survive.
        assert!(!line.contains('*'), "markers leaked: {line}");
        assert!(line.contains("\\b b\\b0"), "bold missing: {line}");
        assert!(line.contains("\\i c\\i0"), "italic missing: {line}");
    }

    #[test]
    fn code_span_is_plain_text_no_font_switch() {
        let line = render_line("run `x` now");
        assert!(!line.contains('`'), "backticks leaked: {line}");
        assert!(!line.contains("\\f1"), "code got a font switch: {line}");
        assert!(line.contains('x'));
    }

    #[test]
    fn smart_quotes_and_em_dash() {
        // Opening double quote, apostrophe, em dash, closing double quote.
        let line = render_line("\"It's fine--really.\"");
        assert!(line.contains("\\u8220 "), "opening quote missing: {line}");
        assert!(line.contains("\\u8217 "), "apostrophe missing: {line}");
        assert!(line.contains("\\u8212 "), "em dash missing: {line}");
        assert!(line.contains("\\u8221 "), "closing quote missing: {line}");
    }

    #[test]
    fn code_span_keeps_straight_quotes() {
        let line = render_line("`it's` literal");
        // The apostrophe inside code stays straight (no \u escape for it).
        assert!(line.contains("it's"), "code apostrophe was smartened: {line}");
    }

    #[test]
    fn level1_heading_page_break_but_not_first() {
        let doc = buf("# Chapter One\nProse.\n# Chapter Two\nMore.\n");
        let rtf = render(&doc, ManuscriptFont::TimesNewRoman);
        // Exactly one \page — before Chapter Two, not Chapter One.
        assert_eq!(rtf.matches("\\page").count(), 1);
        let ch1 = rtf.find("Chapter One").unwrap();
        let page = rtf.find("\\page").unwrap();
        let ch2 = rtf.find("Chapter Two").unwrap();
        assert!(ch1 < page && page < ch2);
    }

    #[test]
    fn sub_heading_is_bold_paragraph_no_page_break() {
        let doc = buf("# Chapter\nBody.\n## A Scene\nMore body.\n");
        let rtf = render(&doc, ManuscriptFont::TimesNewRoman);
        // Only the chapter causes a page break; the scene does not add one.
        assert_eq!(rtf.matches("\\page").count(), 0); // first heading, no page
        assert!(rtf.contains("\\b A Scene\\b0"), "scene not bold: {rtf}");
    }

    #[test]
    fn note_lines_contribute_nothing() {
        let doc = buf(".. fix this later\nReal prose.\n");
        let rtf = render(&doc, ManuscriptFont::TimesNewRoman);
        assert!(!rtf.contains("fix this later"));
        assert!(rtf.contains("Real prose."));
    }

    #[test]
    fn document_is_brace_balanced() {
        let doc = buf("# Title\nText with a \\ and { and } in it.\n");
        let rtf = render(&doc, ManuscriptFont::TimesNewRoman);
        assert!(rtf.starts_with("{\\rtf1"));
        assert!(rtf.ends_with('}'));
        // User braces are escaped as \{ \} — confirm that happened, then count
        // only *structural* braces (drop the escaped literals first).
        assert!(rtf.contains("\\{ and \\}"), "user braces not escaped: {rtf}");
        let structural = rtf.replace("\\{", "").replace("\\}", "");
        let opens = structural.matches('{').count();
        let closes = structural.matches('}').count();
        assert_eq!(opens, closes, "unbalanced structural braces");
    }

    #[test]
    fn courier_selects_font_one() {
        let doc = buf("Plain line.\n");
        let rtf = render(&doc, ManuscriptFont::Courier);
        assert!(rtf.contains("\\f1\\fs24"));
    }
}