use crate::buffer::Buffer;
use crate::markdown::{self, MdKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManuscriptFont {
TimesNewRoman,
Courier,
}
impl ManuscriptFont {
fn font_index(self) -> u8 {
match self {
ManuscriptFont::TimesNewRoman => 0,
ManuscriptFont::Courier => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Emphasis {
bold: bool,
italic: bool,
code: bool,
}
impl Emphasis {
const PLAIN: Emphasis = Emphasis {
bold: false,
italic: false,
code: false,
};
}
const BLANK_LINES_BEFORE_CHAPTER: usize = 9;
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");
let body_para = format!("\\pard\\plain\\f{f}\\fs24\\ql\\sl480\\slmult1\\fi720 ");
let mut first = true;
for line in 0..buf.len_lines() {
let text = buf.line_text(line);
if text.trim_start().starts_with("..") {
continue;
}
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;
}
out.push_str(&body_para);
out.push_str("\\b ");
out.push_str(&escape_rtf(&title));
out.push_str("\\b0\\par\n");
first = false;
continue;
}
out.push_str(&body_para);
out.push_str(&render_line(&text));
out.push_str("\\par\n");
first = false;
}
out.push('}');
out
}
fn render_line(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
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);
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));
}
if active.bold {
out.push_str("\\b0 ");
}
if active.italic {
out.push_str("\\i0 ");
}
out
}
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 => {
out.push(('\u{2014}', emph));
i += run_len(tagged, i, '-');
}
'.' if run_len(tagged, i, '.') >= 3 => {
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
}
fn opens_quote(prev: char) -> bool {
prev.is_whitespace() || matches!(prev, '(' | '[' | '{' | '\u{2014}' | '\u{2013}')
}
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
}
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(),
c if (c as u32) >= 0x10000 => "?".to_string(),
c => {
let cp = c as u32;
let signed = if cp > 0x7FFF {
cp as i32 - 0x10000
} else {
cp as i32
};
format!("\\u{signed} {}", ascii_fallback(c))
}
}
}
fn ascii_fallback(c: char) -> char {
match c {
'\u{2018}' | '\u{2019}' => '\'',
'\u{201C}' | '\u{201D}' => '"',
'\u{2014}' | '\u{2013}' => '-',
'\u{2026}' => '.',
_ => '?',
}
}
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() {
assert_eq!(escape_char('\u{2019}'), "\\u8217 '");
assert_eq!(escape_char('\u{201C}'), "\\u8220 \"");
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*");
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() {
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");
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);
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);
assert_eq!(rtf.matches("\\page").count(), 0); 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('}'));
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"));
}
}