use crate::image::{self, ImageMeta};
use crate::ir::*;
use crate::layout::{Layout, LayoutKind};
use crate::syntax::{self, Token};
use crate::theme::Theme;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::io::{Cursor, Write};
use std::path::Path;
use zip::write::FileOptions;
use zip::CompressionMethod;
const NS: &str = concat!(
" 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:dc=\"http://purl.org/dc/elements/1.1/\"",
" xmlns:meta=\"urn:oasis:names:tc:opendocument:xmlns:meta:1.0\"",
" xmlns:svg=\"urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0\"",
" xmlns:presentation=\"urn:oasis:names:tc:opendocument:xmlns:presentation:1.0\"",
);
pub fn write(
slides: &[Slide],
theme: &Theme,
layout: &Layout,
deck_title: &str,
author: &str,
base_dir: &Path,
logo: Option<&Path>,
transition: Option<&str>,
transition_dur: f32,
direction: Option<&str>,
) -> Result<Vec<u8>> {
let rtl = direction
.map(|s| s.eq_ignore_ascii_case("rtl"))
.unwrap_or(false);
let mut metas: Vec<ImageMeta> = Vec::new();
let mut by_src: HashMap<String, usize> = HashMap::new();
for slide in slides {
collect_block_images(&slide.blocks, base_dir, &mut metas, &mut by_src)?;
if let Some(bg) = &slide.bg_image {
if !by_src.contains_key(bg) {
let path = base_dir.join(bg);
let meta = image::load(&path)
.with_context(|| format!("loading background image {}", bg))?;
by_src.insert(bg.clone(), metas.len());
metas.push(meta);
}
}
}
let logo_key: Option<String> = if let Some(p) = logo {
let key = format!("__logo:{}", p.display());
if !by_src.contains_key(&key) {
let meta =
image::load(p).with_context(|| format!("loading logo image {}", p.display()))?;
by_src.insert(key.clone(), metas.len());
metas.push(meta);
}
Some(key)
} else {
None
};
let imgs = Imgs {
by_src: &by_src,
metas: &metas,
logo_key: logo_key.as_deref(),
};
let buf: Vec<u8> = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = zip::ZipWriter::new(cursor);
let stored = FileOptions::default().compression_method(CompressionMethod::Stored);
let deflated = FileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("mimetype", stored)?;
zip.write_all(b"application/vnd.oasis.opendocument.presentation")?;
let manifest = build_manifest(&metas);
write_file(&mut zip, "META-INF/manifest.xml", &manifest, deflated)?;
let meta = build_meta(deck_title, author);
write_file(&mut zip, "meta.xml", &meta, deflated)?;
let styles = build_styles(theme, transition);
write_file(&mut zip, "styles.xml", &styles, deflated)?;
let content = build_content(
slides,
theme,
layout,
deck_title,
&imgs,
transition,
transition_dur,
);
let content = if rtl {
apply_rtl_odp(&content)
} else {
content
};
write_file(&mut zip, "content.xml", &content, deflated)?;
for (idx, m) in metas.iter().enumerate() {
zip.start_file(&format!("Pictures/image{}.{}", idx + 1, m.ext), stored)?;
zip.write_all(&m.bytes)?;
}
let cursor = zip.finish()?;
Ok(cursor.into_inner())
}
fn write_file<W: Write + std::io::Seek>(
zip: &mut zip::ZipWriter<W>,
name: &str,
content: &str,
opts: FileOptions,
) -> Result<()> {
zip.start_file(name, opts)?;
zip.write_all(content.as_bytes())?;
Ok(())
}
struct Imgs<'a> {
by_src: &'a HashMap<String, usize>,
metas: &'a [ImageMeta],
logo_key: Option<&'a str>,
}
impl<'a> Imgs<'a> {
fn dims(&self, src: &str) -> Option<(u32, u32)> {
self.by_src
.get(src)
.map(|i| (self.metas[*i].width, self.metas[*i].height))
}
fn index(&self, src: &str) -> Option<usize> {
self.by_src.get(src).copied()
}
fn href(&self, src: &str) -> Option<String> {
let idx = self.index(src)?;
Some(format!("Pictures/image{}.{}", idx + 1, self.metas[idx].ext))
}
fn logo(&self) -> Option<(&str, u32, u32)> {
let k = self.logo_key?;
let i = *self.by_src.get(k)?;
Some((k, self.metas[i].width, self.metas[i].height))
}
}
fn collect_block_images(
blocks: &[Block],
base_dir: &Path,
metas: &mut Vec<ImageMeta>,
by_src: &mut HashMap<String, usize>,
) -> Result<()> {
for b in blocks {
match b {
Block::Image { src, .. } => {
if by_src.contains_key(src) {
continue;
}
let meta = image::load_any_or_placeholder(base_dir, src);
by_src.insert(src.clone(), metas.len());
metas.push(meta);
}
Block::Columns { left, right } => {
collect_block_images(left, base_dir, metas, by_src)?;
collect_block_images(right, base_dir, metas, by_src)?;
}
_ => {}
}
}
Ok(())
}
fn build_manifest(metas: &[ImageMeta]) -> String {
let mut s = String::from(
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:version="1.2" manifest:media-type="application/vnd.oasis.opendocument.presentation"/>
<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:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
"#,
);
for (i, m) in metas.iter().enumerate() {
let mime = if m.ext == "png" {
"image/png"
} else {
"image/jpeg"
};
s.push_str(&format!(
"<manifest:file-entry manifest:full-path=\"Pictures/image{}.{}\" manifest:media-type=\"{}\"/>\n",
i + 1,
m.ext,
mime,
));
}
s.push_str("</manifest:manifest>");
s
}
fn build_meta(deck_title: &str, author: &str) -> String {
let now = "2026-01-01T00:00:00";
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" office:version="1.2">
<office:meta>
<dc:title>{title}</dc:title>
<meta:initial-creator>{author}</meta:initial-creator>
<dc:creator>{author}</dc:creator>
<meta:creation-date>{now}</meta:creation-date>
<dc:date>{now}</dc:date>
<meta:generator>md2any</meta:generator>
</office:meta>
</office:document-meta>"#,
title = escape_xml(deck_title),
author = escape_xml(author),
now = now,
)
}
fn build_styles(theme: &Theme, transition: Option<&str>) -> String {
let trans_attrs = odp_transition_attrs(transition);
let (page_w, page_h) = (cm(theme.slide_w), cm(theme.slide_h));
let orient = if theme.portrait {
"portrait"
} else {
"landscape"
};
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles{ns} office:version="1.2">
<office:styles>
<style:default-style style:family="graphic">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
<style:paragraph-properties fo:text-align="start"/>
<style:text-properties style:font-name="{body_font}" fo:color="#{body_color}"/>
</style:default-style>
</office:styles>
<office:automatic-styles>
<style:page-layout style:name="PL1">
<style:page-layout-properties fo:page-width="{w}" fo:page-height="{h}" style:print-orientation="{orient}"/>
</style:page-layout>
<style:style style:name="dp-default" style:family="drawing-page">
<style:drawing-page-properties draw:fill="solid" draw:fill-color="#{bg}" presentation:background-objects-visible="true" presentation:background-visible="true" presentation:display-header="false" presentation:display-footer="false" presentation:display-page-number="false" presentation:display-date-time="false"{trans}/>
</style:style>
<style:style style:name="dp-section" style:family="drawing-page">
<style:drawing-page-properties draw:fill="solid" draw:fill-color="#{section_bg}" presentation:background-objects-visible="true" presentation:background-visible="true" presentation:display-header="false" presentation:display-footer="false" presentation:display-page-number="false" presentation:display-date-time="false"{trans}/>
</style:style>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Default" style:page-layout-name="PL1" draw:style-name="dp-default"/>
<style:master-page style:name="Section" style:page-layout-name="PL1" draw:style-name="dp-section"/>
</office:master-styles>
</office:document-styles>"##,
ns = NS,
w = page_w,
h = page_h,
orient = orient,
bg = theme.bg,
section_bg = theme.section_bg,
body_color = theme.body_color,
body_font = escape_xml(&theme.body_font),
trans = trans_attrs,
)
}
fn apply_rtl_odp(xml: &str) -> String {
xml.replace(r#"fo:text-align="start""#, r#"fo:text-align="end""#)
.replace(r#"fo:text-align="left""#, r#"fo:text-align="right""#)
.replace(r#"text-align:start"#, r#"text-align:end"#)
.replace(r#"text-align:left"#, r#"text-align:right"#)
}
fn odp_transition_attrs(kind: Option<&str>) -> String {
let Some(kind) = kind else {
return String::new();
};
let kind = kind.to_ascii_lowercase();
if kind.is_empty() || kind == "none" {
return String::new();
}
let style = match kind.as_str() {
"fade" => "fade",
"push" => "move-from-right",
"wipe" => "horizontal-stripes",
"cover" => "uncover-to-left",
"split" => "vertical-stripes",
_ => return String::new(),
};
format!(
r#" presentation:transition-type="automatic" presentation:transition-style="{}" presentation:transition-speed="medium""#,
style,
)
}
#[derive(Hash, Eq, PartialEq, Clone)]
struct TextStyleKey {
color: String,
size_pt: u32,
bold: bool,
italic: bool,
strike: bool,
underline: bool,
font: String,
}
#[derive(Hash, Eq, PartialEq, Clone)]
struct ParaStyleKey {
align: &'static str,
line_spacing_pct: u16,
}
#[derive(Hash, Eq, PartialEq, Clone)]
struct GraphicStyleKey {
fill: Option<String>,
no_fill: bool,
stroke: Option<(String, u32)>,
corner_radius_emu: u32,
text_anchor: &'static str,
text_align: &'static str,
}
struct Registry {
text: HashMap<TextStyleKey, String>,
para: HashMap<ParaStyleKey, String>,
graphic: HashMap<GraphicStyleKey, String>,
text_seq: u32,
para_seq: u32,
graphic_seq: u32,
}
impl Registry {
fn new() -> Self {
Registry {
text: HashMap::new(),
para: HashMap::new(),
graphic: HashMap::new(),
text_seq: 0,
para_seq: 0,
graphic_seq: 0,
}
}
fn text_name(&mut self, k: TextStyleKey) -> String {
if let Some(n) = self.text.get(&k) {
return n.clone();
}
let n = format!("T{}", self.text_seq);
self.text_seq += 1;
self.text.insert(k, n.clone());
n
}
fn para_name(&mut self, k: ParaStyleKey) -> String {
if let Some(n) = self.para.get(&k) {
return n.clone();
}
let n = format!("P{}", self.para_seq);
self.para_seq += 1;
self.para.insert(k, n.clone());
n
}
fn graphic_name(&mut self, k: GraphicStyleKey) -> String {
if let Some(n) = self.graphic.get(&k) {
return n.clone();
}
let n = format!("gr{}", self.graphic_seq);
self.graphic_seq += 1;
self.graphic.insert(k, n.clone());
n
}
fn emit(&self) -> String {
let mut s = String::new();
let mut paras: Vec<(&ParaStyleKey, &String)> = self.para.iter().collect();
paras.sort_by(|a, b| a.1.cmp(b.1));
for (k, name) in paras {
s.push_str(&format!(
r#"<style:style style:name="{n}" style:family="paragraph"><style:paragraph-properties fo:text-align="{a}" style:line-height-at-least="0cm" fo:line-height="{lh}%"/></style:style>"#,
n = name,
a = k.align,
lh = k.line_spacing_pct,
));
}
let mut texts: Vec<(&TextStyleKey, &String)> = self.text.iter().collect();
texts.sort_by(|a, b| a.1.cmp(b.1));
for (k, name) in texts {
let mut attrs = format!(
r##" fo:color="#{c}" fo:font-size="{sz}pt""##,
c = k.color,
sz = k.size_pt,
);
attrs.push_str(&format!(
r#" style:font-name="{f}" fo:font-family="{f}""#,
f = escape_xml(&k.font),
));
if k.bold {
attrs.push_str(r#" fo:font-weight="bold""#);
}
if k.italic {
attrs.push_str(r#" fo:font-style="italic""#);
}
if k.strike {
attrs.push_str(r#" style:text-line-through-style="solid""#);
}
if k.underline {
attrs.push_str(r#" style:text-underline-style="solid""#);
}
s.push_str(&format!(
r#"<style:style style:name="{n}" style:family="text"><style:text-properties{attrs}/></style:style>"#,
n = name,
attrs = attrs,
));
}
let mut graphics: Vec<(&GraphicStyleKey, &String)> = self.graphic.iter().collect();
graphics.sort_by(|a, b| a.1.cmp(b.1));
for (k, name) in graphics {
let fill_attr = if k.no_fill {
String::from(r#" draw:fill="none""#)
} else if let Some(c) = &k.fill {
format!(r##" draw:fill="solid" draw:fill-color="#{}""##, c)
} else {
String::from(r#" draw:fill="none""#)
};
let stroke_attr = if let Some((c, w)) = &k.stroke {
format!(
r##" draw:stroke="solid" svg:stroke-color="#{}" svg:stroke-width="{}""##,
c,
cm(*w),
)
} else {
String::from(r#" draw:stroke="none""#)
};
let corner = if k.corner_radius_emu > 0 {
format!(r#" draw:corner-radius="{}""#, cm(k.corner_radius_emu))
} else {
String::new()
};
s.push_str(&format!(
r#"<style:style style:name="{n}" style:family="graphic"><style:graphic-properties{fill}{stroke}{corner} draw:textarea-vertical-align="{anchor}" draw:textarea-horizontal-align="{halign}" fo:padding="0cm"/></style:style>"#,
n = name,
fill = fill_attr,
stroke = stroke_attr,
corner = corner,
anchor = k.text_anchor,
halign = k.text_align,
));
}
s
}
}
fn build_content(
slides: &[Slide],
theme: &Theme,
layout: &Layout,
deck_title: &str,
imgs: &Imgs,
_transition: Option<&str>,
_transition_dur: f32,
) -> String {
let mut reg = Registry::new();
let mut body = String::new();
for (i, slide) in slides.iter().enumerate() {
let num = i + 1;
let total = slides.len();
let xml = match &slide.kind {
SlideKind::Title {
subtitle,
author,
date,
} => render_title_slide(
slide,
num,
subtitle.as_deref(),
author.as_deref(),
date.as_deref(),
theme,
layout,
imgs,
&mut reg,
),
SlideKind::Section => {
render_section_slide(slide, num, total, theme, layout, imgs, &mut reg)
}
SlideKind::Content => {
render_content_slide(slide, num, total, deck_title, theme, layout, imgs, &mut reg)
}
};
body.push_str(&xml);
}
let auto_styles = reg.emit();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content{ns} office:version="1.2">
<office:automatic-styles>
{styles}
</office:automatic-styles>
<office:body>
<office:presentation>
{body}
</office:presentation>
</office:body>
</office:document-content>"#,
ns = NS,
styles = auto_styles,
body = body,
)
}
fn page_open(name: &str, master: &str) -> String {
format!(
r#"<draw:page draw:name="{n}" draw:style-name="dp-default" draw:master-page-name="{m}">"#,
n = escape_xml(name),
m = master,
)
}
fn page_close(notes: Option<&str>, theme: &Theme, reg: &mut Registry) -> String {
let mut s = String::new();
if let Some(notes_text) = notes {
let text_style = reg.text_name(TextStyleKey {
color: "1F2937".into(),
size_pt: 12,
bold: false,
italic: false,
strike: false,
underline: false,
font: theme.body_font.clone(),
});
let para_style = reg.para_name(ParaStyleKey {
align: "start",
line_spacing_pct: 120,
});
let graphic = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
let mut paras = String::new();
for line in notes_text.split('\n') {
paras.push_str(&format!(
r#"<text:p text:style-name="{p}"><text:span text:style-name="{t}">{x}</text:span></text:p>"#,
p = para_style,
t = text_style,
x = escape_xml(line.trim()),
));
}
s.push_str(&format!(
r#"<presentation:notes><draw:frame draw:style-name="{g}" presentation:class="notes" svg:x="2cm" svg:y="13cm" svg:width="17cm" svg:height="12cm"><draw:text-box>{p}</draw:text-box></draw:frame></presentation:notes>"#,
g = graphic,
p = paras,
));
}
s.push_str("</draw:page>\n");
s
}
fn bg_image_xml(slide: &Slide, theme: &Theme, imgs: &Imgs, reg: &mut Registry) -> String {
if let Some(bg) = &slide.bg_image {
if let Some(href) = imgs.href(bg) {
let g = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
return format!(
r#"<draw:frame draw:style-name="{g}" svg:x="0cm" svg:y="0cm" svg:width="{w}" svg:height="{h}"><draw:image xlink:href="{href}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame>"#,
g = g,
w = cm(theme.slide_w),
h = cm(theme.slide_h),
href = escape_xml(&href),
);
}
}
String::new()
}
fn render_title_slide(
slide: &Slide,
num: usize,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
theme: &Theme,
layout: &Layout,
imgs: &Imgs,
reg: &mut Registry,
) -> String {
let mut s = page_open(&format!("page{}", num), "Default");
s.push_str(&bg_image_xml(slide, theme, imgs, reg));
match layout.kind {
LayoutKind::Clean => title_clean(&mut s, slide, subtitle, author, date, theme, reg),
LayoutKind::Studio => title_studio(&mut s, slide, subtitle, author, date, theme, reg),
LayoutKind::Frame => title_frame(&mut s, slide, subtitle, author, date, theme, reg),
LayoutKind::Bold => title_bold(&mut s, slide, subtitle, author, date, theme, reg),
}
s.push_str(&page_close(slide.notes.as_deref(), theme, reg));
s
}
fn render_section_slide(
slide: &Slide,
num: usize,
total: usize,
theme: &Theme,
layout: &Layout,
imgs: &Imgs,
reg: &mut Registry,
) -> String {
let master = if matches!(layout.kind, LayoutKind::Clean | LayoutKind::Frame) {
"Section"
} else {
"Default"
};
let mut s = format!(
r#"<draw:page draw:name="page{}" draw:style-name="dp-{}" draw:master-page-name="{}">"#,
num,
if master == "Section" {
"section"
} else {
"default"
},
master,
);
s.push_str(&bg_image_xml(slide, theme, imgs, reg));
match layout.kind {
LayoutKind::Clean | LayoutKind::Frame => section_centered(&mut s, slide, theme, reg),
LayoutKind::Studio => section_studio(&mut s, slide, num, total, theme, reg),
LayoutKind::Bold => section_bold(&mut s, slide, theme, reg),
}
s.push_str(&page_close(slide.notes.as_deref(), theme, reg));
s
}
fn render_content_slide(
slide: &Slide,
num: usize,
total: usize,
deck_title: &str,
theme: &Theme,
layout: &Layout,
imgs: &Imgs,
reg: &mut Registry,
) -> String {
let mut s = page_open(&format!("page{}", num), "Default");
s.push_str(&bg_image_xml(slide, theme, imgs, reg));
content_slide_body(
&mut s, slide, num, total, deck_title, theme, layout, imgs, reg,
);
s.push_str(&page_close(slide.notes.as_deref(), theme, reg));
s
}
fn title_clean(
s: &mut String,
slide: &Slide,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
theme: &Theme,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
s.push_str(&rect(reg, 0, 0, w, 360000, &theme.accent));
s.push_str(&rect(
reg,
600000,
h / 2 - 1700000,
100000,
600000,
&theme.accent,
));
let title_y = h / 2 - 1600000;
s.push_str(&text_frame(
reg,
800000,
title_y,
w - 1200000,
1400000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.title_color,
true,
false,
&theme.title_font,
));
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1800 } else { 2400 };
s.push_str(&text_frame(
reg,
800000,
title_y + 1400000,
w - 1200000,
700000,
"top",
"left",
&[Run::plain(sub)],
sub_size,
&theme.accent,
false,
false,
&theme.body_font,
));
}
if let Some(text) = author_date(author, date) {
s.push_str(&text_frame(
reg,
800000,
h - 700000,
w - 1200000,
400000,
"top",
"left",
&[Run::plain(&text)],
1400,
&theme.muted_color,
false,
false,
&theme.body_font,
));
}
}
fn title_studio(
s: &mut String,
slide: &Slide,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
theme: &Theme,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
s.push_str(&rect(reg, 0, 0, 90000, h, &theme.accent));
if let Some(text) = author_date(author, date) {
let kicker = letterspaced(&text);
s.push_str(&text_frame(
reg,
900000,
900000,
w - 1500000,
400000,
"top",
"left",
&[Run::plain(&kicker)],
1200,
&theme.muted_color,
true,
false,
&theme.body_font,
));
}
let title_y = h / 2 - 1000000;
s.push_str(&text_frame(
reg,
900000,
title_y,
w - 1500000,
1600000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.title_color,
false,
true,
&theme.title_font,
));
if let Some(sub) = subtitle {
let sub_y = title_y + 1600000;
let sub_size = if theme.portrait { 1700 } else { 2200 };
s.push_str(&text_frame(
reg,
900000,
sub_y,
w - 1500000,
700000,
"top",
"left",
&[Run::plain(sub)],
sub_size,
&theme.body_color,
false,
false,
&theme.body_font,
));
s.push_str(&rect(
reg,
900000,
sub_y + 800000,
600000,
30000,
&theme.accent,
));
}
}
fn title_frame(
s: &mut String,
slide: &Slide,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
theme: &Theme,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
let sidebar = 2_200_000_u32;
s.push_str(&rect(reg, 0, 0, sidebar, h, &theme.accent));
s.push_str(&rect(reg, 300000, 300000, 380000, 40000, &theme.on_accent));
let mut meta_lines = Vec::new();
if let Some(a) = author {
meta_lines.push(a);
}
if let Some(d) = date {
meta_lines.push(d);
}
if !meta_lines.is_empty() {
let runs: Vec<Run> = meta_lines
.iter()
.enumerate()
.flat_map(|(i, line)| {
let mut v = Vec::new();
if i > 0 {
v.push(Run::plain("\n"));
}
v.push(Run::plain(*line));
v
})
.collect();
s.push_str(&text_frame(
reg,
300000,
h - 1200000,
sidebar - 600000,
900000,
"top",
"left",
&runs,
1300,
&theme.on_accent,
false,
false,
&theme.body_font,
));
}
let title_x = sidebar + 600000;
let title_y = h / 2 - 1100000;
let title_w = w - title_x - 600000;
s.push_str(&text_frame(
reg,
title_x,
title_y,
title_w,
1500000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.title_color,
true,
false,
&theme.title_font,
));
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1700 } else { 2200 };
s.push_str(&text_frame(
reg,
title_x,
title_y + 1500000,
title_w,
700000,
"top",
"left",
&[Run::plain(sub)],
sub_size,
&theme.accent,
false,
false,
&theme.body_font,
));
}
}
fn title_bold(
s: &mut String,
slide: &Slide,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
theme: &Theme,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
let block_h = h * 60 / 100;
let pad = 700000;
s.push_str(&rect(reg, 0, 0, w, block_h, &theme.accent));
let title_y = block_h - 1400000 - pad;
s.push_str(&text_frame(
reg,
pad,
title_y,
w - 2 * pad,
1400000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.on_accent,
true,
false,
&theme.title_font,
));
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1800 } else { 2400 };
s.push_str(&text_frame(
reg,
pad,
block_h + 500000,
w - 2 * pad,
700000,
"top",
"left",
&[Run::plain(sub)],
sub_size,
&theme.title_color,
false,
false,
&theme.body_font,
));
}
if let Some(text) = author_date(author, date) {
s.push_str(&text_frame(
reg,
pad,
h - 700000,
w - 2 * pad,
400000,
"top",
"left",
&[Run::plain(&text)],
1400,
&theme.muted_color,
false,
false,
&theme.body_font,
));
}
}
fn section_centered(s: &mut String, slide: &Slide, theme: &Theme, reg: &mut Registry) {
let w = theme.slide_w;
let h = theme.slide_h;
let bar_w = 1200000;
s.push_str(&rect(
reg,
(w - bar_w) / 2,
h / 2 - 1000000,
bar_w,
60000,
&theme.section_text,
));
s.push_str(&text_frame(
reg,
800000,
h / 2 - 700000,
w - 1600000,
1500000,
"middle",
"center",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.section_text,
true,
false,
&theme.title_font,
));
}
fn section_studio(
s: &mut String,
slide: &Slide,
num: usize,
total: usize,
theme: &Theme,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
s.push_str(&rect(reg, 0, 0, 90000, h, &theme.accent));
let huge = format!("{:02}", num.min(99));
let huge_size = if theme.portrait { 9000 } else { 13000 };
s.push_str(&text_frame(
reg,
w - 2_400_000,
380000,
2_300_000,
2_400_000,
"top",
"right",
&[Run::plain(&huge)],
huge_size,
&theme.divider,
true,
false,
&theme.title_font,
));
let kicker_x = 900000;
let kicker_y = h - 2_600_000;
let kicker_text = format!(
"{} · {}",
letterspaced("section"),
letterspaced(&format!("{} of {}", num, total))
);
s.push_str(&text_frame(
reg,
kicker_x,
kicker_y,
w - 1800000,
350000,
"top",
"left",
&[Run::plain(&kicker_text)],
1100,
&theme.muted_color,
true,
false,
&theme.body_font,
));
let title_y = kicker_y + 400000;
s.push_str(&text_frame(
reg,
kicker_x,
title_y,
w - kicker_x - 600000,
1800000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.title_color,
false,
true,
&theme.title_font,
));
s.push_str(&rect(
reg,
kicker_x,
title_y + 1900000,
600000,
30000,
&theme.accent,
));
}
fn section_bold(s: &mut String, slide: &Slide, theme: &Theme, reg: &mut Registry) {
let w = theme.slide_w;
let h = theme.slide_h;
let block_h = h * 70 / 100;
let pad = 700000;
s.push_str(&rect(reg, 0, 0, w, block_h, &theme.accent));
s.push_str(&text_frame(
reg,
pad,
block_h - 1500000 - pad,
w - 2 * pad,
1500000,
"top",
"left",
&[Run::plain(&slide.title)],
theme.hero_size,
&theme.on_accent,
true,
false,
&theme.title_font,
));
}
fn content_slide_body(
s: &mut String,
slide: &Slide,
num: usize,
total: usize,
deck_title: &str,
theme: &Theme,
layout: &Layout,
imgs: &Imgs,
reg: &mut Registry,
) {
let w = theme.slide_w;
let h = theme.slide_h;
let base_margin: u32 = 533400;
let left_offset = layout.content_left_offset();
let extra_left = if layout.shows_rail() { 200000 } else { 0 };
let content_x = if left_offset > 0 {
left_offset + 480000
} else {
base_margin + extra_left
};
let content_w = w.saturating_sub(content_x + base_margin);
if layout.shows_rail() {
s.push_str(&rect(reg, 0, 0, layout.rail_width(), h, &theme.accent));
}
if layout.shows_sidebar() {
let sb_w = layout.sidebar_width();
s.push_str(&rect(reg, 0, 0, sb_w, h, &theme.accent));
let pad = 300000;
s.push_str(&text_frame(
reg,
pad,
pad,
sb_w - 2 * pad,
600000,
"top",
"left",
&[Run::plain(deck_title)],
1200,
&theme.on_accent,
true,
false,
&theme.body_font,
));
s.push_str(&text_frame(
reg,
pad,
h - 600000,
sb_w - 2 * pad,
400000,
"top",
"left",
&[Run::plain(&format!("{:02} / {:02}", num, total))],
1100,
&theme.on_accent,
false,
false,
&theme.body_font,
));
}
let title_y: u32 = if matches!(layout.kind, LayoutKind::Bold) {
280000
} else {
360000
};
let title_h: u32 = if matches!(layout.kind, LayoutKind::Bold) {
820000
} else {
720000
};
if matches!(layout.kind, LayoutKind::Bold) {
let block_h = title_h + 240000;
s.push_str(&rect(reg, 0, 0, w, title_y + block_h, &theme.accent));
s.push_str(&text_frame(
reg,
base_margin,
title_y,
w - 2 * base_margin,
title_h,
"top",
"left",
&[Run::plain(&slide.title)],
theme.title_size,
&theme.on_accent,
true,
false,
&theme.title_font,
));
} else {
s.push_str(&text_frame(
reg,
content_x,
title_y,
content_w,
title_h,
"top",
"left",
&[Run::plain(&slide.title)],
theme.title_size,
&theme.title_color,
true,
false,
&theme.title_font,
));
}
let underline_y = if matches!(layout.kind, LayoutKind::Clean) {
let y = title_y + title_h + 30000;
s.push_str(&rect(reg, content_x, y, 400000, 50000, &theme.accent));
s.push_str(&rect(
reg,
content_x + 420000,
y + 18000,
content_w - 420000,
14000,
&theme.divider,
));
y
} else {
title_y + title_h
};
let content_y_start = underline_y
+ if matches!(layout.kind, LayoutKind::Clean) {
200000
} else {
280000
};
let footer_y = h - 400000;
let content_max_y = footer_y - 100000;
let content_h_total = content_max_y.saturating_sub(content_y_start);
render_blocks(
s,
&slide.blocks,
theme,
content_x,
content_y_start,
content_w,
content_h_total,
imgs,
reg,
);
if !layout.shows_sidebar() {
if let Some((key, iw, ih)) = imgs.logo() {
let logo_h: u32 = 300000;
let logo_w = if ih > 0 {
((logo_h as u64 * iw as u64) / ih as u64) as u32
} else {
logo_h
};
if let Some(href) = imgs.href(key) {
let g = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
s.push_str(&format!(
r#"<draw:frame draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><draw:image xlink:href="{href}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame>"#,
g = g,
x = cm(content_x),
y = cm(footer_y - 50000),
w = cm(logo_w),
h = cm(logo_h),
href = escape_xml(&href),
));
}
} else {
s.push_str(&text_frame(
reg,
content_x,
footer_y,
content_w.saturating_sub(800000),
300000,
"top",
"left",
&[Run::plain(deck_title)],
1000,
&theme.muted_color,
false,
false,
&theme.body_font,
));
}
s.push_str(&text_frame(
reg,
w - base_margin - 800000,
footer_y,
800000,
300000,
"top",
"right",
&[Run::plain(&format!("{} / {}", num, total))],
1000,
&theme.muted_color,
false,
false,
&theme.body_font,
));
}
if layout.shows_corner_decoration() {
s.push_str(&rect(
reg,
w - 280000,
h - 280000,
120000,
120000,
&theme.accent,
));
}
}
fn render_blocks(
out: &mut String,
blocks: &[Block],
theme: &Theme,
x: u32,
y_start: u32,
w: u32,
h_total: u32,
imgs: &Imgs,
reg: &mut Registry,
) {
let estimated: u32 = blocks
.iter()
.map(|b| block_height_emu(b, w, theme.slide_h, imgs))
.sum::<u32>()
+ (blocks.len().saturating_sub(1) as u32) * 80000;
let scale: f32 = if estimated > h_total && estimated > 0 {
h_total as f32 / estimated as f32
} else {
1.0
};
let mut y = y_start;
for block in blocks {
let raw_h = block_height_emu(block, w, theme.slide_h, imgs);
let h = (((raw_h as f32) * scale) as u32).max(200000);
match block {
Block::Paragraph(runs) => {
out.push_str(&text_frame(
reg,
x,
y,
w,
h,
"top",
"left",
runs,
theme.body_size,
&theme.body_color,
false,
false,
&theme.body_font,
));
}
Block::Heading { level, runs } => {
let sz = match level {
3 => theme.title_size - 400,
4 => theme.title_size - 600,
_ => theme.title_size - 800,
};
out.push_str(&text_frame(
reg,
x,
y,
w,
h,
"top",
"left",
runs,
sz,
&theme.title_color,
true,
false,
&theme.title_font,
));
}
Block::List(items) => {
if items.len() > crate::theme::LONG_LIST_THRESHOLD && !theme.portrait {
let half = items.len().div_ceil(2);
let (l, r) = items.split_at(half);
let gap: u32 = 200000;
let col_w = (w.saturating_sub(gap)) / 2;
out.push_str(&list_frame(reg, x, y, col_w, h, l, theme));
out.push_str(&list_frame(reg, x + col_w + gap, y, col_w, h, r, theme));
} else {
out.push_str(&list_frame(reg, x, y, w, h, items, theme));
}
}
Block::CodeBlock {
lang,
title,
lines,
line_numbers,
} => {
out.push_str(&code_block(
reg,
x,
y,
w,
h,
lines,
title.as_deref(),
lang.as_deref(),
*line_numbers,
theme,
));
}
Block::Quote(paras) => {
out.push_str(&rect(reg, x, y, 60000, h, &theme.accent));
let mut runs: Vec<Run> = Vec::new();
for (i, prun) in paras.iter().enumerate() {
if i > 0 {
runs.push(Run::plain("\n"));
}
runs.extend(prun.clone());
}
out.push_str(&text_frame(
reg,
x + 180000,
y,
w.saturating_sub(180000),
h,
"top",
"left",
&runs,
theme.body_size - 100,
&theme.body_color,
false,
true,
&theme.body_font,
));
}
Block::Table { headers, rows } => {
out.push_str(&render_table(reg, x, y, w, h, headers, rows, theme));
}
Block::Columns { left, right } => {
let gap: u32 = 280000;
let half = w.saturating_sub(gap) / 2;
render_blocks(out, left, theme, x, y, half, h, imgs, reg);
render_blocks(
out,
right,
theme,
x + half + gap,
y,
w - half - gap,
h,
imgs,
reg,
);
}
Block::ColumnBreak => {}
Block::Image {
src,
alt,
width_pct,
} => {
let effective_w = match width_pct {
Some(pct) => w * (*pct as u32) / 100,
None => w,
};
out.push_str(&render_image(
reg,
x,
y,
effective_w,
h,
src,
alt,
theme,
imgs,
));
}
Block::Footnotes(items) => {
out.push_str(&render_footnotes_block(reg, x, y, w, h, items, theme));
}
}
y += h + 80000;
}
}
fn render_footnotes_block(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
items: &[ListItem],
theme: &Theme,
) -> String {
let mut combined: Vec<Run> = Vec::new();
for (i, item) in items.iter().enumerate() {
if i > 0 {
combined.push(Run::plain("\n"));
}
combined.extend(item.runs.iter().cloned());
}
text_frame(
reg,
x,
y,
w,
h,
"top",
"left",
&combined,
(theme.body_size as f32 * 0.7) as u32,
&theme.muted_color,
false,
false,
&theme.body_font,
)
}
fn block_height_emu(b: &Block, w: u32, slide_h: u32, imgs: &Imgs) -> u32 {
let cpl = chars_per_line(w);
match b {
Block::Paragraph(runs) => {
let chars = total_chars(runs) as u32;
let lines = (chars / cpl).max(1) + 1;
lines * 270000 + 60000
}
Block::Heading { .. } => 520000,
Block::List(items) => {
let total: u32 = items
.iter()
.map(|i| {
let chars = i.runs.iter().map(|r| r.text.chars().count()).sum::<usize>() as u32;
let item_cpl = cpl.saturating_sub(4 + (i.level as u32) * 4).max(12);
let lines = (chars / item_cpl).max(1) + 1;
lines * 280000
})
.sum::<u32>()
+ 80000;
if items.len() > crate::theme::LONG_LIST_THRESHOLD {
let col_w = (w.saturating_sub(200000)) / 2;
let col_cpl = chars_per_line(col_w);
let col_total: u32 = items
.iter()
.map(|i| {
let chars =
i.runs.iter().map(|r| r.text.chars().count()).sum::<usize>() as u32;
let lines = (chars / col_cpl).max(1) + 1;
lines * 280000
})
.sum::<u32>();
(col_total / 2) + 240000
} else {
total
}
}
Block::CodeBlock {
lines, title, lang, ..
} => {
let title_h = if title.is_some() || lang.is_some() {
320000
} else {
0
};
(lines.len() as u32).max(1) * 280000 + 320000 + title_h
}
Block::Quote(paras) => {
paras
.iter()
.map(|runs| {
let chars = total_chars(runs) as u32;
let lines = (chars / cpl.saturating_sub(2).max(12)).max(1) + 1;
lines * 280000
})
.sum::<u32>()
+ 120000
}
Block::Table { rows, .. } => (rows.len() as u32 + 1) * 340000 + 80000,
Block::Columns { left, right } => {
let gap: u32 = 280000;
let half = w.saturating_sub(gap) / 2;
let lh: u32 = left
.iter()
.map(|b| block_height_emu(b, half, slide_h, imgs))
.sum::<u32>()
+ (left.len().saturating_sub(1) as u32) * 80000;
let rh: u32 = right
.iter()
.map(|b| block_height_emu(b, half, slide_h, imgs))
.sum::<u32>()
+ (right.len().saturating_sub(1) as u32) * 80000;
lh.max(rh)
}
Block::ColumnBreak => 0,
Block::Image {
src,
alt,
width_pct: _,
} => {
let max_image_h = slide_h * crate::theme::IMAGE_MAX_HEIGHT_FRACTION_NUM
/ crate::theme::IMAGE_MAX_HEIGHT_FRACTION_DEN;
let placeholder_h = slide_h * 30 / 100;
let display_h = if let Some((iw, ih)) = imgs.dims(src) {
let (_, dh) = fit_image(iw, ih, w, max_image_h);
dh
} else {
placeholder_h
};
let caption = if alt.is_empty() { 0 } else { 260000 };
display_h + caption + 80000
}
Block::Footnotes(items) => {
let total: u32 = items
.iter()
.map(|i| {
let chars = i.runs.iter().map(|r| r.text.chars().count()).sum::<usize>() as u32;
let lines = (chars / cpl.saturating_sub(2).max(20)).max(1);
lines * 200000
})
.sum::<u32>();
total + 120000
}
}
}
fn chars_per_line(w: u32) -> u32 {
(w / 120000).max(20)
}
fn total_chars(runs: &[Run]) -> usize {
runs.iter().map(|r| r.text.chars().count()).sum()
}
fn fit_image(iw: u32, ih: u32, max_w: u32, max_h: u32) -> (u32, u32) {
if iw == 0 || ih == 0 {
return (max_w, max_h);
}
let iw = iw as u64;
let ih = ih as u64;
let mw = max_w as u64;
let mh = max_h as u64;
let h_at_mw = mw * ih / iw;
if h_at_mw <= mh {
(mw as u32, h_at_mw as u32)
} else {
let w_at_mh = mh * iw / ih;
(w_at_mh as u32, mh as u32)
}
}
fn text_frame(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
anchor: &'static str,
halign: &'static str,
runs: &[Run],
size_centipt: u32,
color: &str,
bold: bool,
italic: bool,
font: &str,
) -> String {
let gr = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: anchor,
text_align: halign,
});
let para_align = match halign {
"center" => "center",
"right" => "end",
_ => "start",
};
let para = reg.para_name(ParaStyleKey {
align: para_align,
line_spacing_pct: 120,
});
let runs_xml = runs_to_spans(reg, runs, size_centipt, color, bold, italic, font);
format!(
r#"<draw:frame draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><draw:text-box><text:p text:style-name="{p}">{r}</text:p></draw:text-box></draw:frame>"#,
g = gr,
x = cm(x),
y = cm(y),
w = cm(w),
h = cm(h),
p = para,
r = runs_xml,
)
}
fn runs_to_spans(
reg: &mut Registry,
runs: &[Run],
base_size: u32,
base_color: &str,
base_bold: bool,
base_italic: bool,
font: &str,
) -> String {
let mut s = String::new();
for r in runs {
if r.text.is_empty() {
continue;
}
if r.text == "\n" {
s.push_str("</text:p><text:p>");
continue;
}
let color = if r.link.is_some() {
base_color
} else if r.code {
base_color
} else {
base_color
};
let style = reg.text_name(TextStyleKey {
color: color.to_string(),
size_pt: base_size / 100,
bold: base_bold || r.bold,
italic: base_italic || r.italic,
strike: r.strike,
underline: r.link.is_some(),
font: if r.code {
"Consolas".into()
} else {
font.to_string()
},
});
let text = expand_breaks(&escape_xml(&r.text));
let span = format!(
r#"<text:span text:style-name="{}">{}</text:span>"#,
style, text
);
if let Some(url) = &r.link {
s.push_str(&format!(
r#"<text:a xlink:type="simple" xlink:href="{}">{}</text:a>"#,
escape_xml(url),
span,
));
} else {
s.push_str(&span);
}
}
if s.is_empty() {
s.push_str(r#"<text:span/>"#);
}
s
}
fn expand_breaks(s: &str) -> String {
s.replace('\t', "<text:tab/>")
}
fn code_text(s: &str) -> String {
let mut out = String::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
let mut at_start = true;
while i < chars.len() {
let c = chars[i];
if c == ' ' {
let mut j = i;
while j < chars.len() && chars[j] == ' ' {
j += 1;
}
let count = j - i;
if at_start || count >= 2 {
if count == 1 {
out.push_str("<text:s/>");
} else {
out.push_str(&format!(r#"<text:s text:c="{}"/>"#, count));
}
} else {
out.push(' ');
}
i = j;
at_start = false;
} else if c == '\t' {
out.push_str("<text:tab/>");
i += 1;
at_start = false;
} else {
match c {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && c != '\n' && c != '\r' => {}
c => out.push(c),
}
i += 1;
at_start = false;
}
}
out
}
fn list_frame(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
items: &[ListItem],
theme: &Theme,
) -> String {
let gr = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
let para = reg.para_name(ParaStyleKey {
align: "start",
line_spacing_pct: 120,
});
let bullet_style = reg.text_name(TextStyleKey {
color: theme.accent.clone(),
size_pt: theme.body_size / 100,
bold: false,
italic: false,
strike: false,
underline: false,
font: theme.body_font.clone(),
});
let mut paras = String::new();
let mut ordered_counters: Vec<u32> = Vec::new();
for item in items {
let lvl = item.level.min(8) as usize;
while ordered_counters.len() <= lvl {
ordered_counters.push(0);
}
ordered_counters[lvl] += 1;
for c in &mut ordered_counters[lvl + 1..] {
*c = 0;
}
let bullet_char = if item.ordered {
format!("{}.", ordered_counters[lvl])
} else {
match lvl {
0 => "●",
1 => "○",
2 => "▪",
_ => "·",
}
.to_string()
};
let indent_spaces = " ".repeat(lvl);
let bullet_run = Run {
text: format!("{indent_spaces}"),
bold: false,
italic: false,
code: false,
strike: false,
link: None,
};
let bullet_xml = format!(
r#"<text:span text:style-name="{b}">{bc}</text:span><text:span> </text:span>"#,
b = bullet_style,
bc = escape_xml(&bullet_char),
);
let indent_xml = if !indent_spaces.is_empty() {
runs_to_spans(
reg,
&[bullet_run],
theme.body_size,
&theme.body_color,
false,
false,
&theme.body_font,
)
} else {
String::new()
};
let body_xml = runs_to_spans(
reg,
&item.runs,
theme.body_size,
&theme.body_color,
false,
false,
&theme.body_font,
);
paras.push_str(&format!(
r#"<text:p text:style-name="{p}">{ind}{bul}{body}</text:p>"#,
p = para,
ind = indent_xml,
bul = bullet_xml,
body = body_xml,
));
}
format!(
r#"<draw:frame draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><draw:text-box>{p}</draw:text-box></draw:frame>"#,
g = gr,
x = cm(x),
y = cm(y),
w = cm(w),
h = cm(h),
p = paras,
)
}
fn code_block(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
lines: &[String],
title: Option<&str>,
lang: Option<&str>,
line_numbers: bool,
theme: &Theme,
) -> String {
let mut s = String::new();
let title_h: u32 = if title.is_some() || lang.is_some() {
320000
} else {
0
};
let code_y = y + title_h;
let code_h = h.saturating_sub(title_h);
if title_h > 0 {
let title_g = reg.graphic_name(GraphicStyleKey {
fill: Some(theme.divider.clone()),
no_fill: false,
stroke: Some((theme.divider.clone(), 6350)),
corner_radius_emu: 45000,
text_anchor: "middle",
text_align: "left",
});
let para = reg.para_name(ParaStyleKey {
align: "start",
line_spacing_pct: 120,
});
let title_span = title.map(|t| {
let st = reg.text_name(TextStyleKey {
color: theme.title_color.clone(),
size_pt: 12,
bold: true,
italic: false,
strike: false,
underline: false,
font: theme.mono_font.clone(),
});
format!(
r#"<text:span text:style-name="{s}">{t}</text:span>"#,
s = st,
t = escape_xml(t),
)
});
let lang_span = lang.map(|l| {
let st = reg.text_name(TextStyleKey {
color: theme.muted_color.clone(),
size_pt: 11,
bold: false,
italic: false,
strike: false,
underline: false,
font: theme.body_font.clone(),
});
if title.is_some() {
format!(
r#"<text:span text:style-name="{s}"> · {l}</text:span>"#,
s = st,
l = escape_xml(l),
)
} else {
format!(
r#"<text:span text:style-name="{s}">{l}</text:span>"#,
s = st,
l = escape_xml(l),
)
}
});
let combined: String = [title_span, lang_span]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("");
s.push_str(&format!(
r#"<draw:rect draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{th}"><text:p text:style-name="{p}">{c}</text:p></draw:rect>"#,
g = title_g,
x = cm(x),
y = cm(y),
w = cm(w),
th = cm(title_h),
p = para,
c = combined,
));
}
let body_g = reg.graphic_name(GraphicStyleKey {
fill: Some(theme.code_bg.clone()),
no_fill: false,
stroke: Some((theme.divider.clone(), 6350)),
corner_radius_emu: 45000,
text_anchor: "top",
text_align: "left",
});
let highlighted: Vec<Vec<Token>> = syntax::tokenize(lines, lang);
let gutter_w = if line_numbers {
Some(lines.len().to_string().len())
} else {
None
};
let mut paras = String::new();
let para = reg.para_name(ParaStyleKey {
align: "start",
line_spacing_pct: 115,
});
for (idx, line_tokens) in highlighted.iter().enumerate() {
let mut runs_xml = String::new();
if let Some(width) = gutter_w {
let n = format!("{:>w$}", idx + 1, w = width);
let st = reg.text_name(TextStyleKey {
color: theme.muted_color.clone(),
size_pt: theme.code_size / 100,
bold: false,
italic: false,
strike: false,
underline: false,
font: theme.mono_font.clone(),
});
runs_xml.push_str(&format!(
r#"<text:span text:style-name="{s}">{t}<text:s text:c="2"/></text:span>"#,
s = st,
t = escape_xml(&n),
));
}
if line_tokens.is_empty() || line_tokens.iter().all(|t| t.text.is_empty()) {
let st = reg.text_name(TextStyleKey {
color: theme.code_text.clone(),
size_pt: theme.code_size / 100,
bold: false,
italic: false,
strike: false,
underline: false,
font: theme.mono_font.clone(),
});
runs_xml.push_str(&format!(
r#"<text:span text:style-name="{s}"><text:s/></text:span>"#,
s = st
));
} else {
for token in line_tokens {
if token.text.is_empty() {
continue;
}
let style = theme.syntax_style(token.kind);
let st = reg.text_name(TextStyleKey {
color: style.color.clone(),
size_pt: theme.code_size / 100,
bold: style.bold,
italic: style.italic,
strike: false,
underline: false,
font: theme.mono_font.clone(),
});
runs_xml.push_str(&format!(
r#"<text:span text:style-name="{s}">{t}</text:span>"#,
s = st,
t = code_text(&token.text),
));
}
}
paras.push_str(&format!(
r#"<text:p text:style-name="{p}">{r}</text:p>"#,
p = para,
r = runs_xml,
));
}
s.push_str(&format!(
r#"<draw:frame draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><draw:text-box>{p}</draw:text-box></draw:frame>"#,
g = body_g,
x = cm(x),
y = cm(code_y),
w = cm(w),
h = cm(code_h),
p = paras,
));
s
}
fn render_table(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
headers: &[Vec<Run>],
rows: &[Vec<Vec<Run>>],
theme: &Theme,
) -> String {
let cols = headers
.len()
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
if cols == 0 {
return String::new();
}
let col_w = w / cols as u32;
let header_h = 380000_u32;
let row_h = if rows.is_empty() {
320000
} else {
(h.saturating_sub(header_h) / rows.len() as u32).max(280000)
};
let mut s = String::new();
for c in 0..cols {
let cx = x + col_w * c as u32;
s.push_str(&cell(
reg,
cx,
y,
col_w,
header_h,
headers.get(c).map(|r| r.as_slice()).unwrap_or(&[]),
&theme.accent,
&theme.on_accent,
theme.body_size - 200,
true,
theme,
));
}
for (i, row) in rows.iter().enumerate() {
let ry = y + header_h + row_h * i as u32;
let banded = i % 2 == 1;
let bg = if banded {
theme.code_bg.clone()
} else {
theme.bg.clone()
};
for c in 0..cols {
let cx = x + col_w * c as u32;
s.push_str(&cell(
reg,
cx,
ry,
col_w,
row_h,
row.get(c).map(|r| r.as_slice()).unwrap_or(&[]),
&bg,
&theme.body_color,
theme.body_size - 400,
false,
theme,
));
}
}
s
}
fn cell(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
runs: &[Run],
fill: &str,
text_color: &str,
size_centipt: u32,
bold: bool,
theme: &Theme,
) -> String {
let g = reg.graphic_name(GraphicStyleKey {
fill: Some(fill.to_string()),
no_fill: false,
stroke: Some((theme.divider.clone(), 6350)),
corner_radius_emu: 0,
text_anchor: "middle",
text_align: "left",
});
let para = reg.para_name(ParaStyleKey {
align: "start",
line_spacing_pct: 120,
});
let runs_xml = runs_to_spans(
reg,
runs,
size_centipt,
text_color,
bold,
false,
&theme.body_font,
);
format!(
r#"<draw:rect draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><text:p text:style-name="{p}">{r}</text:p></draw:rect>"#,
g = g,
x = cm(x),
y = cm(y),
w = cm(w),
h = cm(h),
p = para,
r = runs_xml,
)
}
fn render_image(
reg: &mut Registry,
x: u32,
y: u32,
w: u32,
h: u32,
src: &str,
alt: &str,
theme: &Theme,
imgs: &Imgs,
) -> String {
let mut s = String::new();
let caption_h = if alt.is_empty() { 0 } else { 260000 };
let image_h = h.saturating_sub(caption_h + 80000);
let (display_w, display_h) = if let Some((iw, ih)) = imgs.dims(src) {
fit_image(iw, ih, w, image_h)
} else {
(w, image_h)
};
if let Some(href) = imgs.href(src) {
let g = reg.graphic_name(GraphicStyleKey {
fill: None,
no_fill: true,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
let img_x = x + (w.saturating_sub(display_w)) / 2;
s.push_str(&format!(
r#"<draw:frame draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"><draw:image xlink:href="{href}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame>"#,
g = g,
x = cm(img_x),
y = cm(y),
w = cm(display_w),
h = cm(display_h),
href = escape_xml(&href),
));
}
if !alt.is_empty() {
let caption_y = y + display_h + 60000;
s.push_str(&text_frame(
reg,
x,
caption_y,
w,
caption_h,
"top",
"center",
&[Run::plain(alt)],
1300,
&theme.muted_color,
false,
true,
&theme.body_font,
));
}
s
}
fn rect(reg: &mut Registry, x: u32, y: u32, w: u32, h: u32, color: &str) -> String {
let g = reg.graphic_name(GraphicStyleKey {
fill: Some(color.to_string()),
no_fill: false,
stroke: None,
corner_radius_emu: 0,
text_anchor: "top",
text_align: "left",
});
format!(
r#"<draw:rect draw:style-name="{g}" svg:x="{x}" svg:y="{y}" svg:width="{w}" svg:height="{h}"/>"#,
g = g,
x = cm(x),
y = cm(y),
w = cm(w),
h = cm(h),
)
}
fn cm(emu: u32) -> String {
format!("{:.4}cm", emu as f64 / 360000.0)
}
fn escape_xml(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("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
c => out.push(c),
}
}
out
}
fn letterspaced(s: &str) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 {
out.push(' ');
}
for u in c.to_uppercase() {
out.push(u);
}
}
out
}
fn author_date(author: Option<&str>, date: Option<&str>) -> Option<String> {
let mut s = String::new();
if let Some(a) = author {
s.push_str(a);
}
if author.is_some() && date.is_some() {
s.push_str(" · ");
}
if let Some(d) = date {
s.push_str(d);
}
if s.is_empty() {
None
} else {
Some(s)
}
}