Initial commit: md-manuscript editor
Rust/egui desktop app: draggable markdown file list, editor, git sync, per-file chapter titles, and native ODT export. Includes README and Debian 12 (Surface) install guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+580
@@ -0,0 +1,580 @@
|
||||
//! Native OpenDocument Text (.odt) generation from markdown.
|
||||
//!
|
||||
//! An .odt file is a ZIP archive containing (at minimum):
|
||||
//! * `mimetype` - the media type, stored uncompressed and first
|
||||
//! * `META-INF/manifest.xml` - lists the archive members
|
||||
//! * `styles.xml` - named paragraph / text / list styles
|
||||
//! * `content.xml` - the document body
|
||||
//!
|
||||
//! We parse each markdown document with pulldown-cmark and emit ODF flow
|
||||
//! elements. Each source file becomes a chapter: a "Heading 1" with the chapter
|
||||
//! title, followed by the converted body (whose own headings are shifted down a
|
||||
//! level so they nest under the chapter heading).
|
||||
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
/// One chapter to export.
|
||||
pub struct Chapter {
|
||||
pub title: String,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
/// Escape text for use inside XML character data.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape text for use inside an XML attribute value.
|
||||
fn esc_attr(s: &str) -> String {
|
||||
let mut out = esc(s);
|
||||
out = out.replace('"', """);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which block-level element is currently open in the output stream.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Block {
|
||||
None,
|
||||
Para,
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
struct Converter {
|
||||
out: String,
|
||||
block: Block,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
strike: bool,
|
||||
code: bool,
|
||||
link: Option<String>,
|
||||
/// Stack of list markers: true = ordered, false = unordered.
|
||||
list_stack: Vec<bool>,
|
||||
/// True while inside a blockquote (affects paragraph style).
|
||||
quote_depth: u32,
|
||||
/// Buffer collecting the text of the current code block.
|
||||
code_block: Option<String>,
|
||||
/// Heading level offset so document headings nest under the chapter title.
|
||||
heading_offset: u8,
|
||||
}
|
||||
|
||||
impl Converter {
|
||||
fn new(heading_offset: u8) -> Self {
|
||||
Converter {
|
||||
out: String::new(),
|
||||
block: Block::None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
link: None,
|
||||
list_stack: Vec::new(),
|
||||
quote_depth: 0,
|
||||
code_block: None,
|
||||
heading_offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn para_style(&self) -> &'static str {
|
||||
if self.quote_depth > 0 {
|
||||
"Quotations"
|
||||
} else {
|
||||
"Text_20_body"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a block-level element is open to receive inline content.
|
||||
fn ensure_inline_target(&mut self) {
|
||||
if self.block == Block::None {
|
||||
let style = self.para_style();
|
||||
self.out
|
||||
.push_str(&format!("<text:p text:style-name=\"{style}\">"));
|
||||
self.block = Block::Para;
|
||||
}
|
||||
}
|
||||
|
||||
fn close_block(&mut self) {
|
||||
match self.block {
|
||||
Block::Para => self.out.push_str("</text:p>"),
|
||||
Block::Heading(_) => self.out.push_str("</text:h>"),
|
||||
Block::None => {}
|
||||
}
|
||||
self.block = Block::None;
|
||||
}
|
||||
|
||||
/// Emit an inline text run with the currently active character formatting.
|
||||
fn write_text(&mut self, text: &str) {
|
||||
self.ensure_inline_target();
|
||||
let escaped = esc(text);
|
||||
|
||||
// Choose a text (character) style for the active emphasis flags.
|
||||
let span_style = if self.code {
|
||||
Some("Source_20_Text")
|
||||
} else {
|
||||
match (self.bold, self.italic, self.strike) {
|
||||
(true, true, _) => Some("T_bolditalic"),
|
||||
(true, false, false) => Some("T_bold"),
|
||||
(false, true, false) => Some("T_italic"),
|
||||
(_, _, true) => Some("T_strike"),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let mut run = String::new();
|
||||
if let Some(style) = span_style {
|
||||
run.push_str(&format!("<text:span text:style-name=\"{style}\">"));
|
||||
run.push_str(&escaped);
|
||||
run.push_str("</text:span>");
|
||||
} else {
|
||||
run.push_str(&escaped);
|
||||
}
|
||||
|
||||
if let Some(href) = &self.link {
|
||||
self.out.push_str(&format!(
|
||||
"<text:a xlink:type=\"simple\" xlink:href=\"{}\">{run}</text:a>",
|
||||
esc_attr(href)
|
||||
));
|
||||
} else {
|
||||
self.out.push_str(&run);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_list(&mut self, ordered: bool) {
|
||||
self.close_block();
|
||||
let style = if ordered { "L_number" } else { "L_bullet" };
|
||||
self.out
|
||||
.push_str(&format!("<text:list text:style-name=\"{style}\">"));
|
||||
self.list_stack.push(ordered);
|
||||
}
|
||||
|
||||
fn end_list(&mut self) {
|
||||
self.close_block();
|
||||
self.out.push_str("</text:list>");
|
||||
self.list_stack.pop();
|
||||
}
|
||||
|
||||
fn flush_code_block(&mut self) {
|
||||
if let Some(buf) = self.code_block.take() {
|
||||
// Trailing newline from the fenced block is not a real line.
|
||||
let content = buf.strip_suffix('\n').unwrap_or(&buf);
|
||||
for line in content.split('\n') {
|
||||
self.out
|
||||
.push_str("<text:p text:style-name=\"Preformatted_Text\">");
|
||||
self.write_preformatted_line(line);
|
||||
self.out.push_str("</text:p>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a single code line, preserving runs of spaces and tabs.
|
||||
fn write_preformatted_line(&mut self, line: &str) {
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
' ' => {
|
||||
let mut count = 1;
|
||||
while chars.peek() == Some(&' ') {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
if count == 1 {
|
||||
self.out.push(' ');
|
||||
} else {
|
||||
self.out
|
||||
.push_str(&format!("<text:s text:c=\"{count}\"/>"));
|
||||
}
|
||||
}
|
||||
'\t' => self.out.push_str("<text:tab/>"),
|
||||
'&' => self.out.push_str("&"),
|
||||
'<' => self.out.push_str("<"),
|
||||
'>' => self.out.push_str(">"),
|
||||
_ => self.out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_number(&self, level: HeadingLevel) -> u8 {
|
||||
let base = match level {
|
||||
HeadingLevel::H1 => 1,
|
||||
HeadingLevel::H2 => 2,
|
||||
HeadingLevel::H3 => 3,
|
||||
HeadingLevel::H4 => 4,
|
||||
HeadingLevel::H5 => 5,
|
||||
HeadingLevel::H6 => 6,
|
||||
};
|
||||
(base + self.heading_offset).min(10)
|
||||
}
|
||||
|
||||
fn run(mut self, markdown: &str) -> String {
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(markdown, opts);
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => self.start_tag(tag),
|
||||
Event::End(tag) => self.end_tag(tag),
|
||||
Event::Text(t) => {
|
||||
if let Some(buf) = self.code_block.as_mut() {
|
||||
buf.push_str(&t);
|
||||
} else {
|
||||
self.write_text(&t);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
self.code = true;
|
||||
self.write_text(&t);
|
||||
self.code = false;
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
if self.code_block.is_some() {
|
||||
// handled via Text
|
||||
} else {
|
||||
self.write_text(" ");
|
||||
}
|
||||
}
|
||||
Event::HardBreak => {
|
||||
self.ensure_inline_target();
|
||||
self.out.push_str("<text:line-break/>");
|
||||
}
|
||||
Event::Rule => {
|
||||
self.close_block();
|
||||
self.out
|
||||
.push_str("<text:p text:style-name=\"Horizontal_Line\"></text:p>");
|
||||
}
|
||||
Event::Html(_) | Event::InlineHtml(_) => { /* skip raw HTML */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.close_block();
|
||||
self.out
|
||||
}
|
||||
|
||||
fn start_tag(&mut self, tag: Tag) {
|
||||
match tag {
|
||||
Tag::Paragraph => {
|
||||
// Lazily opened by the first inline content.
|
||||
self.close_block();
|
||||
}
|
||||
Tag::Heading { level, .. } => {
|
||||
self.close_block();
|
||||
let n = self.heading_number(level);
|
||||
self.out.push_str(&format!(
|
||||
"<text:h text:style-name=\"Heading_20_{n}\" text:outline-level=\"{n}\">"
|
||||
));
|
||||
self.block = Block::Heading(n);
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
self.close_block();
|
||||
self.quote_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
self.close_block();
|
||||
let _ = match kind {
|
||||
CodeBlockKind::Fenced(_) => (),
|
||||
CodeBlockKind::Indented => (),
|
||||
};
|
||||
self.code_block = Some(String::new());
|
||||
}
|
||||
Tag::List(first) => {
|
||||
self.start_list(first.is_some());
|
||||
}
|
||||
Tag::Item => {
|
||||
self.out.push_str("<text:list-item>");
|
||||
}
|
||||
Tag::Emphasis => self.italic = true,
|
||||
Tag::Strong => self.bold = true,
|
||||
Tag::Strikethrough => self.strike = true,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.link = Some(dest_url.to_string());
|
||||
}
|
||||
Tag::Image { title, dest_url, .. } => {
|
||||
// We do not embed images; surface a readable placeholder.
|
||||
let label = if !title.is_empty() {
|
||||
title.to_string()
|
||||
} else {
|
||||
dest_url.to_string()
|
||||
};
|
||||
self.write_text(&format!("[image: {label}]"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn end_tag(&mut self, tag: TagEnd) {
|
||||
match tag {
|
||||
TagEnd::Paragraph => self.close_block(),
|
||||
TagEnd::Heading(_) => self.close_block(),
|
||||
TagEnd::BlockQuote(_) => {
|
||||
self.close_block();
|
||||
if self.quote_depth > 0 {
|
||||
self.quote_depth -= 1;
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock => self.flush_code_block(),
|
||||
TagEnd::List(_) => self.end_list(),
|
||||
TagEnd::Item => {
|
||||
self.close_block();
|
||||
self.out.push_str("</text:list-item>");
|
||||
}
|
||||
TagEnd::Emphasis => self.italic = false,
|
||||
TagEnd::Strong => self.bold = false,
|
||||
TagEnd::Strikethrough => self.strike = false,
|
||||
TagEnd::Link => self.link = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one markdown document into an ODF body fragment, with headings shifted
|
||||
/// down by `heading_offset` levels.
|
||||
fn markdown_to_body(markdown: &str, heading_offset: u8) -> String {
|
||||
Converter::new(heading_offset).run(markdown)
|
||||
}
|
||||
|
||||
const MIMETYPE: &str = "application/vnd.oasis.opendocument.text";
|
||||
|
||||
fn manifest_xml() -> String {
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn styles_xml() -> String {
|
||||
// Heading sizes for the seven levels we may emit (chapter = 1).
|
||||
let sizes = [18, 16, 14, 13, 12, 11, 11, 11, 11, 11];
|
||||
let mut headings = String::new();
|
||||
for (i, size) in sizes.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
headings.push_str(&format!(
|
||||
"<style:style style:name=\"Heading_20_{n}\" style:display-name=\"Heading {n}\" \
|
||||
style:family=\"paragraph\" style:parent-style-name=\"Heading\" \
|
||||
style:next-style-name=\"Text_20_body\" style:default-outline-level=\"{n}\">\
|
||||
<style:text-properties fo:font-size=\"{size}pt\" fo:font-weight=\"bold\"/>\
|
||||
</style:style>"
|
||||
));
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
|
||||
<office:styles>
|
||||
<style:default-style style:family="paragraph">
|
||||
<style:paragraph-properties fo:hyphenation-ladder-count="no-limit"/>
|
||||
<style:text-properties fo:font-size="11pt" fo:language="en" fo:country="US"/>
|
||||
</style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph" style:class="text"/>
|
||||
<style:style style:name="Text_20_body" style:display-name="Text body" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0.1in"/>
|
||||
</style:style>
|
||||
<style:style style:name="Heading" style:family="paragraph" style:parent-style-name="Standard" style:next-style-name="Text_20_body" style:class="text">
|
||||
<style:paragraph-properties fo:margin-top="0.2in" fo:margin-bottom="0.08in" fo:keep-with-next="always"/>
|
||||
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
{headings}
|
||||
<style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-left="0.4in" fo:margin-right="0.4in" fo:margin-top="0.05in" fo:margin-bottom="0.05in"/>
|
||||
<style:text-properties fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="Preformatted_Text" style:display-name="Preformatted Text" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in"/>
|
||||
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Horizontal_Line" style:display-name="Horizontal Line" style:family="paragraph" style:parent-style-name="Standard" style:class="html">
|
||||
<style:paragraph-properties fo:margin-top="0.05in" fo:margin-bottom="0.1in" fo:border-bottom="0.5pt solid #808080" fo:padding="0in" style:join-border="false"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_bold" style:family="text">
|
||||
<style:text-properties fo:font-weight="bold"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_italic" style:family="text">
|
||||
<style:text-properties fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_bolditalic" style:family="text">
|
||||
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="T_strike" style:family="text">
|
||||
<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="single"/>
|
||||
</style:style>
|
||||
<style:style style:name="Source_20_Text" style:display-name="Source Text" style:family="text">
|
||||
<style:text-properties style:font-name="Liberation Mono" fo:font-family="'Liberation Mono'" style:font-family-generic="modit" fo:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Internet_20_Link" style:display-name="Internet Link" style:family="text">
|
||||
<style:text-properties fo:color="#0563c1" style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"/>
|
||||
</style:style>
|
||||
<text:list-style style:name="L_bullet">
|
||||
<text:list-level-style-bullet text:level="1" text:bullet-char="•"><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
<text:list-level-style-bullet text:level="2" text:bullet-char="◦"><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
<text:list-level-style-bullet text:level="3" text:bullet-char="▪"><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-bullet>
|
||||
</text:list-style>
|
||||
<text:list-style style:name="L_number">
|
||||
<text:list-level-style-number text:level="1" style:num-format="1" text:num-suffix="."><style:list-level-properties text:space-before="0.25in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
<text:list-level-style-number text:level="2" style:num-format="a" text:num-suffix="."><style:list-level-properties text:space-before="0.5in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
<text:list-level-style-number text:level="3" style:num-format="i" text:num-suffix="."><style:list-level-properties text:space-before="0.75in" text:min-label-width="0.25in"/></text:list-level-style-number>
|
||||
</text:list-style>
|
||||
</office:styles>
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn content_xml(chapters: &[Chapter]) -> String {
|
||||
let mut body = String::new();
|
||||
for chapter in chapters {
|
||||
// Chapter heading (always level 1); the document's own headings are
|
||||
// shifted down by one so they nest beneath it.
|
||||
body.push_str(&format!(
|
||||
"<text:h text:style-name=\"Heading_20_1\" text:outline-level=\"1\">{}</text:h>",
|
||||
esc(&chapter.title)
|
||||
));
|
||||
body.push_str(&markdown_to_body(&chapter.markdown, 1));
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
|
||||
<office:body>
|
||||
<office:text>
|
||||
{body}
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write the chapters to an .odt file at `out_path`.
|
||||
pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> {
|
||||
let file = std::fs::File::create(out_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
|
||||
// mimetype MUST be the first entry and stored without compression.
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
zip.start_file("mimetype", stored)
|
||||
.map_err(to_io)?;
|
||||
zip.write_all(MIMETYPE.as_bytes())?;
|
||||
|
||||
let deflated =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
zip.add_directory("META-INF/", deflated).map_err(to_io)?;
|
||||
zip.start_file("META-INF/manifest.xml", deflated)
|
||||
.map_err(to_io)?;
|
||||
zip.write_all(manifest_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("styles.xml", deflated).map_err(to_io)?;
|
||||
zip.write_all(styles_xml().as_bytes())?;
|
||||
|
||||
zip.start_file("content.xml", deflated).map_err(to_io)?;
|
||||
zip.write_all(content_xml(chapters).as_bytes())?;
|
||||
|
||||
zip.finish().map_err(to_io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_io(e: zip::result::ZipError) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_covers_common_markdown() {
|
||||
let md = "\
|
||||
Some **bold** and *italic* and ***both*** and `code` and ~~gone~~.
|
||||
|
||||
## A subheading
|
||||
|
||||
- one
|
||||
- two
|
||||
- nested
|
||||
|
||||
1. first
|
||||
2. second
|
||||
|
||||
> a quote
|
||||
|
||||
```
|
||||
let x = 1;
|
||||
indented();
|
||||
```
|
||||
|
||||
A [link](https://example.com) and a rule:
|
||||
|
||||
---
|
||||
|
||||
Done.";
|
||||
let body = markdown_to_body(md, 1);
|
||||
assert!(body.contains("<text:span text:style-name=\"T_bold\">bold</text:span>"));
|
||||
assert!(body.contains("<text:span text:style-name=\"T_italic\">italic</text:span>"));
|
||||
assert!(body.contains("T_bolditalic"));
|
||||
assert!(body.contains("Source_20_Text"));
|
||||
assert!(body.contains("T_strike"));
|
||||
// Heading level 2 in source shifts to Heading_20_3 (offset 1).
|
||||
assert!(body.contains("text:style-name=\"Heading_20_3\""));
|
||||
assert!(body.contains("<text:list text:style-name=\"L_bullet\">"));
|
||||
assert!(body.contains("<text:list text:style-name=\"L_number\">"));
|
||||
assert!(body.contains("Quotations"));
|
||||
assert!(body.contains("Preformatted_Text"));
|
||||
assert!(body.contains("xlink:href=\"https://example.com\""));
|
||||
assert!(body.contains("Horizontal_Line"));
|
||||
// spaces in indented code preserved
|
||||
assert!(body.contains("<text:s text:c=\"4\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_valid_archive() {
|
||||
let chapters = vec![
|
||||
Chapter {
|
||||
title: "Chapter One".to_string(),
|
||||
markdown: "Hello **world**.\n\n## Scene\n\nText.".to_string(),
|
||||
},
|
||||
Chapter {
|
||||
title: "Chapter Two".to_string(),
|
||||
markdown: "- a\n- b\n".to_string(),
|
||||
},
|
||||
];
|
||||
let dir = std::env::temp_dir();
|
||||
let out = dir.join("md_manuscript_test.odt");
|
||||
export(&chapters, &out).expect("export ok");
|
||||
|
||||
// Re-open the archive and check required members exist with mimetype first.
|
||||
let file = std::fs::File::open(&out).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(file).unwrap();
|
||||
assert_eq!(zip.file_names().next().unwrap(), "mimetype");
|
||||
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
|
||||
for required in ["mimetype", "META-INF/manifest.xml", "styles.xml", "content.xml"] {
|
||||
assert!(names.iter().any(|n| n == required), "missing {required}");
|
||||
}
|
||||
use std::io::Read;
|
||||
let mut content = String::new();
|
||||
zip.by_name("content.xml")
|
||||
.unwrap()
|
||||
.read_to_string(&mut content)
|
||||
.unwrap();
|
||||
assert!(content.contains(">Chapter One</text:h>"));
|
||||
assert!(content.contains(">Chapter Two</text:h>"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user