use common::types::EntityId;
use std::collections::HashMap;
pub(crate) fn xml_escape(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_str("""),
'\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' => {}
_ => out.push(c),
}
}
out
}
pub(crate) fn encode_run_text(text: &str) -> String {
let mut out = String::new();
let mut literal = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\n' => {
flush_literal(&mut out, &mut literal);
out.push_str("<text:line-break/>");
}
'\t' => {
flush_literal(&mut out, &mut literal);
out.push_str("<text:tab/>");
}
' ' => {
let mut count = 1usize;
while chars.peek() == Some(&' ') {
count += 1;
chars.next();
}
if count == 1 {
literal.push(' ');
} else {
flush_literal(&mut out, &mut literal);
out.push_str(&format!("<text:s text:c=\"{count}\"/>"));
}
}
_ => literal.push(c),
}
}
flush_literal(&mut out, &mut literal);
out
}
fn flush_literal(out: &mut String, literal: &mut String) {
if !literal.is_empty() {
out.push_str(&xml_escape(literal));
literal.clear();
}
}
const TWIPS_PER_PX: f64 = 15.0;
pub(crate) fn px_to_pt(px: i64) -> f64 {
(px as f64 * TWIPS_PER_PX) / 20.0
}
pub(crate) fn twips_to_pt(twips: i32) -> f64 {
twips as f64 / 20.0
}
pub(crate) fn half_points_to_pt(half_points: usize) -> f64 {
half_points as f64 / 2.0
}
pub(crate) fn fmt_pt(value: f64) -> String {
let mut s = format!("{value:.2}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s.push_str("pt");
s
}
pub(crate) const INDENT_STEP_PT: f64 = 36.0;
#[derive(Clone, PartialEq, Eq, Hash)]
struct StyleBody {
open_attrs: String,
inner: String,
}
impl StyleBody {
fn new(open_attrs: String, inner: String) -> Self {
Self { open_attrs, inner }
}
fn inner_only(inner: String) -> Self {
Self {
open_attrs: String::new(),
inner,
}
}
}
struct StyleBucket {
prefix: &'static str,
seen: HashMap<StyleBody, String>,
order: Vec<(String, StyleBody)>,
}
impl StyleBucket {
fn new(prefix: &'static str) -> Self {
Self {
prefix,
seen: HashMap::new(),
order: Vec::new(),
}
}
fn intern(&mut self, body: StyleBody) -> String {
if let Some(name) = self.seen.get(&body) {
return name.clone();
}
let name = format!("{}{}", self.prefix, self.order.len() + 1);
self.seen.insert(body.clone(), name.clone());
self.order.push((name.clone(), body));
name
}
}
#[derive(Default)]
pub(crate) struct OdtStyleSheet {
paragraph: Option<StyleBucket>,
text: Option<StyleBucket>,
table: Option<StyleBucket>,
table_column: Option<StyleBucket>,
table_cell: Option<StyleBucket>,
list_seen: HashMap<EntityId, String>,
list_styles: Vec<(String, String)>,
}
impl OdtStyleSheet {
fn paragraph_bucket(&mut self) -> &mut StyleBucket {
self.paragraph.get_or_insert_with(|| StyleBucket::new("P"))
}
fn text_bucket(&mut self) -> &mut StyleBucket {
self.text.get_or_insert_with(|| StyleBucket::new("T"))
}
fn table_bucket(&mut self) -> &mut StyleBucket {
self.table.get_or_insert_with(|| StyleBucket::new("Tbl"))
}
fn table_column_bucket(&mut self) -> &mut StyleBucket {
self.table_column
.get_or_insert_with(|| StyleBucket::new("TblCol"))
}
fn table_cell_bucket(&mut self) -> &mut StyleBucket {
self.table_cell
.get_or_insert_with(|| StyleBucket::new("TblCell"))
}
pub(crate) fn paragraph_style(
&mut self,
parent: &str,
para_attrs: &str,
text_attrs: &str,
) -> String {
if para_attrs.is_empty() && text_attrs.is_empty() {
return parent.to_string();
}
let mut inner = String::new();
if !para_attrs.is_empty() {
inner.push_str(&format!("<style:paragraph-properties {para_attrs}/>"));
}
if !text_attrs.is_empty() {
inner.push_str(&format!("<style:text-properties {text_attrs}/>"));
}
self.paragraph_bucket().intern(StyleBody::new(
format!("style:parent-style-name=\"{parent}\""),
inner,
))
}
pub(crate) fn text_style(&mut self, attrs: &str) -> String {
self.text_bucket().intern(StyleBody::inner_only(format!(
"<style:text-properties {attrs}/>"
)))
}
pub(crate) fn table_style(&mut self, attrs: &str) -> String {
self.table_bucket().intern(StyleBody::inner_only(format!(
"<style:table-properties {attrs}/>"
)))
}
pub(crate) fn table_column_style(&mut self, attrs: &str) -> String {
self.table_column_bucket()
.intern(StyleBody::inner_only(format!(
"<style:table-column-properties {attrs}/>"
)))
}
pub(crate) fn table_cell_style(&mut self, attrs: &str) -> String {
self.table_cell_bucket()
.intern(StyleBody::inner_only(format!(
"<style:table-cell-properties {attrs}/>"
)))
}
pub(crate) fn list_style(
&mut self,
list_id: EntityId,
build: impl FnOnce(&str) -> String,
) -> String {
if let Some(name) = self.list_seen.get(&list_id) {
return name.clone();
}
let name = format!("L{}", self.list_styles.len() + 1);
let xml = build(&name);
self.list_styles.push((name.clone(), xml));
self.list_seen.insert(list_id, name.clone());
name
}
pub(crate) fn automatic_styles_xml(&self) -> String {
let mut out = String::new();
let families: [(&Option<StyleBucket>, &str); 5] = [
(&self.paragraph, "paragraph"),
(&self.text, "text"),
(&self.table, "table"),
(&self.table_column, "table-column"),
(&self.table_cell, "table-cell"),
];
for (bucket, family) in families {
let Some(bucket) = bucket else { continue };
for (name, body) in &bucket.order {
let open_attrs = if body.open_attrs.is_empty() {
String::new()
} else {
format!(" {}", body.open_attrs)
};
out.push_str(&format!(
"<style:style style:name=\"{name}\" style:family=\"{family}\"{open_attrs}>{}</style:style>",
body.inner
));
}
}
for (_, xml) in &self.list_styles {
out.push_str(xml);
}
out
}
}
pub(crate) fn named_styles_xml(
options: &common::parser_tools::OdtExportOptions,
heading_styles: &[common::parser_tools::OdtHeadingStyle],
) -> String {
let mut out = String::new();
let mut standard_text_attrs = String::new();
if let Some(family) = &options.font_family {
standard_text_attrs.push_str(&format!(" style:font-name=\"{}\"", xml_escape(family)));
}
if let Some(hp) = options.font_half_points {
standard_text_attrs.push_str(&format!(
" fo:font-size=\"{}\"",
fmt_pt(half_points_to_pt(hp))
));
}
out.push_str(&format!(
"<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\">\
<style:text-properties{standard_text_attrs}/></style:style>"
));
out.push_str(&format!(
"<style:default-style style:family=\"paragraph\"><style:text-properties{standard_text_attrs}/></style:default-style>"
));
for (i, h) in heading_styles.iter().enumerate() {
let level = i + 1;
let mut para_attrs = String::new();
if let Some(before) = h.space_before_twips {
para_attrs.push_str(&format!(
" fo:margin-top=\"{}\"",
fmt_pt(twips_to_pt(before))
));
}
if let Some(after) = h.space_after_twips {
para_attrs.push_str(&format!(
" fo:margin-bottom=\"{}\"",
fmt_pt(twips_to_pt(after))
));
}
if h.keep_with_next {
para_attrs.push_str(" fo:keep-with-next=\"always\"");
}
if h.page_break_before {
para_attrs.push_str(" fo:break-before=\"page\"");
}
if let Some(a) = &h.alignment {
para_attrs.push_str(&format!(" fo:text-align=\"{}\"", odf_align(a)));
}
let mut text_attrs = String::new();
if let Some(size) = h.size_half_points {
text_attrs.push_str(&format!(
" fo:font-size=\"{}\"",
fmt_pt(half_points_to_pt(size))
));
}
if h.bold {
text_attrs.push_str(" fo:font-weight=\"bold\"");
}
if h.italic {
text_attrs.push_str(" fo:font-style=\"italic\"");
}
out.push_str(&format!(
"<style:style style:name=\"Heading_{level}\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties{para_attrs}/><style:text-properties{text_attrs}/></style:style>"
));
}
out.push_str(&format!(
"<style:style style:name=\"Epigraph\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties fo:margin-left=\"{indent}\"/>\
<style:text-properties fo:font-style=\"italic\"/></style:style>\
<style:style style:name=\"EpigraphAttribution\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties fo:margin-left=\"{indent}\" fo:text-align=\"right\"/>\
</style:style>\
<style:style style:name=\"Quote\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties fo:margin-left=\"{indent}\"/></style:style>",
indent = fmt_pt(INDENT_STEP_PT)
));
out.push_str(
"<style:style style:name=\"Rule\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties fo:margin-top=\"12pt\" fo:margin-bottom=\"12pt\" \
fo:border-top=\"none\" fo:border-left=\"none\" fo:border-right=\"none\" \
fo:border-bottom=\"0.5pt solid #000000\" fo:padding=\"0pt\"/></style:style>",
);
out.push_str(
"<style:style style:name=\"Code_Block\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"text\">\
<style:paragraph-properties fo:background-color=\"#F5F5F5\" fo:keep-together=\"always\"/>\
<style:text-properties style:font-name=\"Courier New\" \
style:font-name-complex=\"Courier New\"/></style:style>",
);
if options.page_numbers {
out.push_str(
"<style:style style:name=\"Header\" style:family=\"paragraph\" \
style:parent-style-name=\"Standard\" style:class=\"extra\">\
<style:paragraph-properties fo:text-align=\"right\"/></style:style>",
);
}
out
}
pub(crate) fn odf_align(alignment: &common::entities::Alignment) -> &'static str {
use common::entities::Alignment;
match alignment {
Alignment::Left => "left",
Alignment::Right => "right",
Alignment::Center => "center",
Alignment::Justify => "justify",
}
}
pub(crate) fn page_layout_and_master_page_xml(
options: &common::parser_tools::OdtExportOptions,
) -> (String, String) {
let mut layout_attrs = String::new();
if let (Some(w), Some(h)) = (options.page_width_twips, options.page_height_twips) {
layout_attrs.push_str(&format!(
" fo:page-width=\"{}\" fo:page-height=\"{}\"",
fmt_pt(twips_to_pt(w as i32)),
fmt_pt(twips_to_pt(h as i32))
));
}
if let Some(m) = options.margin_top_twips {
layout_attrs.push_str(&format!(" fo:margin-top=\"{}\"", fmt_pt(twips_to_pt(m))));
}
if let Some(m) = options.margin_bottom_twips {
layout_attrs.push_str(&format!(" fo:margin-bottom=\"{}\"", fmt_pt(twips_to_pt(m))));
}
if let Some(m) = options.margin_left_twips {
layout_attrs.push_str(&format!(" fo:margin-left=\"{}\"", fmt_pt(twips_to_pt(m))));
}
if let Some(m) = options.margin_right_twips {
layout_attrs.push_str(&format!(" fo:margin-right=\"{}\"", fmt_pt(twips_to_pt(m))));
}
let page_layout = format!(
"<style:page-layout style:name=\"PM1\"><style:page-layout-properties{layout_attrs} \
style:print-orientation=\"portrait\"/></style:page-layout>"
);
let header = if options.page_numbers {
let prefix = match &options.running_header {
Some(text) if !text.trim().is_empty() => {
xml_escape(format!("{} ", text.trim()).as_str())
}
_ => String::new(),
};
format!(
"<style:header><text:p text:style-name=\"Header\">{prefix}\
<text:page-number>1</text:page-number></text:p></style:header>"
)
} else {
String::new()
};
let master_page = format!(
"<style:master-page style:name=\"Standard\" style:page-layout-name=\"PM1\">{header}</style:master-page>"
);
(page_layout, master_page)
}
const NAMESPACES: &str = "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:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" \
xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing: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\" \
xmlns:dc=\"http://purl.org/dc/elements/1.1/\" \
xmlns:loext=\"urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0\" \
xmlns:skrb=\"urn:ferntech:text-document:comment:1\"";
pub(crate) fn content_xml(styles: &OdtStyleSheet, body_xml: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<office:document-content {NAMESPACES} office:version=\"1.3\">\
<office:automatic-styles>{}</office:automatic-styles>\
<office:body><office:text>{body_xml}</office:text></office:body>\
</office:document-content>",
styles.automatic_styles_xml()
)
}
pub(crate) fn styles_xml(
options: &common::parser_tools::OdtExportOptions,
heading_styles: &[common::parser_tools::OdtHeadingStyle],
) -> String {
let (page_layout, master_page) = page_layout_and_master_page_xml(options);
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<office:document-styles {NAMESPACES} office:version=\"1.3\">\
<office:styles>{}</office:styles>\
<office:automatic-styles>{page_layout}</office:automatic-styles>\
<office:master-styles>{master_page}</office:master-styles>\
</office:document-styles>",
named_styles_xml(options, heading_styles)
)
}
fn manifest_xml(image_entries: &[(String, String)]) -> String {
let mut out = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" \
manifest:version=\"1.3\">\
<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" \
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\"/>",
);
for (href, media_type) in image_entries {
out.push_str(&format!(
"<manifest:file-entry manifest:full-path=\"{}\" manifest:media-type=\"{}\"/>",
xml_escape(href),
xml_escape(media_type)
));
}
out.push_str("</manifest:manifest>");
out
}
pub(crate) fn package_odt(
content_xml: &str,
styles_xml: &str,
images: &[(String, Vec<u8>, String)],
) -> anyhow::Result<Vec<u8>> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut buf = std::io::Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(&mut buf);
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("mimetype", stored)?;
zip.write_all(b"application/vnd.oasis.opendocument.text")?;
let deflated =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.add_directory("META-INF/", deflated)?;
let image_entries: Vec<(String, String)> = images
.iter()
.map(|(href, _, media_type)| (href.clone(), media_type.clone()))
.collect();
zip.start_file("META-INF/manifest.xml", deflated)?;
zip.write_all(manifest_xml(&image_entries).as_bytes())?;
zip.start_file("content.xml", deflated)?;
zip.write_all(content_xml.as_bytes())?;
zip.start_file("styles.xml", deflated)?;
zip.write_all(styles_xml.as_bytes())?;
if !images.is_empty() {
zip.add_directory("Pictures/", deflated)?;
for (href, bytes, _) in images {
zip.start_file(href, deflated)?;
zip.write_all(bytes)?;
}
}
zip.finish()?;
Ok(buf.into_inner())
}