perfectstar2k 0.4.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.
use std::io;

use super::zip::{self, Entry};
use super::{Block, CompiledDoc, Exporter, TextRun, escape_xml};

pub struct DocxExporter;

impl Exporter for DocxExporter {
    fn render(&self, doc: &CompiledDoc) -> io::Result<Vec<u8>> {
        let document = document_xml(doc);
        zip::archive(&[
            Entry {
                name: "[Content_Types].xml",
                data: CONTENT_TYPES.as_bytes(),
            },
            Entry {
                name: "_rels/.rels",
                data: RELS.as_bytes(),
            },
            Entry {
                name: "word/_rels/document.xml.rels",
                data: DOCUMENT_RELS.as_bytes(),
            },
            Entry {
                name: "word/document.xml",
                data: document.as_bytes(),
            },
            Entry {
                name: "word/styles.xml",
                data: STYLES.as_bytes(),
            },
        ])
    }
}

fn document_xml(doc: &CompiledDoc) -> String {
    let mut out = String::from(
        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>",
    );
    for block in &doc.blocks {
        match block {
            Block::Heading { level, runs } => {
                out.push_str(&format!(
                    "<w:p><w:pPr><w:pStyle w:val=\"Heading{level}\"/></w:pPr>"
                ));
                render_runs(&mut out, runs);
                out.push_str("</w:p>");
            }
            Block::Paragraph(runs) => {
                out.push_str("<w:p>");
                render_runs(&mut out, runs);
                out.push_str("</w:p>");
            }
            Block::PageBreak => out.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
            Block::Separator => {
                // Visual separator between compiled documents — an empty paragraph.
                out.push_str(
                    "<w:p><w:pPr><w:spacing w:before=\"240\" w:after=\"240\"/></w:pPr></w:p>",
                );
            }
        }
    }
    out.push_str("<w:sectPr><w:pgSz w:w=\"12240\" w:h=\"15840\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\"/></w:sectPr></w:body></w:document>");
    out
}

fn render_runs(out: &mut String, runs: &[TextRun]) {
    for run in runs {
        out.push_str("<w:r>");
        if run.bold || run.italic || run.code {
            out.push_str("<w:rPr>");
            if run.bold {
                out.push_str("<w:b/>");
            }
            if run.italic {
                out.push_str("<w:i/>");
            }
            if run.code {
                out.push_str("<w:rFonts w:ascii=\"Courier New\" w:hAnsi=\"Courier New\"/>");
            }
            out.push_str("</w:rPr>");
        }
        out.push_str("<w:t xml:space=\"preserve\">");
        out.push_str(&escape_xml(&run.text));
        out.push_str("</w:t></w:r>");
    }
}

const CONTENT_TYPES: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/><Override PartName=\"/word/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\"/></Types>";
const RELS: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/></Relationships>";
const DOCUMENT_RELS: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/></Relationships>";
const STYLES: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:uiPriority w:val=\"9\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"32\"/></w:rPr></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"28\"/></w:rPr></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading3\"><w:name w:val=\"heading 3\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"2\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"26\"/></w:rPr></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading4\"><w:name w:val=\"heading 4\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"3\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading5\"><w:name w:val=\"heading 5\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"4\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style><w:style w:type=\"paragraph\" w:styleId=\"Heading6\"><w:name w:val=\"heading 6\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:qFormat/><w:pPr><w:outlineLvl w:val=\"5\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style></w:styles>";

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

    #[test]
    fn document_xml_matches_golden() {
        let doc = CompiledDoc::from_text("# One\nA **bold** & *fine* -- line.\n.. note\n");
        assert_eq!(
            document_xml(&doc),
            include_str!("../../tests/fixtures/export/document.xml.golden")
        );
        assert!(
            DocxExporter
                .render(&doc)
                .unwrap()
                .starts_with(b"PK\x03\x04")
        );
    }
}