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 flate2::write::ZlibEncoder;
use flate2::Compression;
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
const EMU_PER_PT: f32 = 12700.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotesPageSize {
Slide,
A4,
}
impl Default for NotesPageSize {
fn default() -> Self {
NotesPageSize::Slide
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotesLayout {
Auto,
SideBySide,
Below,
}
impl Default for NotesLayout {
fn default() -> Self {
NotesLayout::Auto
}
}
pub fn write(
slides: &[Slide],
theme: &Theme,
layout: &Layout,
deck_title: &str,
author: &str,
base_dir: &Path,
logo: Option<&Path>,
handout: Option<u32>,
transition: Option<&str>,
transition_dur: f32,
direction: Option<&str>,
with_notes: bool,
notes_page_size: NotesPageSize,
notes_layout: NotesLayout,
cjk_font: Option<&Path>,
) -> Result<Vec<u8>> {
let mut font_options = crate::font::PdfFontOptions::default();
if let Some(path) = cjk_font {
font_options.fallback_fonts.push(path);
}
write_with_font_options(
slides,
theme,
layout,
deck_title,
author,
base_dir,
logo,
handout,
transition,
transition_dur,
direction,
with_notes,
notes_page_size,
notes_layout,
font_options,
)
}
pub fn write_with_font_options(
slides: &[Slide],
theme: &Theme,
layout: &Layout,
deck_title: &str,
author: &str,
base_dir: &Path,
logo: Option<&Path>,
handout: Option<u32>,
transition: Option<&str>,
transition_dur: f32,
direction: Option<&str>,
with_notes: bool,
notes_page_size: NotesPageSize,
notes_layout: NotesLayout,
font_options: crate::font::PdfFontOptions<'_>,
) -> Result<Vec<u8>> {
let _ = direction;
let mut metas: Vec<ImageMeta> = Vec::new();
let mut by_src: HashMap<String, usize> = HashMap::new();
for (idx, slide) in slides.iter().enumerate() {
let slide_num = idx + 1;
collect_block_images(&slide.blocks, base_dir, &mut metas, &mut by_src)
.with_context(|| format!("on slide {} ({:?})", slide_num, slide.title))?;
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 {} (slide {})", bg, slide_num)
})?;
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 mut decoded_images: Vec<DecodedImage> = Vec::new();
for m in &metas {
decoded_images.push(decode_image(m)?);
}
let mut pdf = PdfWriter::new(deck_title, author, theme.slide_w, theme.slide_h);
pdf.catalog_id = pdf.alloc_id();
pdf.pages_id = pdf.alloc_id();
pdf.info_id = pdf.alloc_id();
for _ in 0..FONT_COUNT {
let id = pdf.alloc_id();
pdf.font_ids.push(id);
}
for _ in 0..decoded_images.len() {
let id = pdf.alloc_id();
pdf.image_ids.push(id);
}
let page_w_pt = (theme.slide_w as f32 / EMU_PER_PT).round() as u32;
let page_h_pt = (theme.slide_h as f32 / EMU_PER_PT).round() as u32;
let imgs = Imgs {
by_src: &by_src,
metas: &metas,
logo_key: logo_key.as_deref(),
};
let fonts = PdfFonts::load_with_options(font_options)?;
for _ in FACE_COUNT..fonts.face_count() {
let id = pdf.alloc_id();
pdf.font_ids.push(id);
}
pdf.write_header();
pdf.write_info();
pdf.used_glyphs = vec![std::collections::HashSet::new(); fonts.face_count()];
pdf.write_images(&decoded_images);
let mut all_links: Vec<Vec<LinkRect>> = Vec::with_capacity(slides.len());
let mut all_contents: Vec<Vec<u8>> = Vec::with_capacity(slides.len());
for (i, slide) in slides.iter().enumerate() {
let num = i + 1;
let mut sr = SlideRenderer::new(theme, layout, page_w_pt, page_h_pt, &fonts);
sr.render(slide, num, slides.len(), deck_title, &imgs);
let (content, links, slide_glyphs) = sr.finish();
all_contents.push(content);
all_links.push(links);
for (face_idx, set) in slide_glyphs.into_iter().enumerate() {
pdf.used_glyphs[face_idx].extend(set);
}
}
if let Some(n_per) = handout {
if !matches!(n_per, 2 | 4 | 6) {
anyhow::bail!(
"--handout supports 2, 4, or 6 slides per page (got {})",
n_per
);
}
write_handout(&mut pdf, &all_contents, page_w_pt, page_h_pt, n_per, &fonts)?;
} else if with_notes {
let notes: Vec<Option<&str>> = slides.iter().map(|s| s.notes.as_deref()).collect();
write_notes_pages(
&mut pdf,
&all_contents,
¬es,
page_w_pt,
page_h_pt,
notes_page_size,
notes_layout,
&fonts,
)?;
} else {
write_slide_pages(
&mut pdf,
&all_contents,
&all_links,
page_w_pt,
page_h_pt,
transition,
transition_dur,
);
}
for set in &mut pdf.used_glyphs {
set.insert(0);
}
let used = std::mem::take(&mut pdf.used_glyphs);
pdf.write_fonts(&fonts, &used);
pdf.write_catalog();
pdf.write_xref_and_trailer();
Ok(pdf.buf)
}
fn write_slide_pages(
pdf: &mut PdfWriter,
all_contents: &[Vec<u8>],
all_links: &[Vec<LinkRect>],
page_w_pt: u32,
page_h_pt: u32,
transition: Option<&str>,
transition_dur: f32,
) {
let n = all_contents.len();
let mut slide_page_ids: Vec<u32> = (0..n).map(|_| pdf.alloc_id()).collect();
let slide_content_ids: Vec<u32> = (0..n).map(|_| pdf.alloc_id()).collect();
let mut slide_annot_ids: Vec<Vec<u32>> = Vec::with_capacity(n);
for links in all_links {
let ids: Vec<u32> = (0..links.len()).map(|_| pdf.alloc_id()).collect();
slide_annot_ids.push(ids);
}
let trans_dict = pdf_transition_dict(transition, transition_dur);
for i in 0..n {
let compressed = deflate(&all_contents[i]);
pdf.write_compressed_stream(slide_content_ids[i], &compressed);
for (link, annot_id) in all_links[i].iter().zip(slide_annot_ids[i].iter()) {
pdf.write_link_annot(*annot_id, link);
}
pdf.write_page_with_trans(
slide_page_ids[i],
slide_content_ids[i],
page_w_pt,
page_h_pt,
&slide_annot_ids[i],
&trans_dict,
);
}
pdf.write_pages_tree(&slide_page_ids);
let _ = &mut slide_page_ids;
}
fn pdf_transition_dict(kind: Option<&str>, duration_s: f32) -> 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 s = match kind.as_str() {
"fade" => "/Fade",
"push" => "/Push",
"wipe" => "/Wipe",
"cover" => "/Cover",
"split" => "/Split",
_ => return String::new(),
};
let d = duration_s.clamp(0.05, 5.0);
format!(" /Trans << /Type /Trans /S {s} /D {d:.3} >>")
}
fn write_handout(
pdf: &mut PdfWriter,
all_contents: &[Vec<u8>],
slide_w_pt: u32,
slide_h_pt: u32,
n_per_page: u32,
fonts: &PdfFonts,
) -> Result<()> {
let page_w: f32 = 595.0;
let page_h: f32 = 842.0;
let margin: f32 = 36.0;
let gutter: f32 = 18.0;
let (cols, rows) = match n_per_page {
2 => (1u32, 2u32),
4 => (2, 2),
6 => (2, 3),
_ => unreachable!(),
};
let caption_gap: f32 = 14.0;
let usable_w = page_w - 2.0 * margin - gutter * (cols as f32 - 1.0);
let usable_h =
page_h - 2.0 * margin - (gutter + caption_gap) * (rows as f32 - 1.0) - caption_gap;
let max_cell_w = usable_w / cols as f32;
let max_cell_h = usable_h / rows as f32;
let scale_x = max_cell_w / slide_w_pt as f32;
let scale_y = max_cell_h / slide_h_pt as f32;
let scale = scale_x.min(scale_y);
let thumb_w = scale * slide_w_pt as f32;
let thumb_h = scale * slide_h_pt as f32;
let cell_w = thumb_w;
let cell_h = thumb_h;
let grid_w = cols as f32 * cell_w + gutter * (cols as f32 - 1.0);
let grid_h = rows as f32 * cell_h + (gutter + caption_gap) * (rows as f32 - 1.0) + caption_gap;
let grid_x = (page_w - grid_w) / 2.0;
let grid_y_top = page_h - (page_h - grid_h) / 2.0;
let form_ids: Vec<u32> = (0..all_contents.len()).map(|_| pdf.alloc_id()).collect();
for (i, content) in all_contents.iter().enumerate() {
pdf.write_form_xobject(form_ids[i], content, slide_w_pt, slide_h_pt);
}
let per_page = (cols * rows) as usize;
let n_pages = (all_contents.len() + per_page - 1) / per_page;
let handout_page_ids: Vec<u32> = (0..n_pages).map(|_| pdf.alloc_id()).collect();
let handout_content_ids: Vec<u32> = (0..n_pages).map(|_| pdf.alloc_id()).collect();
for page_idx in 0..n_pages {
let mut ops = Vec::new();
let start = page_idx * per_page;
let end = ((page_idx + 1) * per_page).min(all_contents.len());
for (slot, slide_idx) in (start..end).enumerate() {
let col = slot as u32 % cols;
let row = slot as u32 / cols;
let cell_x = grid_x + col as f32 * (cell_w + gutter);
let row_top = grid_y_top - row as f32 * (cell_h + gutter + caption_gap);
let cell_y = row_top - cell_h;
let tx = cell_x;
let ty = cell_y;
let _ = write!(
&mut ops,
"q {sx:.5} 0 0 {sy:.5} {tx:.3} {ty:.3} cm /S{i} Do Q\n",
sx = scale,
sy = scale,
tx = tx,
ty = ty,
i = slide_idx + 1,
);
let caption_y = ty - 12.0;
if caption_y > 4.0 {
let label = format!("{} / {}", slide_idx + 1, all_contents.len());
let label_w = text_width_pt(fonts, &label, 0, 8.0);
let label_x = tx + (thumb_w - label_w) / 2.0;
let hex = glyph_hex_string(fonts, &label, 0);
pdf.record_glyphs(0, &hex);
let _ = write!(
&mut ops,
"BT\n/F1 8 Tf\n0.4 0.4 0.4 rg\n{:.3} {:.3} Td\n{} Tj\nET\n",
label_x, caption_y, hex,
);
}
}
let compressed = deflate(&ops);
pdf.write_compressed_stream(handout_content_ids[page_idx], &compressed);
pdf.write_handout_page(
handout_page_ids[page_idx],
handout_content_ids[page_idx],
page_w as u32,
page_h as u32,
&form_ids,
);
}
pdf.write_pages_tree(&handout_page_ids);
Ok(())
}
fn write_notes_pages(
pdf: &mut PdfWriter,
all_contents: &[Vec<u8>],
notes: &[Option<&str>],
slide_w_pt: u32,
slide_h_pt: u32,
notes_page_size: NotesPageSize,
notes_layout: NotesLayout,
fonts: &PdfFonts,
) -> Result<()> {
let (page_w, page_h) = match notes_page_size {
NotesPageSize::Slide => (slide_w_pt as f32, slide_h_pt as f32),
NotesPageSize::A4 => (595.0, 842.0),
};
let margin: f32 = page_w.min(page_h) * 0.055;
let margin = margin.clamp(24.0, 42.0);
let header_h: f32 = 16.0;
let gap: f32 = (page_w.min(page_h) * 0.035).clamp(14.0, 26.0);
let content_top = page_h - margin - header_h - gap;
let content_bottom = margin;
let content_h = (content_top - content_bottom).max(80.0);
let side_by_side = match notes_layout {
NotesLayout::Auto => page_w > page_h * 1.15,
NotesLayout::SideBySide => true,
NotesLayout::Below => false,
};
let (scale, thumb_x, thumb_y, notes_left, notes_right, notes_top, notes_bottom, divider) =
if side_by_side {
let thumb_max_w = (page_w - 2.0 * margin - gap) * 0.58;
let thumb_max_h = content_h;
let scale = (thumb_max_w / slide_w_pt as f32).min(thumb_max_h / slide_h_pt as f32);
let thumb_w = scale * slide_w_pt as f32;
let thumb_h = scale * slide_h_pt as f32;
let thumb_x = margin;
let thumb_y = content_bottom + (content_h - thumb_h) / 2.0;
let notes_left = thumb_x + thumb_w + gap;
let notes_right = page_w - margin;
(
scale,
thumb_x,
thumb_y,
notes_left,
notes_right,
content_top,
content_bottom,
NotesDivider::Vertical {
x: notes_left - gap / 2.0,
y: content_bottom,
h: content_h,
},
)
} else {
let thumb_max_w = page_w - 2.0 * margin;
let thumb_max_h = content_h * 0.48;
let scale = (thumb_max_w / slide_w_pt as f32).min(thumb_max_h / slide_h_pt as f32);
let thumb_w = scale * slide_w_pt as f32;
let thumb_h = scale * slide_h_pt as f32;
let thumb_x = (page_w - thumb_w) / 2.0;
let thumb_y = content_top - thumb_h;
let notes_top = thumb_y - gap;
(
scale,
thumb_x,
thumb_y,
margin,
page_w - margin,
notes_top,
content_bottom,
NotesDivider::Horizontal {
x: margin,
y: notes_top + gap * 0.35,
w: page_w - 2.0 * margin,
},
)
};
let notes_w_pt = (notes_right - notes_left).max(80.0);
let notes_size_pt: f32 = 10.0;
let notes_line_h: f32 = notes_size_pt * 1.4;
let n = all_contents.len();
let form_ids: Vec<u32> = (0..n).map(|_| pdf.alloc_id()).collect();
for (i, content) in all_contents.iter().enumerate() {
pdf.write_form_xobject(form_ids[i], content, slide_w_pt, slide_h_pt);
}
let page_ids: Vec<u32> = (0..n).map(|_| pdf.alloc_id()).collect();
let content_ids: Vec<u32> = (0..n).map(|_| pdf.alloc_id()).collect();
for i in 0..n {
let mut ops = Vec::new();
let header = format!("Slide {} / {}", i + 1, n);
let header_hex = glyph_hex_string(fonts, &header, 0);
pdf.record_glyphs(0, &header_hex);
let _ = write!(
&mut ops,
"BT\n/F1 11 Tf\n0.4 0.4 0.4 rg\n{:.3} {:.3} Td\n{} Tj\nET\n",
margin,
page_h - margin,
header_hex,
);
let _ = write!(
&mut ops,
"q {sx:.5} 0 0 {sy:.5} {tx:.3} {ty:.3} cm /S{idx} Do Q\n",
sx = scale,
sy = scale,
tx = thumb_x,
ty = thumb_y,
idx = i + 1,
);
match divider {
NotesDivider::Horizontal { x, y, w } => {
let _ = write!(
&mut ops,
"0.85 0.89 0.94 rg\n{x:.3} {y:.3} {w:.3} 0.8 re f\n",
x = x,
y = y,
w = w,
);
}
NotesDivider::Vertical { x, y, h } => {
let _ = write!(
&mut ops,
"0.85 0.89 0.94 rg\n{x:.3} {y:.3} 0.8 {h:.3} re f\n",
x = x,
y = y,
h = h,
);
}
}
let body = notes[i].unwrap_or("โ no notes โ");
let lines = wrap_text_simple(
fonts,
body,
0, notes_size_pt,
notes_w_pt,
);
let mut y = notes_top - notes_line_h;
for line in lines {
if y < notes_bottom {
break;
}
let hex = glyph_hex_string(fonts, &line, 0);
pdf.record_glyphs(0, &hex);
let _ = write!(
&mut ops,
"BT\n/F1 {sz:.2} Tf\n0.12 0.16 0.23 rg\n{x:.3} {y:.3} Td\n{hex} Tj\nET\n",
sz = notes_size_pt,
x = notes_left,
y = y,
);
y -= notes_line_h;
}
let compressed = deflate(&ops);
pdf.write_compressed_stream(content_ids[i], &compressed);
pdf.write_handout_page(
page_ids[i],
content_ids[i],
page_w as u32,
page_h as u32,
&form_ids,
);
}
pdf.write_pages_tree(&page_ids);
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum NotesDivider {
Horizontal { x: f32, y: f32, w: f32 },
Vertical { x: f32, y: f32, h: f32 },
}
struct DecodedImage {
width: u32,
height: u32,
filter: &'static str,
data: Vec<u8>,
colorspace: &'static str,
bpc: u8,
}
fn decode_image(m: &ImageMeta) -> Result<DecodedImage> {
match m.ext {
"jpeg" => Ok(DecodedImage {
width: m.width,
height: m.height,
filter: "DCTDecode",
data: m.bytes.clone(),
colorspace: "DeviceRGB",
bpc: 8,
}),
"png" => decode_png(m).context("decode PNG for PDF embed"),
_ => anyhow::bail!("unsupported image format for PDF: {}", m.ext),
}
}
fn decode_png(m: &ImageMeta) -> Result<DecodedImage> {
let bytes = &m.bytes;
if bytes.len() < 8 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" {
anyhow::bail!("not a PNG");
}
let mut idat = Vec::new();
let mut width = 0u32;
let mut height = 0u32;
let mut bit_depth = 8u8;
let mut color_type = 2u8;
let mut palette: Option<Vec<[u8; 3]>> = None;
let mut i = 8usize;
while i + 8 <= bytes.len() {
let len = u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]) as usize;
let kind = &bytes[i + 4..i + 8];
let data_start = i + 8;
let data_end = data_start + len;
if data_end > bytes.len() {
anyhow::bail!("PNG chunk truncated");
}
let data = &bytes[data_start..data_end];
match kind {
b"IHDR" => {
width = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
height = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
bit_depth = data[8];
color_type = data[9];
}
b"PLTE" => {
let mut p = Vec::new();
for c in data.chunks(3) {
if c.len() == 3 {
p.push([c[0], c[1], c[2]]);
}
}
palette = Some(p);
}
b"IDAT" => {
idat.extend_from_slice(data);
}
b"IEND" => break,
_ => {}
}
i = data_end + 4; }
if bit_depth != 8 {
anyhow::bail!(
"PNG bit depth {} not supported (PDF embed expects 8)",
bit_depth
);
}
let bpp = match color_type {
0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4, other => anyhow::bail!("PNG color type {} not supported", other),
};
let raw = inflate(&idat).context("inflate IDAT")?;
let stride = (width as usize) * bpp;
if raw.len() != (stride + 1) * height as usize {
anyhow::bail!(
"PNG data length mismatch: got {}, expected {}",
raw.len(),
(stride + 1) * height as usize,
);
}
let mut unfiltered = vec![0u8; stride * height as usize];
for row in 0..height as usize {
let filter = raw[row * (stride + 1)];
let src = &raw[row * (stride + 1) + 1..(row + 1) * (stride + 1)];
let dst_start = row * stride;
let prev_row_start = if row > 0 { (row - 1) * stride } else { 0 };
for col in 0..stride {
let left = if col >= bpp {
unfiltered[dst_start + col - bpp]
} else {
0
};
let up = if row > 0 {
unfiltered[prev_row_start + col]
} else {
0
};
let up_left = if row > 0 && col >= bpp {
unfiltered[prev_row_start + col - bpp]
} else {
0
};
let raw_byte = src[col];
let value = match filter {
0 => raw_byte,
1 => raw_byte.wrapping_add(left),
2 => raw_byte.wrapping_add(up),
3 => raw_byte.wrapping_add(((left as u16 + up as u16) / 2) as u8),
4 => raw_byte.wrapping_add(paeth(left, up, up_left)),
_ => anyhow::bail!("PNG filter {} unsupported", filter),
};
unfiltered[dst_start + col] = value;
}
}
let rgb = match color_type {
2 => unfiltered, 6 => {
let mut out = Vec::with_capacity((width * height * 3) as usize);
for px in unfiltered.chunks_exact(4) {
let a = px[3] as u32;
let blend = |c: u8| -> u8 {
let v = (c as u32) * a + 255 * (255 - a);
(v / 255) as u8
};
out.push(blend(px[0]));
out.push(blend(px[1]));
out.push(blend(px[2]));
}
out
}
0 => {
let mut out = Vec::with_capacity((width * height * 3) as usize);
for &g in &unfiltered {
out.push(g);
out.push(g);
out.push(g);
}
out
}
4 => {
let mut out = Vec::with_capacity((width * height * 3) as usize);
for px in unfiltered.chunks_exact(2) {
let g = px[0];
let a = px[1] as u32;
let blend = |c: u8| -> u8 {
let v = (c as u32) * a + 255 * (255 - a);
(v / 255) as u8
};
out.push(blend(g));
out.push(blend(g));
out.push(blend(g));
}
out
}
3 => {
let pal = palette.unwrap_or_default();
let mut out = Vec::with_capacity((width * height * 3) as usize);
for &idx in &unfiltered {
let c = pal.get(idx as usize).copied().unwrap_or([0, 0, 0]);
out.push(c[0]);
out.push(c[1]);
out.push(c[2]);
}
out
}
_ => unreachable!(),
};
let deflated = deflate(&rgb);
Ok(DecodedImage {
width,
height,
filter: "FlateDecode",
data: deflated,
colorspace: "DeviceRGB",
bpc: 8,
})
}
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let p = a as i32 + b as i32 - c as i32;
let pa = (p - a as i32).abs();
let pb = (p - b as i32).abs();
let pc = (p - c as i32).abs();
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
fn inflate(data: &[u8]) -> Result<Vec<u8>> {
use flate2::read::ZlibDecoder;
use std::io::Read;
let mut decoder = ZlibDecoder::new(data);
let mut out = Vec::new();
decoder.read_to_end(&mut out)?;
Ok(out)
}
fn deflate(data: &[u8]) -> Vec<u8> {
let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
e.write_all(data).expect("deflate");
e.finish().expect("deflate finish")
}
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(())
}
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 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))
}
}
use crate::font::{FaceKind, FaceMetrics, PdfFonts, FACE_COUNT};
const FONT_COUNT: usize = FACE_COUNT;
const FONT_HELV: usize = 0; const FONT_HELV_BOLD: usize = 1; const FONT_HELV_OBL: usize = 2; const FONT_HELV_BOLD_OBL: usize = 3; const FONT_COUR: usize = 4; const FONT_COUR_BOLD: usize = 4;
fn face_for_index(idx: usize) -> FaceKind {
match idx {
0 => FaceKind::SansRegular,
1 => FaceKind::SansBold,
2 => FaceKind::SansOblique,
3 => FaceKind::SansBoldOblique,
_ => FaceKind::Mono,
}
}
fn font_index(bold: bool, italic: bool, mono: bool) -> usize {
match (mono, bold, italic) {
(true, _, _) => FONT_COUR,
(false, false, false) => FONT_HELV,
(false, true, false) => FONT_HELV_BOLD,
(false, false, true) => FONT_HELV_OBL,
(false, true, true) => FONT_HELV_BOLD_OBL,
}
}
fn runs_width_emu(fonts: &PdfFonts, line: &[Run], size_centipt: u32, base_bold: bool) -> u32 {
let size_pt = size_centipt as f32 / 100.0;
let w: f32 = line
.iter()
.map(|r| {
let idx = font_index(base_bold || r.bold, r.italic, r.code);
text_width_pt(fonts, &r.text, idx, size_pt)
})
.sum();
(w * EMU_PER_PT) as u32
}
fn aligned_cell_x(
align: Option<crate::ir::ColumnAlign>,
col_left: u32,
col_w: u32,
pad_x: u32,
line_w: u32,
) -> u32 {
match align {
Some(crate::ir::ColumnAlign::Center) => col_left + col_w.saturating_sub(line_w) / 2,
Some(crate::ir::ColumnAlign::Right) => {
col_left + col_w.saturating_sub(pad_x).saturating_sub(line_w)
}
_ => col_left + pad_x,
}
}
fn text_width_pt(fonts: &PdfFonts, text: &str, font_idx: usize, size_pt: f32) -> f32 {
let mut total = 0.0_f32;
for c in text.chars() {
let (face_idx, gid) = pick_face(fonts, font_idx, c);
let face = &fonts.metrics[face_idx];
total += face.glyph_width_pt(gid, size_pt);
}
total
}
fn pick_face(fonts: &PdfFonts, primary: usize, c: char) -> (usize, u16) {
fonts.face_for_char(primary, c).unwrap_or((primary, 0))
}
fn build_cmap_body(pairs: &[(u16, char)], font_name: &str) -> String {
let mut s = String::with_capacity(pairs.len() * 24);
s.push_str(
"/CIDInit /ProcSet findresource begin\n\
12 dict begin\n\
begincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n",
);
s.push_str(&format!("/CMapName /{}-UCS def\n", font_name));
s.push_str(
"/CMapType 2 def\n\
1 begincodespacerange\n\
<0000> <FFFF>\n\
endcodespacerange\n",
);
for chunk in pairs.chunks(100) {
s.push_str(&format!("{} beginbfchar\n", chunk.len()));
for (gid, c) in chunk {
s.push_str(&format!("<{:04X}> {}\n", gid, utf16be_hex(*c)));
}
s.push_str("endbfchar\n");
}
s.push_str(
"endcmap\n\
CMapName currentdict /CMap defineresource pop\n\
end\nend\n",
);
s
}
fn build_tounicode_cmap(face: &FaceMetrics, ttf: &[u8], font_name: &str) -> String {
build_cmap_body(&face.cid_to_unicode(ttf), font_name)
}
fn utf16be_hex(c: char) -> String {
let cp = c as u32;
if cp <= 0xFFFF {
format!("<{:04X}>", cp)
} else {
let v = cp - 0x10000;
let hi = 0xD800 + (v >> 10);
let lo = 0xDC00 + (v & 0x3FF);
format!("<{:04X}{:04X}>", hi, lo)
}
}
fn glyph_hex_string(fonts: &PdfFonts, text: &str, font_idx: usize) -> String {
let face = &fonts.metrics[font_idx];
let mut out = String::with_capacity(text.len() * 4 + 2);
out.push('<');
for c in text.chars() {
let gid = face.glyph_for_char(&fonts.bytes[font_idx], c).unwrap_or(0);
out.push_str(&format!("{:04X}", gid));
}
out.push('>');
out
}
fn record_glyphs_from_hex(set: &mut std::collections::HashSet<u16>, hex: &str) {
let stripped = hex.trim_start_matches('<').trim_end_matches('>');
let mut bytes = stripped.as_bytes();
while bytes.len() >= 4 {
let s = std::str::from_utf8(&bytes[..4]).unwrap_or("0000");
if let Ok(gid) = u16::from_str_radix(s, 16) {
set.insert(gid);
}
bytes = &bytes[4..];
}
}
fn font_has_cff_outlines(bytes: &[u8]) -> bool {
bytes.starts_with(b"OTTO")
}
fn subset_font(
ttf: &[u8],
used: &std::collections::HashSet<u16>,
) -> anyhow::Result<(Vec<u8>, subsetter::GlyphRemapper)> {
let mut glyphs: Vec<u16> = used.iter().copied().collect();
glyphs.sort_unstable();
let remapper = subsetter::GlyphRemapper::new_from_glyphs_sorted(&glyphs);
let out = subsetter::subset(ttf, 0, &remapper)
.map_err(|e| anyhow::anyhow!("subset font: {:?}", e))?;
Ok((out.to_vec(), remapper))
}
fn glyph_hex_runs(
fonts: &PdfFonts,
text: &str,
primary: usize,
size_pt: f32,
) -> Vec<(usize, String, f32)> {
let mut runs: Vec<(usize, String, f32)> = Vec::new();
for c in text.chars() {
let (face_idx, gid) = pick_face(fonts, primary, c);
let advance = fonts.metrics[face_idx].glyph_width_pt(gid, size_pt);
let hex_pair = format!("{:04X}", gid);
match runs.last_mut() {
Some((f, hex, a)) if *f == face_idx => {
hex.push_str(&hex_pair);
*a += advance;
}
_ => runs.push((face_idx, hex_pair, advance)),
}
}
for (_, hex, _) in &mut runs {
let wrapped = format!("<{}>", hex);
*hex = wrapped;
}
runs
}
fn unicode_to_winansi(c: char) -> Option<u8> {
if (c as u32) < 0x80 {
return Some(c as u8);
}
match c {
'โฌ' => Some(0x80),
'โ' => Some(0x82),
'ฦ' => Some(0x83),
'โ' => Some(0x84),
'โฆ' => Some(0x85),
'โ ' => Some(0x86),
'โก' => Some(0x87),
'ห' => Some(0x88),
'โฐ' => Some(0x89),
'ล ' => Some(0x8A),
'โน' => Some(0x8B),
'ล' => Some(0x8C),
'ลฝ' => Some(0x8E),
'โ' => Some(0x91),
'โ' => Some(0x92),
'โ' => Some(0x93),
'โ' => Some(0x94),
'โข' => Some(0x95),
'โ' => Some(0x96),
'โ' => Some(0x97),
'ห' => Some(0x98),
'โข' => Some(0x99),
'ลก' => Some(0x9A),
'โบ' => Some(0x9B),
'ล' => Some(0x9C),
'ลพ' => Some(0x9E),
'ลธ' => Some(0x9F),
'ยท' => Some(0xB7),
'โ' => Some(0x95), 'โ' => Some(b'o'),
'โช' => Some(0x95),
c if (c as u32) >= 0xA0 && (c as u32) <= 0xFF => Some(c as u8),
_ => None,
}
}
fn encode_winansi(s: &str) -> Vec<u8> {
let mut prev = SubSuperKind::Plain;
let mut out = Vec::with_capacity(s.len());
for c in s.chars() {
if let Some(b) = unicode_to_winansi(c) {
out.push(b);
prev = SubSuperKind::Plain;
} else if let Some((kind, fallback)) = math_ascii_fallback(c) {
if kind != SubSuperKind::Plain && kind != prev {
out.push(match kind {
SubSuperKind::Super => b'^',
SubSuperKind::Sub => b'_',
SubSuperKind::Plain => unreachable!(),
});
}
out.extend_from_slice(fallback.as_bytes());
prev = kind;
} else {
prev = SubSuperKind::Plain;
}
}
out
}
#[derive(Clone, Copy, PartialEq)]
enum SubSuperKind {
Plain,
Super,
Sub,
}
fn math_ascii_fallback(c: char) -> Option<(SubSuperKind, &'static str)> {
use SubSuperKind::*;
Some(match c {
'ฮฑ' => (Plain, "alpha"),
'ฮฒ' => (Plain, "beta"),
'ฮณ' => (Plain, "gamma"),
'ฮด' => (Plain, "delta"),
'ฮต' => (Plain, "epsilon"),
'ฮถ' => (Plain, "zeta"),
'ฮท' => (Plain, "eta"),
'ฮธ' => (Plain, "theta"),
'ฮน' => (Plain, "iota"),
'ฮบ' => (Plain, "kappa"),
'ฮป' => (Plain, "lambda"),
'ฮผ' => (Plain, "mu"),
'ฮฝ' => (Plain, "nu"),
'ฮพ' => (Plain, "xi"),
'ฯ' => (Plain, "pi"),
'ฯ' => (Plain, "rho"),
'ฯ' => (Plain, "sigma"),
'ฯ' => (Plain, "sigma"),
'ฯ' => (Plain, "tau"),
'ฯ
' => (Plain, "upsilon"),
'ฯ' => (Plain, "phi"),
'ฯ' => (Plain, "phi"),
'ฯ' => (Plain, "theta"),
'ฯ' => (Plain, "pi"),
'ฯฑ' => (Plain, "rho"),
'ฯ' => (Plain, "chi"),
'ฯ' => (Plain, "psi"),
'ฯ' => (Plain, "omega"),
'ฮ' => (Plain, "A"),
'ฮ' => (Plain, "B"),
'ฮ' => (Plain, "Gamma"),
'ฮ' => (Plain, "Delta"),
'ฮ' => (Plain, "E"),
'ฮ' => (Plain, "Z"),
'ฮ' => (Plain, "H"),
'ฮ' => (Plain, "Theta"),
'ฮ' => (Plain, "I"),
'ฮ' => (Plain, "K"),
'ฮ' => (Plain, "Lambda"),
'ฮ' => (Plain, "M"),
'ฮ' => (Plain, "N"),
'ฮ' => (Plain, "Xi"),
'ฮ ' => (Plain, "Pi"),
'ฮก' => (Plain, "P"),
'ฮฃ' => (Plain, "Sigma"),
'ฮค' => (Plain, "T"),
'ฮฅ' => (Plain, "Y"),
'ฮฆ' => (Plain, "Phi"),
'ฮง' => (Plain, "X"),
'ฮจ' => (Plain, "Psi"),
'ฮฉ' => (Plain, "Omega"),
'โ' => (Plain, "Sum"),
'โ' => (Plain, "Prod"),
'โซ' => (Plain, "Int"),
'โฎ' => (Plain, "Int"),
'โ' => (Plain, "Union"),
'โ' => (Plain, "Inter"),
'โค' => (Plain, "<="),
'โฅ' => (Plain, ">="),
'โ ' => (Plain, "!="),
'โ' => (Plain, "~="),
'โก' => (Plain, "=="),
'โผ' => (Plain, "~"),
'โ' => (Plain, "~="),
'โ' => (Plain, "prop"),
'โ' => (Plain, "->"),
'โ' => (Plain, "<-"),
'โ' => (Plain, "<->"),
'โ' => (Plain, "=>"),
'โ' => (Plain, "<="),
'โ' => (Plain, "<=>"),
'โฆ' => (Plain, "|->"),
'ยฑ' => (Plain, "+/-"),
'โ' => (Plain, "-/+"),
'ร' => (Plain, "x"),
'รท' => (Plain, "/"),
'โช' => (Plain, "U"),
'โฉ' => (Plain, "n"),
'โ' => (Plain, "\\"),
'โ' => (Plain, "*"),
'โ' => (Plain, "*"),
'โ' => (Plain, "forall"),
'โ' => (Plain, "exists"),
'โ' => (Plain, "!exists"),
'โ' => (Plain, "in"),
'โ' => (Plain, "!in"),
'โ' => (Plain, "sub"),
'โ' => (Plain, "sup"),
'โ' => (Plain, "subeq"),
'โ' => (Plain, "supeq"),
'โง' => (Plain, "and"),
'โจ' => (Plain, "or"),
'ยฌ' => (Plain, "!"),
'โ' => (Plain, "inf"),
'โ
' => (Plain, "{}"),
'โ' => (Plain, "d"),
'โ' => (Plain, "grad"),
'โ' => (Plain, "hbar"),
'โ' => (Plain, "l"),
'โ' => (Plain, "Re"),
'โ' => (Plain, "Im"),
'โต' => (Plain, "aleph"),
'โฆ' => (Plain, "..."),
'โฏ' => (Plain, "..."),
'โฎ' => (Plain, ":"),
'โฑ' => (Plain, "..."),
'โ' => (Plain, "sqrt"),
'โฐ' => (Super, "0"),
'โด' => (Super, "4"),
'โต' => (Super, "5"),
'โถ' => (Super, "6"),
'โท' => (Super, "7"),
'โธ' => (Super, "8"),
'โน' => (Super, "9"),
'โบ' => (Super, "+"),
'โป' => (Super, "-"),
'โผ' => (Super, "="),
'โฝ' => (Super, "("),
'โพ' => (Super, ")"),
'โฟ' => (Super, "n"),
'โฑ' => (Super, "i"),
'แต' => (Super, "a"),
'แต' => (Super, "b"),
'แถ' => (Super, "c"),
'แต' => (Super, "d"),
'แต' => (Super, "e"),
'แถ ' => (Super, "f"),
'แต' => (Super, "g"),
'สฐ' => (Super, "h"),
'สฒ' => (Super, "j"),
'แต' => (Super, "k"),
'หก' => (Super, "l"),
'แต' => (Super, "m"),
'แต' => (Super, "o"),
'แต' => (Super, "p"),
'สณ' => (Super, "r"),
'หข' => (Super, "s"),
'แต' => (Super, "t"),
'แต' => (Super, "u"),
'แต' => (Super, "v"),
'สท' => (Super, "w"),
'หฃ' => (Super, "x"),
'สธ' => (Super, "y"),
'แถป' => (Super, "z"),
'แดฌ' => (Super, "A"),
'แดฎ' => (Super, "B"),
'แดฐ' => (Super, "D"),
'แดฑ' => (Super, "E"),
'แดณ' => (Super, "G"),
'แดด' => (Super, "H"),
'แดต' => (Super, "I"),
'แดถ' => (Super, "J"),
'แดท' => (Super, "K"),
'แดธ' => (Super, "L"),
'แดน' => (Super, "M"),
'แดบ' => (Super, "N"),
'แดผ' => (Super, "O"),
'แดพ' => (Super, "P"),
'แดฟ' => (Super, "R"),
'แต' => (Super, "T"),
'แต' => (Super, "U"),
'โฑฝ' => (Super, "V"),
'แต' => (Super, "W"),
'โ' => (Sub, "0"),
'โ' => (Sub, "1"),
'โ' => (Sub, "2"),
'โ' => (Sub, "3"),
'โ' => (Sub, "4"),
'โ
' => (Sub, "5"),
'โ' => (Sub, "6"),
'โ' => (Sub, "7"),
'โ' => (Sub, "8"),
'โ' => (Sub, "9"),
'โ' => (Sub, "+"),
'โ' => (Sub, "-"),
'โ' => (Sub, "="),
'โ' => (Sub, "("),
'โ' => (Sub, ")"),
'โ' => (Sub, "a"),
'โ' => (Sub, "e"),
'โ' => (Sub, "h"),
'แตข' => (Sub, "i"),
'โฑผ' => (Sub, "j"),
'โ' => (Sub, "k"),
'โ' => (Sub, "l"),
'โ' => (Sub, "m"),
'โ' => (Sub, "n"),
'โ' => (Sub, "o"),
'โ' => (Sub, "p"),
'แตฃ' => (Sub, "r"),
'โ' => (Sub, "s"),
'โ' => (Sub, "t"),
'แตค' => (Sub, "u"),
'แตฅ' => (Sub, "v"),
'โ' => (Sub, "x"),
_ => return None,
})
}
fn pdf_escape_string(s: &str) -> Vec<u8> {
let bytes = encode_winansi(s);
let mut out = Vec::with_capacity(bytes.len() + 8);
for b in bytes {
match b {
b'(' => out.extend_from_slice(b"\\("),
b')' => out.extend_from_slice(b"\\)"),
b'\\' => out.extend_from_slice(b"\\\\"),
0x0A => out.extend_from_slice(b"\\n"),
0x0D => out.extend_from_slice(b"\\r"),
b => out.push(b),
}
}
out
}
struct PdfWriter {
buf: Vec<u8>,
offsets: Vec<u64>,
next_id: u32,
catalog_id: u32,
pages_id: u32,
info_id: u32,
font_ids: Vec<u32>,
image_ids: Vec<u32>,
title: String,
author: String,
slide_w: u32,
slide_h: u32,
used_glyphs: Vec<std::collections::HashSet<u16>>,
}
impl PdfWriter {
fn new(title: &str, author: &str, slide_w: u32, slide_h: u32) -> Self {
PdfWriter {
buf: Vec::with_capacity(64 * 1024),
offsets: Vec::new(),
next_id: 0,
catalog_id: 0,
pages_id: 0,
info_id: 0,
font_ids: Vec::new(),
image_ids: Vec::new(),
title: title.to_string(),
author: author.to_string(),
slide_w,
slide_h,
used_glyphs: Vec::new(),
}
}
fn record_glyphs(&mut self, face: usize, hex: &str) {
while self.used_glyphs.len() <= face {
self.used_glyphs.push(std::collections::HashSet::new());
}
record_glyphs_from_hex(&mut self.used_glyphs[face], hex);
}
fn alloc_id(&mut self) -> u32 {
self.next_id += 1;
self.offsets.push(0);
self.next_id
}
fn write_header(&mut self) {
self.buf.extend_from_slice(b"%PDF-1.7\n");
self.buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
}
fn start_object(&mut self, id: u32) {
let offset = self.buf.len() as u64;
self.offsets[(id - 1) as usize] = offset;
self.buf
.extend_from_slice(format!("{} 0 obj\n", id).as_bytes());
}
fn end_object(&mut self) {
self.buf.extend_from_slice(b"\nendobj\n");
}
fn write_info(&mut self) {
self.start_object(self.info_id);
self.buf.extend_from_slice(b"<<\n");
self.buf.extend_from_slice(b"/Title (");
self.buf
.extend_from_slice(&pdf_escape_string(&self.title.clone()));
self.buf.extend_from_slice(b")\n");
self.buf.extend_from_slice(b"/Author (");
self.buf
.extend_from_slice(&pdf_escape_string(&self.author.clone()));
self.buf.extend_from_slice(b")\n");
self.buf.extend_from_slice(b"/Producer (md2any)\n");
self.buf.extend_from_slice(b"/Creator (md2any)\n");
self.buf.extend_from_slice(b">>");
self.end_object();
}
fn write_fonts(&mut self, fonts: &PdfFonts, used: &[std::collections::HashSet<u16>]) {
for i in 0..fonts.face_count() {
let face_metrics = &fonts.metrics[i];
let is_cff = font_has_cff_outlines(&fonts.bytes[i]);
let cidfont_id = self.alloc_id();
let descriptor_id = self.alloc_id();
let fontfile_id = self.alloc_id();
let tounicode_id = self.alloc_id();
let cidtogid_id = if is_cff { None } else { Some(self.alloc_id()) };
let (subset, remapper): (Vec<u8>, Option<subsetter::GlyphRemapper>) = if is_cff {
(fonts.bytes[i].clone(), None)
} else {
match subset_font(&fonts.bytes[i], &used[i]) {
Ok((bytes, remapper)) => (bytes, Some(remapper)),
Err(_) => {
let all: Vec<u16> = (0..face_metrics.num_glyphs).collect();
let identity = subsetter::GlyphRemapper::new_from_glyphs_sorted(&all);
(fonts.bytes[i].clone(), Some(identity))
}
}
};
let ttf_bytes: &[u8] = ⊂
let ps_name = fonts.names[i].as_str();
let kind = if i < FACE_COUNT {
Some(face_for_index(i))
} else {
None
};
self.start_object(self.font_ids[i]);
let body = format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{name} \
/Encoding /Identity-H /DescendantFonts [{cid} 0 R] \
/ToUnicode {tu} 0 R >>",
name = ps_name,
cid = cidfont_id,
tu = tounicode_id,
);
self.buf.extend_from_slice(body.as_bytes());
self.end_object();
let scale = 1000.0 / face_metrics.units_per_em as f32;
let mut used_sorted: Vec<u16> = used[i].iter().copied().collect();
used_sorted.sort_unstable();
let mut widths = String::with_capacity(used_sorted.len() * 12);
widths.push('[');
for orig_gid in &used_sorted {
let w = (face_metrics.glyph_width(*orig_gid) as f32 * scale).round() as i32;
widths.push_str(&format!("{gid} [{w}] ", gid = orig_gid, w = w));
}
widths.push(']');
self.start_object(cidfont_id);
let cid_subtype = if is_cff {
"CIDFontType0"
} else {
"CIDFontType2"
};
let cidtogid_entry = match cidtogid_id {
Some(id) => format!(" /CIDToGIDMap {id} 0 R"),
None => String::new(),
};
let cid_body = format!(
"<< /Type /Font /Subtype /{subtype} /BaseFont /{name} \
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
/FontDescriptor {desc} 0 R{ctgm} /W {w} >>",
subtype = cid_subtype,
name = ps_name,
desc = descriptor_id,
ctgm = cidtogid_entry,
w = widths,
);
self.buf.extend_from_slice(cid_body.as_bytes());
self.end_object();
let bbox = (
(face_metrics.bbox.0 as f32 * scale).round() as i32,
(face_metrics.bbox.1 as f32 * scale).round() as i32,
(face_metrics.bbox.2 as f32 * scale).round() as i32,
(face_metrics.bbox.3 as f32 * scale).round() as i32,
);
let ascent = (face_metrics.ascent as f32 * scale).round() as i32;
let descent = (face_metrics.descent as f32 * scale).round() as i32;
let cap_height = (face_metrics.cap_height as f32 * scale).round() as i32;
let mut flags = 32u32;
if matches!(kind, Some(FaceKind::Mono)) {
flags |= 1; }
if matches!(
kind,
Some(FaceKind::SansOblique | FaceKind::SansBoldOblique)
) {
flags |= 64; }
self.start_object(descriptor_id);
let fontfile_key = if is_cff { "FontFile3" } else { "FontFile2" };
let desc_body = format!(
"<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \
/FontBBox [{bx0} {by0} {bx1} {by1}] /ItalicAngle {ia} \
/Ascent {asc} /Descent {dsc} /CapHeight {cap} /StemV 80 \
/{fontfile_key} {file} 0 R >>",
name = ps_name,
flags = flags,
bx0 = bbox.0,
by0 = bbox.1,
bx1 = bbox.2,
by1 = bbox.3,
ia = face_metrics.italic_angle as i32,
asc = ascent,
dsc = descent,
cap = cap_height,
file = fontfile_id,
);
self.buf.extend_from_slice(desc_body.as_bytes());
self.end_object();
let compressed = deflate(ttf_bytes);
self.start_object(fontfile_id);
let header = if is_cff {
format!(
"<< /Length {len} /Subtype /OpenType /Filter /FlateDecode >>\nstream\n",
len = compressed.len(),
)
} else {
format!(
"<< /Length {len} /Length1 {orig} /Filter /FlateDecode >>\nstream\n",
len = compressed.len(),
orig = ttf_bytes.len(),
)
};
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(&compressed);
self.buf.extend_from_slice(b"\nendstream");
self.end_object();
let cmap = build_tounicode_cmap(face_metrics, &fonts.bytes[i], ps_name);
let cmap_compressed = deflate(cmap.as_bytes());
self.start_object(tounicode_id);
let header = format!(
"<< /Length {len} /Filter /FlateDecode >>\nstream\n",
len = cmap_compressed.len(),
);
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(&cmap_compressed);
self.buf.extend_from_slice(b"\nendstream");
self.end_object();
if let (Some(cidtogid_id), Some(remapper)) = (cidtogid_id, remapper.as_ref()) {
let max_orig = used[i].iter().copied().max().unwrap_or(0);
let map_len = (max_orig as usize + 1) * 2;
let mut map_bytes = vec![0u8; map_len];
for &orig_gid in &used[i] {
if let Some(new_gid) = remapper.get(orig_gid) {
let off = orig_gid as usize * 2;
map_bytes[off] = (new_gid >> 8) as u8;
map_bytes[off + 1] = (new_gid & 0xFF) as u8;
}
}
let map_compressed = deflate(&map_bytes);
self.start_object(cidtogid_id);
let header = format!(
"<< /Length {len} /Filter /FlateDecode >>\nstream\n",
len = map_compressed.len(),
);
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(&map_compressed);
self.buf.extend_from_slice(b"\nendstream");
self.end_object();
}
}
}
fn write_images(&mut self, images: &[DecodedImage]) {
for (i, img) in images.iter().enumerate() {
self.start_object(self.image_ids[i]);
let header = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /{} /BitsPerComponent {} /Filter /{} /Length {} >>\nstream\n",
img.width, img.height, img.colorspace, img.bpc, img.filter, img.data.len(),
);
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(&img.data);
self.buf.extend_from_slice(b"\nendstream");
self.end_object();
}
}
fn write_compressed_stream(&mut self, id: u32, data: &[u8]) {
self.start_object(id);
let header = format!(
"<< /Length {} /Filter /FlateDecode >>\nstream\n",
data.len()
);
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(data);
self.buf.extend_from_slice(b"\nendstream");
self.end_object();
}
fn write_form_xobject(&mut self, id: u32, content: &[u8], w_pt: u32, h_pt: u32) {
let compressed = deflate(content);
self.start_object(id);
let mut res = String::from("<< /Font << ");
for i in 0..self.font_ids.len() {
res.push_str(&format!("/F{} {} 0 R ", i + 1, self.font_ids[i]));
}
res.push_str(">>");
if !self.image_ids.is_empty() {
res.push_str(" /XObject << ");
for (i, im_id) in self.image_ids.iter().enumerate() {
res.push_str(&format!("/Im{} {} 0 R ", i + 1, im_id));
}
res.push_str(">>");
}
res.push_str(" >>");
let header = format!(
"<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 {w} {h}] /Resources {res} /Length {len} /Filter /FlateDecode >>\nstream\n",
w = w_pt,
h = h_pt,
res = res,
len = compressed.len(),
);
self.buf.extend_from_slice(header.as_bytes());
self.buf.extend_from_slice(&compressed);
self.buf.extend_from_slice(b"\nendstream\n");
self.end_object();
}
fn write_handout_page(
&mut self,
page_id: u32,
content_id: u32,
w_pt: u32,
h_pt: u32,
form_ids: &[u32],
) {
self.start_object(page_id);
let mut res = String::from("<< /Font << ");
for i in 0..self.font_ids.len() {
res.push_str(&format!("/F{} {} 0 R ", i + 1, self.font_ids[i]));
}
res.push_str(">>");
res.push_str(" /XObject << ");
for (i, fid) in form_ids.iter().enumerate() {
res.push_str(&format!("/S{} {} 0 R ", i + 1, fid));
}
res.push_str(">> >>");
let body = format!(
"<< /Type /Page /Parent {} 0 R /MediaBox [0 0 {} {}] /Resources {} /Contents {} 0 R >>",
self.pages_id, w_pt, h_pt, res, content_id,
);
self.buf.extend_from_slice(body.as_bytes());
self.end_object();
}
fn write_page_with_trans(
&mut self,
page_id: u32,
content_id: u32,
w_pt: u32,
h_pt: u32,
annot_ids: &[u32],
trans_dict: &str,
) {
self.start_object(page_id);
let mut res = String::from("<< /Font << ");
for i in 0..self.font_ids.len() {
res.push_str(&format!("/F{} {} 0 R ", i + 1, self.font_ids[i]));
}
res.push_str(">>");
if !self.image_ids.is_empty() {
res.push_str(" /XObject << ");
for (i, id) in self.image_ids.iter().enumerate() {
res.push_str(&format!("/Im{} {} 0 R ", i + 1, id));
}
res.push_str(">>");
}
res.push_str(" >>");
let annots = if annot_ids.is_empty() {
String::new()
} else {
let mut s = String::from(" /Annots [");
for id in annot_ids {
s.push_str(&format!("{} 0 R ", id));
}
s.push(']');
s
};
let body = format!(
"<< /Type /Page /Parent {} 0 R /MediaBox [0 0 {} {}] /Resources {} /Contents {} 0 R{}{} >>",
self.pages_id, w_pt, h_pt, res, content_id, annots, trans_dict,
);
self.buf.extend_from_slice(body.as_bytes());
self.end_object();
}
fn write_link_annot(&mut self, id: u32, rect: &LinkRect) {
self.start_object(id);
let uri = pdf_escape_string(&rect.uri);
let mut body = Vec::new();
let _ = write!(
&mut body,
"<< /Type /Annot /Subtype /Link /Rect [{:.3} {:.3} {:.3} {:.3}] /Border [0 0 0] /A << /Type /Action /S /URI /URI (",
rect.llx, rect.lly, rect.urx, rect.ury,
);
body.extend_from_slice(&uri);
body.extend_from_slice(b") >> >>");
self.buf.extend_from_slice(&body);
self.end_object();
}
fn write_pages_tree(&mut self, page_ids: &[u32]) {
self.start_object(self.pages_id);
let mut kids = String::from("[");
for id in page_ids {
kids.push_str(&format!("{} 0 R ", id));
}
kids.push(']');
let body = format!(
"<< /Type /Pages /Kids {} /Count {} >>",
kids,
page_ids.len(),
);
self.buf.extend_from_slice(body.as_bytes());
self.end_object();
}
fn write_catalog(&mut self) {
self.start_object(self.catalog_id);
let body = format!("<< /Type /Catalog /Pages {} 0 R >>", self.pages_id);
self.buf.extend_from_slice(body.as_bytes());
self.end_object();
}
fn write_xref_and_trailer(&mut self) {
let xref_offset = self.buf.len() as u64;
let total = self.next_id as usize + 1;
self.buf
.extend_from_slice(format!("xref\n0 {}\n", total).as_bytes());
self.buf.extend_from_slice(b"0000000000 65535 f \n");
for i in 0..self.next_id as usize {
self.buf
.extend_from_slice(format!("{:010} 00000 n \n", self.offsets[i]).as_bytes());
}
let trailer = format!(
"trailer\n<< /Size {} /Root {} 0 R /Info {} 0 R >>\nstartxref\n{}\n%%EOF\n",
total, self.catalog_id, self.info_id, xref_offset,
);
self.buf.extend_from_slice(trailer.as_bytes());
let _ = self.slide_w;
let _ = self.slide_h;
}
}
struct SlideRenderer<'a> {
theme: &'a Theme,
layout: &'a Layout,
page_w: u32,
page_h: u32,
ops: Vec<u8>,
links: Vec<LinkRect>,
fonts: &'a PdfFonts,
used_glyphs: Vec<std::collections::HashSet<u16>>,
}
struct LinkRect {
uri: String,
llx: f32,
lly: f32,
urx: f32,
ury: f32,
}
impl<'a> SlideRenderer<'a> {
fn new(
theme: &'a Theme,
layout: &'a Layout,
page_w: u32,
page_h: u32,
fonts: &'a PdfFonts,
) -> Self {
SlideRenderer {
theme,
layout,
page_w,
page_h,
ops: Vec::with_capacity(8 * 1024),
links: Vec::new(),
fonts,
used_glyphs: vec![std::collections::HashSet::new(); fonts.face_count()],
}
}
fn finish(self) -> (Vec<u8>, Vec<LinkRect>, Vec<std::collections::HashSet<u16>>) {
(self.ops, self.links, self.used_glyphs)
}
fn pt(&self, emu: u32) -> f32 {
emu as f32 / EMU_PER_PT
}
fn pdf_y(&self, emu_y: u32) -> f32 {
self.page_h as f32 - self.pt(emu_y)
}
fn fill_background(&mut self, hex: &str) {
let (r, g, b) = hex_to_rgb_f(hex);
let _ = write!(
&mut self.ops,
"{:.3} {:.3} {:.3} rg\n0 0 {} {} re f\n",
r, g, b, self.page_w, self.page_h
);
}
fn rect(&mut self, x: u32, y: u32, w: u32, h: u32, hex: &str) {
let (r, g, b) = hex_to_rgb_f(hex);
let xp = self.pt(x);
let wp = self.pt(w);
let hp = self.pt(h);
let yp = self.pdf_y(y) - hp;
let _ = write!(
&mut self.ops,
"{:.3} {:.3} {:.3} rg\n{:.3} {:.3} {:.3} {:.3} re f\n",
r, g, b, xp, yp, wp, hp,
);
}
fn text_line(
&mut self,
x: u32,
y: u32,
text: &str,
size_centipt: u32,
color_hex: &str,
bold: bool,
italic: bool,
mono: bool,
align: TextAlign,
max_w_emu: u32,
) -> usize {
let (r, g, b) = hex_to_rgb_f(color_hex);
let font_idx = font_index(bold, italic, mono);
let size_pt = size_centipt as f32 / 100.0;
let total_width = text_width_pt(self.fonts, text, font_idx, size_pt);
let max_w_pt = self.pt(max_w_emu);
let lines: Vec<String> = if total_width <= max_w_pt || max_w_pt <= 0.0 {
vec![text.to_string()]
} else {
wrap_text_simple(self.fonts, text, font_idx, size_pt, max_w_pt)
};
let line_h_pt = size_pt * 1.25;
let baseline_first = self.pdf_y(y) - size_pt * 0.78;
for (i, line_text) in lines.iter().enumerate() {
let line_width = text_width_pt(self.fonts, line_text, font_idx, size_pt);
let xp = match align {
TextAlign::Left => self.pt(x),
TextAlign::Center => self.pt(x) + (max_w_pt - line_width) / 2.0,
TextAlign::Right => self.pt(x) + max_w_pt - line_width,
};
let baseline = baseline_first - i as f32 * line_h_pt;
let runs = glyph_hex_runs(self.fonts, line_text, font_idx, size_pt);
let mut cur_x = xp;
for (face, hex, adv) in runs {
record_glyphs_from_hex(&mut self.used_glyphs[face], &hex);
let _ = write!(
&mut self.ops,
"BT\n/F{} {:.2} Tf\n{:.3} {:.3} {:.3} rg\n{:.3} {:.3} Td\n{} Tj\nET\n",
face + 1,
size_pt,
r,
g,
b,
cur_x,
baseline,
hex,
);
cur_x += adv;
}
}
lines.len()
}
fn text_at_baseline_pt(
&mut self,
x_pt: f32,
baseline_pt: f32,
text: &str,
size_pt: f32,
color_hex: &str,
italic: bool,
bold: bool,
) {
if text.trim().is_empty() || !x_pt.is_finite() || !baseline_pt.is_finite() {
return;
}
let (r, g, b) = hex_to_rgb_f(color_hex);
let font_idx = match (italic, bold) {
(false, false) => FONT_HELV,
(true, false) => FONT_HELV_OBL,
(false, true) => FONT_HELV_BOLD,
(true, true) => FONT_HELV_BOLD_OBL,
};
let runs = glyph_hex_runs(self.fonts, text, font_idx, size_pt.max(1.0));
let mut cur_x = x_pt;
for (face, hex, adv) in runs {
record_glyphs_from_hex(&mut self.used_glyphs[face], &hex);
let _ = write!(
&mut self.ops,
"BT\n/F{} {:.2} Tf\n{:.3} {:.3} {:.3} rg\n{:.3} {:.3} Td\n{} Tj\nET\n",
face + 1,
size_pt.max(1.0),
r,
g,
b,
cur_x,
baseline_pt,
hex,
);
cur_x += adv;
}
}
fn draw_math_text_layout(
&mut self,
layout: &crate::math::MathTextLayout,
origin_x_pt: f32,
top_y_pt: f32,
scale: f32,
color_hex: &str,
) {
for draw in &layout.draws {
match draw {
crate::math::MathLayoutDraw::Text {
x,
y,
size,
text,
bold,
} => {
let (x_pt, baseline_pt) =
self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, *x, *y);
self.text_at_baseline_pt(
x_pt,
baseline_pt,
text,
size * scale,
color_hex,
true,
*bold,
);
}
crate::math::MathLayoutDraw::Line {
x1,
y1,
x2,
y2,
stroke_width,
} => {
let p1 = self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, *x1, *y1);
let p2 = self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, *x2, *y2);
self.stroke_line_pt(p1, p2, (stroke_width * scale).max(0.22), color_hex);
}
crate::math::MathLayoutDraw::Polyline {
points,
stroke_width,
} => {
let points = points
.iter()
.map(|(x, y)| {
self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, *x, *y)
})
.collect::<Vec<_>>();
self.stroke_polyline_pt(&points, (stroke_width * scale).max(0.22), color_hex);
}
crate::math::MathLayoutDraw::Delimiter {
x,
y,
width,
height,
token,
stroke_width,
} => self.draw_math_delimiter_pt(
origin_x_pt,
top_y_pt,
scale,
(*x, *y, *width, *height),
token,
(stroke_width * scale).max(0.22),
color_hex,
),
}
}
}
fn math_local_to_pdf_pt(
&self,
origin_x_pt: f32,
top_y_pt: f32,
scale: f32,
x: f32,
y: f32,
) -> (f32, f32) {
(
origin_x_pt + x * scale,
self.page_h as f32 - (top_y_pt + y * scale),
)
}
fn stroke_line_pt(
&mut self,
p1: (f32, f32),
p2: (f32, f32),
stroke_width_pt: f32,
color_hex: &str,
) {
self.stroke_path_pt(
&format!("{:.3} {:.3} m\n{:.3} {:.3} l\n", p1.0, p1.1, p2.0, p2.1),
stroke_width_pt,
color_hex,
);
}
fn stroke_polyline_pt(&mut self, points: &[(f32, f32)], stroke_width_pt: f32, color_hex: &str) {
if points.len() < 2 {
return;
}
let mut path = format!("{:.3} {:.3} m\n", points[0].0, points[0].1);
for (x, y) in &points[1..] {
path.push_str(&format!("{x:.3} {y:.3} l\n"));
}
self.stroke_path_pt(&path, stroke_width_pt, color_hex);
}
fn stroke_path_pt(&mut self, path: &str, stroke_width_pt: f32, color_hex: &str) {
if !stroke_width_pt.is_finite() || stroke_width_pt <= 0.0 {
return;
}
let (r, g, b) = hex_to_rgb_f(color_hex);
let _ = write!(
&mut self.ops,
"q\n{:.3} {:.3} {:.3} RG\n{:.3} w\n1 J\n1 j\n{}S\nQ\n",
r, g, b, stroke_width_pt, path,
);
}
fn draw_math_delimiter_pt(
&mut self,
origin_x_pt: f32,
top_y_pt: f32,
scale: f32,
rect: (f32, f32, f32, f32),
token: &str,
stroke_width_pt: f32,
color_hex: &str,
) {
let (x, y, width, height) = rect;
let page_h = self.page_h as f32;
let pt = |lx: f32, ly: f32| -> (f32, f32) {
(origin_x_pt + lx * scale, page_h - (top_y_pt + ly * scale))
};
match token {
"(" => {
let p0 = pt(x + width * 0.86, y);
let c1 = pt(x + width * 0.10, y + height * 0.20);
let c2 = pt(x + width * 0.10, y + height * 0.80);
let p1 = pt(x + width * 0.86, y + height);
let path = format!(
"{:.3} {:.3} m\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n",
p0.0, p0.1, c1.0, c1.1, c2.0, c2.1, p1.0, p1.1
);
self.stroke_path_pt(&path, stroke_width_pt, color_hex);
}
")" => {
let p0 = pt(x + width * 0.14, y);
let c1 = pt(x + width * 0.90, y + height * 0.20);
let c2 = pt(x + width * 0.90, y + height * 0.80);
let p1 = pt(x + width * 0.14, y + height);
let path = format!(
"{:.3} {:.3} m\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n",
p0.0, p0.1, c1.0, c1.1, c2.0, c2.1, p1.0, p1.1
);
self.stroke_path_pt(&path, stroke_width_pt, color_hex);
}
"[" => self.stroke_polyline_pt(
&[
pt(x + width, y),
pt(x, y),
pt(x, y + height),
pt(x + width, y + height),
],
stroke_width_pt,
color_hex,
),
"]" => self.stroke_polyline_pt(
&[
pt(x, y),
pt(x + width, y),
pt(x + width, y + height),
pt(x, y + height),
],
stroke_width_pt,
color_hex,
),
"{" => {
let p0 = pt(x + width, y);
let c1 = pt(x + width * 0.18, y);
let c2 = pt(x + width * 0.20, y + height * 0.28);
let p1 = pt(x + width * 0.56, y + height * 0.38);
let c3 = pt(x + width * 0.82, y + height * 0.46);
let c4 = pt(x + width * 0.18, y + height * 0.45);
let p2 = pt(x + width * 0.18, y + height * 0.50);
let c5 = pt(x + width * 0.18, y + height * 0.55);
let c6 = pt(x + width * 0.82, y + height * 0.54);
let p3 = pt(x + width * 0.56, y + height * 0.62);
let c7 = pt(x + width * 0.20, y + height * 0.72);
let c8 = pt(x + width * 0.18, y + height);
let p4 = pt(x + width, y + height);
let path = format!(
"{:.3} {:.3} m\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n",
p0.0, p0.1,
c1.0, c1.1, c2.0, c2.1, p1.0, p1.1,
c3.0, c3.1, c4.0, c4.1, p2.0, p2.1,
c5.0, c5.1, c6.0, c6.1, p3.0, p3.1,
c7.0, c7.1, c8.0, c8.1, p4.0, p4.1
);
self.stroke_path_pt(&path, stroke_width_pt, color_hex);
}
"}" => {
let p0 = pt(x, y);
let c1 = pt(x + width * 0.82, y);
let c2 = pt(x + width * 0.80, y + height * 0.28);
let p1 = pt(x + width * 0.44, y + height * 0.38);
let c3 = pt(x + width * 0.18, y + height * 0.46);
let c4 = pt(x + width * 0.82, y + height * 0.45);
let p2 = pt(x + width * 0.82, y + height * 0.50);
let c5 = pt(x + width * 0.82, y + height * 0.55);
let c6 = pt(x + width * 0.18, y + height * 0.54);
let p3 = pt(x + width * 0.44, y + height * 0.62);
let c7 = pt(x + width * 0.80, y + height * 0.72);
let c8 = pt(x + width * 0.82, y + height);
let p4 = pt(x, y + height);
let path = format!(
"{:.3} {:.3} m\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} c\n",
p0.0, p0.1,
c1.0, c1.1, c2.0, c2.1, p1.0, p1.1,
c3.0, c3.1, c4.0, c4.1, p2.0, p2.1,
c5.0, c5.1, c6.0, c6.1, p3.0, p3.1,
c7.0, c7.1, c8.0, c8.1, p4.0, p4.1
);
self.stroke_path_pt(&path, stroke_width_pt, color_hex);
}
"|" => self.stroke_line_pt(
pt(x + width * 0.5, y),
pt(x + width * 0.5, y + height),
stroke_width_pt,
color_hex,
),
"โ" => {
self.stroke_line_pt(
pt(x + width * 0.35, y),
pt(x + width * 0.35, y + height),
stroke_width_pt,
color_hex,
);
self.stroke_line_pt(
pt(x + width * 0.65, y),
pt(x + width * 0.65, y + height),
stroke_width_pt,
color_hex,
);
}
"โจ" => self.stroke_polyline_pt(
&[
pt(x + width, y),
pt(x, y + height * 0.5),
pt(x + width, y + height),
],
stroke_width_pt,
color_hex,
),
"โฉ" => self.stroke_polyline_pt(
&[pt(x, y), pt(x + width, y + height * 0.5), pt(x, y + height)],
stroke_width_pt,
color_hex,
),
_ => {
let (x_pt, baseline_pt) =
self.math_local_to_pdf_pt(origin_x_pt, top_y_pt, scale, x, y + height * 0.82);
self.text_at_baseline_pt(
x_pt,
baseline_pt,
token,
height * scale,
color_hex,
true,
false,
);
}
}
}
fn line_h_emu(size_centipt: u32) -> u32 {
let pt = size_centipt as f32 / 100.0;
(pt * 1.25 * EMU_PER_PT) as u32
}
fn fit_hero_size(&self, text: &str, base: u32, max_w_emu: u32, max_h_emu: u32) -> u32 {
let font_idx = font_index(true, false, false);
let max_w_pt = self.pt(max_w_emu);
let mut size = base;
for _ in 0..12 {
let pt = size as f32 / 100.0;
let lines =
if max_w_pt <= 0.0 || text_width_pt(self.fonts, text, font_idx, pt) <= max_w_pt {
1
} else {
wrap_text_simple(self.fonts, text, font_idx, pt, max_w_pt)
.len()
.max(1)
};
let block_h = lines as u32 * Self::line_h_emu(size);
if block_h <= max_h_emu || size <= 1400 {
break;
}
let ratio = (max_h_emu as f32 / block_h as f32).sqrt();
let next = ((size as f32 * ratio).floor() as u32).max(1400);
size = if next >= size { size - 100 } else { next };
}
size
}
fn paragraph(
&mut self,
runs: &[Run],
x: u32,
y_start: u32,
max_w_emu: u32,
size_centipt: u32,
base_color_hex: &str,
base_bold: bool,
base_italic: bool,
) -> u32 {
let lines = wrap_runs(
self.fonts,
runs,
self.pt(max_w_emu),
size_centipt as f32 / 100.0,
base_bold,
base_italic,
);
let line_height_emu = (size_centipt as f32 * 1.25 * EMU_PER_PT / 100.0) as u32;
let mut y = y_start;
for line in lines {
self.text_runs_line(
&line,
x,
y,
size_centipt,
base_color_hex,
base_bold,
base_italic,
);
y += line_height_emu;
}
y
}
fn text_runs_line(
&mut self,
runs: &[Run],
x: u32,
y: u32,
size_centipt: u32,
base_color_hex: &str,
base_bold: bool,
base_italic: bool,
) {
let size_pt = size_centipt as f32 / 100.0;
let mut cursor_pt = self.pt(x);
let baseline = self.pdf_y(y) - size_pt * 0.78;
for r in runs {
if r.text.is_empty() {
continue;
}
let bold = base_bold || r.bold;
let italic = base_italic || r.italic;
let mono = r.code;
let font_idx = font_index(bold, italic, mono);
let color_hex = if r.link.is_some() {
&self.theme.link
} else if r.code {
&self.theme.code_accent
} else {
base_color_hex
};
let (rc, gc, bc) = hex_to_rgb_f(color_hex);
let width = text_width_pt(self.fonts, &r.text, font_idx, size_pt);
let runs = glyph_hex_runs(self.fonts, &r.text, font_idx, size_pt);
let mut cur_x = cursor_pt;
for (face, hex, adv) in runs {
record_glyphs_from_hex(&mut self.used_glyphs[face], &hex);
let _ = write!(
&mut self.ops,
"BT\n/F{} {:.2} Tf\n{:.3} {:.3} {:.3} rg\n{:.3} {:.3} Td\n{} Tj\nET\n",
face + 1,
size_pt,
rc,
gc,
bc,
cur_x,
baseline,
hex,
);
cur_x += adv;
}
if let Some(uri) = r.link.as_deref() {
let underline_y = baseline - size_pt * 0.08;
let (lr, lg, lb) = hex_to_rgb_f(color_hex);
let _ = write!(
&mut self.ops,
"{:.3} {:.3} {:.3} rg\n{:.3} {:.3} {:.3} {:.3} re f\n",
lr,
lg,
lb,
cursor_pt,
underline_y,
width,
size_pt * 0.05,
);
self.links.push(LinkRect {
uri: uri.to_string(),
llx: cursor_pt,
lly: underline_y,
urx: cursor_pt + width,
ury: baseline + size_pt * 0.85,
});
}
if r.strike {
let strike_y = baseline + size_pt * 0.25;
let (sr, sg, sb) = hex_to_rgb_f(color_hex);
let _ = write!(
&mut self.ops,
"{:.3} {:.3} {:.3} rg\n{:.3} {:.3} {:.3} {:.3} re f\n",
sr,
sg,
sb,
cursor_pt,
strike_y,
width,
size_pt * 0.05,
);
}
cursor_pt += width;
}
}
fn draw_image(&mut self, src: &str, x: u32, y: u32, w: u32, h: u32, imgs: &Imgs) {
let Some(idx) = imgs.index(src) else {
return;
};
let xp = self.pt(x);
let wp = self.pt(w);
let hp = self.pt(h);
let yp = self.pdf_y(y) - hp;
let _ = write!(
&mut self.ops,
"q\n{:.3} 0 0 {:.3} {:.3} {:.3} cm\n/Im{} Do\nQ\n",
wp,
hp,
xp,
yp,
idx + 1,
);
}
fn render(&mut self, slide: &Slide, num: usize, total: usize, deck_title: &str, imgs: &Imgs) {
self.fill_background(&self.theme.bg.clone());
if let Some(bg) = &slide.bg_image {
if imgs.index(bg).is_some() {
self.draw_image(bg, 0, 0, self.theme.slide_w, self.theme.slide_h, imgs);
}
}
if self.render_full_page_image_slide(slide, imgs) {
return;
}
if self.render_full_page_code_slide(slide) {
return;
}
match &slide.kind {
SlideKind::Title {
subtitle,
author,
date,
} => {
self.render_title_slide(
slide,
subtitle.as_deref(),
author.as_deref(),
date.as_deref(),
);
}
SlideKind::Section => {
self.render_section_slide(slide, num, total);
}
SlideKind::Content => {
self.render_content_slide(slide, num, total, deck_title, imgs);
}
}
}
fn render_full_page_image_slide(&mut self, slide: &Slide, imgs: &Imgs) -> bool {
let Some((src, _, _)) = slide.full_page_image() else {
return false;
};
let (display_w, display_h) = if let Some((iw, ih)) = imgs.dims(src) {
fit_image(iw, ih, self.theme.slide_w, self.theme.slide_h)
} else {
(self.theme.slide_w, self.theme.slide_h)
};
let x = self.theme.slide_w.saturating_sub(display_w) / 2;
let y = self.theme.slide_h.saturating_sub(display_h) / 2;
self.draw_image(src, x, y, display_w, display_h, imgs);
true
}
fn render_full_page_code_slide(&mut self, slide: &Slide) -> bool {
let Some((lines, lang)) = slide.full_page_code() else {
return false;
};
let math_markup = crate::math::is_markup_text_language(lang);
if math_markup {
self.render_full_page_math_markup(lines);
return true;
}
let rendered_lines = crate::math::translate_markup_lines(lines, lang);
let margin_x = if self.theme.portrait { 280000 } else { 360000 };
let margin_y = if self.theme.portrait { 320000 } else { 300000 };
let max_w = self.theme.slide_w.saturating_sub(margin_x * 2);
let max_h = self.theme.slide_h.saturating_sub(margin_y * 2);
let base_size = self
.theme
.code_size
.min(if self.theme.portrait { 850 } else { 950 });
let base_pt = base_size as f32 / 100.0;
let font_idx = if math_markup {
FONT_HELV_OBL
} else {
FONT_COUR
};
let max_line_pt = rendered_lines
.iter()
.map(|line| text_width_pt(self.fonts, line, font_idx, base_pt))
.fold(1.0_f32, f32::max);
let max_w_pt = self.pt(max_w).max(1.0);
let line_count = rendered_lines.len().max(1) as f32;
let max_h_pt = self.pt(max_h).max(1.0);
let line_h_factor = if math_markup { 1.10_f32 } else { 1.18_f32 };
let scale_w = max_w_pt / max_line_pt;
let scale_h = max_h_pt / (line_count * base_pt * line_h_factor);
let scale = scale_w.min(scale_h).min(1.0);
let size = ((base_size as f32) * scale).clamp(450.0, base_size as f32) as u32;
let line_h = (size as f32 * line_h_factor * EMU_PER_PT / 100.0) as u32;
let total_h = line_h.saturating_mul(rendered_lines.len().max(1) as u32);
let mut y = margin_y + max_h.saturating_sub(total_h) / 2;
let color = self.theme.title_color.clone();
for line in &rendered_lines {
self.text_line(
margin_x,
y,
line,
size,
&color,
false,
math_markup,
!math_markup,
if math_markup {
TextAlign::Center
} else {
TextAlign::Left
},
if math_markup { max_w } else { 0 },
);
y = y.saturating_add(line_h);
}
true
}
fn render_full_page_math_markup(&mut self, lines: &[String]) {
let margin_x = if self.theme.portrait { 230000 } else { 320000 };
let margin_y = if self.theme.portrait { 260000 } else { 260000 };
let max_w = self.theme.slide_w.saturating_sub(margin_x * 2);
let max_h = self.theme.slide_h.saturating_sub(margin_y * 2);
let max_w_pt = self.pt(max_w).max(1.0);
let max_h_pt = self.pt(max_h).max(1.0);
let margin_x_pt = self.pt(margin_x);
let margin_y_pt = self.pt(margin_y);
let base_size = 28.0_f32;
let gap = base_size * 0.24;
let metrics = PdfMathMetrics {
fonts: self.fonts,
font_idx: FONT_HELV,
};
let raw_layouts = math_markup_line_layouts(lines, &metrics);
let layouts = fit_packed_math_markup_line_layouts(
lines,
raw_layouts,
base_size,
gap,
max_w_pt / max_h_pt,
&metrics,
);
let (max_line_w, total_h) = math_markup_metrics(&layouts, base_size, gap);
let scale_w = max_w_pt / max_line_w;
let scale_h = max_h_pt / total_h.max(1.0);
let scale = scale_w.min(scale_h).min(1.0).max(0.045);
let rendered_h = total_h * scale;
let mut top_y_pt = margin_y_pt + (max_h_pt - rendered_h).max(0.0) / 2.0;
let color = self.theme.title_color.clone();
for layout in &layouts {
if let Some(layout) = layout {
let line_w_pt = layout.width * scale;
let x_pt = margin_x_pt + (max_w_pt - line_w_pt).max(0.0) / 2.0;
self.draw_math_text_layout(layout, x_pt, top_y_pt, scale, &color);
top_y_pt += layout.height * scale + gap * scale;
} else {
top_y_pt += base_size * 0.65 * scale + gap * scale;
}
}
}
fn render_title_slide(
&mut self,
slide: &Slide,
subtitle: Option<&str>,
author: Option<&str>,
date: Option<&str>,
) {
let theme = self.theme;
let layout = self.layout;
let w = theme.slide_w;
let h = theme.slide_h;
match layout.kind {
LayoutKind::Clean => {
self.rect(0, 0, w, 360000, &theme.accent.clone());
self.rect(
600000,
h / 2 - 1700000,
100000,
600000,
&theme.accent.clone(),
);
let title_top = h / 2 - 1600000;
let title_max_h = (h.saturating_sub(1_400_000)).saturating_sub(title_top);
let hero =
self.fit_hero_size(&slide.title, theme.hero_size, w - 1200000, title_max_h);
let title_lines = self.text_line(
800000,
title_top,
&slide.title,
hero,
&theme.title_color.clone(),
true,
false,
false,
TextAlign::Left,
w - 1200000,
);
let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero);
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1800 } else { 2400 };
self.text_line(
800000,
h / 2 - 400000 + extra,
sub,
sub_size,
&theme.accent.clone(),
false,
false,
false,
TextAlign::Left,
w - 1200000,
);
}
if let Some(text) = author_date(author, date) {
self.text_line(
800000,
h - 700000,
&text,
1400,
&theme.muted_color.clone(),
false,
false,
false,
TextAlign::Left,
w - 1200000,
);
}
}
LayoutKind::Studio => {
self.rect(0, 0, 90000, h, &theme.accent.clone());
if let Some(text) = author_date(author, date) {
let kicker = letterspaced(&text);
self.text_line(
900000,
900000,
&kicker,
1200,
&theme.muted_color.clone(),
true,
false,
false,
TextAlign::Left,
w - 1500000,
);
}
let studio_max_h = (h * 80 / 100).saturating_sub(h / 2 - 1000000);
let hero =
self.fit_hero_size(&slide.title, theme.hero_size, w - 1500000, studio_max_h);
let title_lines = self.text_line(
900000,
h / 2 - 1000000,
&slide.title,
hero,
&theme.title_color.clone(),
false,
true,
false,
TextAlign::Left,
w - 1500000,
);
let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero);
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1700 } else { 2200 };
self.text_line(
900000,
h / 2 + 500000 + extra,
sub,
sub_size,
&theme.body_color.clone(),
false,
false,
false,
TextAlign::Left,
w - 1500000,
);
self.rect(
900000,
h / 2 + 1300000 + extra,
600000,
30000,
&theme.accent.clone(),
);
}
}
LayoutKind::Frame => {
let sidebar = 2_200_000_u32;
self.rect(0, 0, sidebar, h, &theme.accent.clone());
self.rect(300000, 300000, 380000, 40000, &theme.on_accent.clone());
if let Some(a) = author {
self.text_line(
300000,
h - 1200000,
a,
1300,
&theme.on_accent.clone(),
false,
false,
false,
TextAlign::Left,
sidebar - 600000,
);
}
if let Some(d) = date {
self.text_line(
300000,
h - 850000,
d,
1300,
&theme.on_accent.clone(),
false,
false,
false,
TextAlign::Left,
sidebar - 600000,
);
}
let title_x = sidebar + 600000;
let title_w = w - title_x - 600000;
let frame_max_h = (h * 82 / 100).saturating_sub(h / 2 - 1100000);
let hero = self.fit_hero_size(&slide.title, theme.hero_size, title_w, frame_max_h);
let title_lines = self.text_line(
title_x,
h / 2 - 1100000,
&slide.title,
hero,
&theme.title_color.clone(),
true,
false,
false,
TextAlign::Left,
title_w,
);
let extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero);
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1700 } else { 2200 };
self.text_line(
title_x,
h / 2 + 400000 + extra,
sub,
sub_size,
&theme.accent.clone(),
false,
false,
false,
TextAlign::Left,
title_w,
);
}
}
LayoutKind::Bold => {
let block_h = h * 60 / 100;
self.rect(0, 0, w, block_h, &theme.accent.clone());
let pad = 700000;
let hero = self.fit_hero_size(
&slide.title,
theme.hero_size,
w - 2 * pad,
block_h.saturating_sub(pad + 1_400_000),
);
let hero_pt = hero as f32 / 100.0;
let title_lines = {
let font_idx = font_index(true, false, false);
let max_w_pt = self.pt(w - 2 * pad);
let total_w = text_width_pt(self.fonts, &slide.title, font_idx, hero_pt);
if total_w <= max_w_pt || max_w_pt <= 0.0 {
1
} else {
wrap_text_simple(self.fonts, &slide.title, font_idx, hero_pt, max_w_pt)
.len()
}
};
let title_extra = (title_lines.saturating_sub(1) as u32) * Self::line_h_emu(hero);
self.text_line(
pad,
block_h - 1400000 - pad - title_extra,
&slide.title,
hero,
&theme.on_accent.clone(),
true,
false,
false,
TextAlign::Left,
w - 2 * pad,
);
if let Some(sub) = subtitle {
let sub_size = if theme.portrait { 1800 } else { 2400 };
self.text_line(
pad,
block_h + 500000,
sub,
sub_size,
&theme.title_color.clone(),
false,
false,
false,
TextAlign::Left,
w - 2 * pad,
);
}
if let Some(text) = author_date(author, date) {
self.text_line(
pad,
h - 700000,
&text,
1400,
&theme.muted_color.clone(),
false,
false,
false,
TextAlign::Left,
w - 2 * pad,
);
}
}
}
}
fn render_section_slide(&mut self, slide: &Slide, num: usize, total: usize) {
let theme = self.theme;
let layout = self.layout;
let w = theme.slide_w;
let h = theme.slide_h;
match layout.kind {
LayoutKind::Clean | LayoutKind::Frame => {
if slide.bg_image.is_none() {
self.fill_background(&theme.section_bg.clone());
}
let bar_w = 1200000;
self.rect(
(w - bar_w) / 2,
h / 2 - 1000000,
bar_w,
60000,
&theme.section_text.clone(),
);
let sec_top = h / 2 - 200000;
let sec_max_h = (h.saturating_sub(500_000)).saturating_sub(sec_top);
let sec_size =
self.fit_hero_size(&slide.title, theme.hero_size, w - 1600000, sec_max_h);
self.text_line(
800000,
sec_top,
&slide.title,
sec_size,
&theme.section_text.clone(),
true,
false,
false,
TextAlign::Center,
w - 1600000,
);
}
LayoutKind::Studio => {
self.rect(0, 0, 90000, h, &theme.accent.clone());
let huge_size = if theme.portrait { 9000 } else { 13000 };
let huge = format!("{:02}", num.min(99));
self.text_line(
w - 2_400_000,
480000,
&huge,
huge_size,
&theme.divider.clone(),
true,
false,
false,
TextAlign::Right,
2_300_000,
);
let kicker_x = 900000;
let kicker_y = h.saturating_sub(2_600_000);
let kicker_text = format!(
"{} ยท {}",
letterspaced("section"),
letterspaced(&format!("{} of {}", num, total))
);
self.text_line(
kicker_x,
kicker_y,
&kicker_text,
1100,
&theme.muted_color.clone(),
true,
false,
false,
TextAlign::Left,
w - 1800000,
);
self.text_line(
kicker_x,
kicker_y + 700000,
&slide.title,
theme.hero_size,
&theme.title_color.clone(),
false,
true,
false,
TextAlign::Left,
w - kicker_x - 600000,
);
self.rect(
kicker_x,
kicker_y + 1900000,
600000,
30000,
&theme.accent.clone(),
);
}
LayoutKind::Bold => {
let block_h = h * 70 / 100;
self.rect(0, 0, w, block_h, &theme.accent.clone());
let pad = 700000;
self.text_line(
pad,
block_h - 1500000 - pad,
&slide.title,
theme.hero_size,
&theme.on_accent.clone(),
true,
false,
false,
TextAlign::Left,
w - 2 * pad,
);
}
}
}
fn render_content_slide(
&mut self,
slide: &Slide,
num: usize,
total: usize,
deck_title: &str,
imgs: &Imgs,
) {
let theme = self.theme;
let layout = self.layout;
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() {
self.rect(0, 0, layout.rail_width(), h, &theme.accent.clone());
}
if layout.shows_sidebar() {
let sb_w = layout.sidebar_width();
self.rect(0, 0, sb_w, h, &theme.accent.clone());
let pad = 300000;
self.text_line(
pad,
pad,
deck_title,
1200,
&theme.on_accent.clone(),
true,
false,
false,
TextAlign::Left,
sb_w - 2 * pad,
);
self.text_line(
pad,
h - 600000,
&format!("{:02} / {:02}", num, total),
1100,
&theme.on_accent.clone(),
false,
false,
false,
TextAlign::Left,
sb_w - 2 * pad,
);
}
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
};
let title_max_w = if matches!(layout.kind, LayoutKind::Bold) {
w - 2 * base_margin
} else {
content_w
};
let title_lines = {
let title_pt = theme.title_size as f32 / 100.0;
let font_idx = font_index(true, false, false);
let max_w_pt = self.pt(title_max_w);
let total_w = text_width_pt(self.fonts, &slide.title, font_idx, title_pt);
if total_w <= max_w_pt || max_w_pt <= 0.0 {
1
} else {
wrap_text_simple(self.fonts, &slide.title, font_idx, title_pt, max_w_pt).len()
}
};
let title_extra =
(title_lines.saturating_sub(1) as u32) * Self::line_h_emu(theme.title_size);
if matches!(layout.kind, LayoutKind::Bold) {
let block_h = title_h + 240000 + title_extra;
self.rect(0, 0, w, title_y + block_h, &theme.accent.clone());
self.text_line(
base_margin,
title_y,
&slide.title,
theme.title_size,
&theme.on_accent.clone(),
true,
false,
false,
TextAlign::Left,
w - 2 * base_margin,
);
} else {
self.text_line(
content_x,
title_y,
&slide.title,
theme.title_size,
&theme.title_color.clone(),
true,
false,
false,
TextAlign::Left,
content_w,
);
}
let underline_y = if matches!(layout.kind, LayoutKind::Clean) {
let y = title_y + title_h + title_extra + 30000;
self.rect(
content_x,
y + 18000,
content_w,
14000,
&theme.divider.clone(),
);
self.rect(
content_x,
y,
progress_width(content_w, num, total),
50000,
&theme.accent.clone(),
);
y
} else {
title_y + title_h + title_extra
};
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 = content_max_y.saturating_sub(content_y_start);
let _ = self.render_blocks(
&slide.blocks,
content_x,
content_y_start,
content_w,
content_h,
imgs,
);
if !layout.shows_sidebar() {
if let Some((key, iw, ih)) = imgs.logo() {
let logo_h = 280000_u32; let logo_w = if ih > 0 {
((logo_h as u64 * iw as u64) / ih as u64) as u32
} else {
logo_h
};
let logo_y = footer_y - logo_h / 4;
self.draw_image(key, content_x, logo_y, logo_w, logo_h, imgs);
} else {
self.text_line(
content_x,
footer_y,
deck_title,
1000,
&theme.muted_color.clone(),
false,
false,
false,
TextAlign::Left,
content_w.saturating_sub(800000),
);
}
self.text_line(
w - base_margin - 800000,
footer_y,
&format!("{} / {}", num, total),
1000,
&theme.muted_color.clone(),
false,
false,
false,
TextAlign::Right,
800000,
);
}
if layout.shows_corner_decoration() {
self.rect(
w - 280000,
h - 280000,
120000,
120000,
&theme.accent.clone(),
);
}
}
fn render_blocks(
&mut self,
blocks: &[Block],
x: u32,
y_start: u32,
w: u32,
_h_total: u32,
imgs: &Imgs,
) -> u32 {
let mut y = y_start;
for block in blocks {
match block {
Block::Paragraph(runs) => {
let theme = self.theme;
let body_color = theme.body_color.clone();
y = self.paragraph(runs, x, y, w, theme.body_size, &body_color, false, false);
y += 80000;
}
Block::Heading { level, runs } => {
let theme = self.theme;
let sz = match level {
3 => theme.title_size - 400,
4 => theme.title_size - 600,
_ => theme.title_size - 800,
};
let title_color = theme.title_color.clone();
y = self.paragraph(runs, x, y, w, sz, &title_color, true, false);
y += 120000;
}
Block::List(items) => {
if items.len() > crate::theme::LONG_LIST_THRESHOLD && !self.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;
let left_y = self.render_list(l, x, y, col_w);
let right_y = self.render_list(r, x + col_w + gap, y, col_w);
y = left_y.max(right_y);
} else {
y = self.render_list(items, x, y, w);
}
y += 80000;
}
Block::CodeBlock {
lang,
title,
lines,
line_numbers,
start_line,
..
} => {
y = self.render_code_block(
lines,
title.as_deref(),
lang.as_deref(),
*line_numbers,
*start_line,
x,
y,
w,
);
y += 120000;
}
Block::Quote(paras) => {
y = self.render_quote(paras, x, y, w);
y += 80000;
}
Block::Table {
headers,
rows,
aligns,
} => {
y = self.render_table(headers, rows, aligns, x, y, w);
y += 80000;
}
Block::Columns { left, right } => {
let gap: u32 = 280000;
let half = w.saturating_sub(gap) / 2;
let start_y = y;
let left_y = self.render_blocks(left, x, start_y, half, _h_total, imgs);
let right_y = self.render_blocks(
right,
x + half + gap,
start_y,
w - half - gap,
_h_total,
imgs,
);
y = left_y.max(right_y);
}
Block::ColumnBreak => {}
Block::Image {
src,
alt,
width_pct,
} => {
let effective_w = match width_pct {
Some(pct) => w * (*pct as u32) / 100,
None => w,
};
y = self.render_image_block(src, alt, x, y, effective_w, imgs);
y += 80000;
}
Block::Footnotes(items) => {
y = self.render_footnotes(items, x, y, w);
y += 80000;
}
}
}
y
}
fn render_footnotes(&mut self, items: &[ListItem], x: u32, y_start: u32, w: u32) -> u32 {
let size = (self.theme.body_size as f32 * 0.7) as u32;
let line_h = (size as f32 * 1.25 * EMU_PER_PT / 100.0) as u32;
let muted = self.theme.muted_color.clone();
let font = self.theme.body_font.clone();
let _ = font;
let mut y = y_start;
self.rect(x, y, w.min(800000), 8000, &self.theme.divider.clone());
y += 60000;
for item in items {
self.paragraph(&item.runs, x, y, w, size, &muted, false, false);
y += line_h;
}
y
}
fn render_list(&mut self, items: &[ListItem], x: u32, y_start: u32, w: u32) -> u32 {
let theme = self.theme;
let body_color = theme.body_color.clone();
let accent = theme.accent.clone();
let size = theme.body_size;
let line_h = (size as f32 * 1.30 * EMU_PER_PT / 100.0) as u32;
let mut y = y_start;
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 is_task = item.is_task();
let bullet = if is_task {
String::new()
} else if item.ordered {
format!("{}.", ordered_counters[lvl])
} else {
match lvl {
0 => "โ".to_string(),
1 => "โ".to_string(),
_ => "โช".to_string(),
}
};
let indent_emu = (lvl as u32) * 300000;
let bullet_x = x + indent_emu;
let bullet_pt = size as f32 / 100.0;
let bullet_w_pt = text_width_pt(
self.fonts,
&bullet,
font_index(false, false, false),
bullet_pt,
);
let bullet_w_emu = (bullet_w_pt * EMU_PER_PT) as u32;
let gutter_emu = if is_task {
0
} else {
bullet_w_emu.max(180_000) + 120_000
};
self.text_line(
bullet_x,
y,
&bullet,
size,
&accent,
false,
false,
false,
TextAlign::Left,
gutter_emu,
);
let text_x = bullet_x + gutter_emu;
let text_w = w.saturating_sub(text_x - x);
let chars = item
.runs
.iter()
.map(|r| r.text.chars().count())
.sum::<usize>();
let estimated_lines =
((chars as f32 * (size as f32 / 100.0) * 0.55) / self.pt(text_w)).ceil() as u32;
let next_y = self.paragraph(
&item.runs,
text_x,
y,
text_w,
size,
&body_color,
false,
false,
);
y = if next_y > y {
next_y
} else {
y + line_h * estimated_lines.max(1)
};
}
y
}
fn render_quote(&mut self, paras: &[Vec<Run>], x: u32, y_start: u32, w: u32) -> u32 {
let theme = self.theme;
let body_color = theme.body_color.clone();
let accent = theme.accent.clone();
let size = theme.body_size - 100;
let bar_x = x;
let mut y = y_start;
let start_y = y;
for (i, runs) in paras.iter().enumerate() {
if i > 0 {
y += 120000;
}
y = self.paragraph(
runs,
x + 180000,
y,
w.saturating_sub(180000),
size,
&body_color,
false,
true,
);
}
self.rect(bar_x, start_y, 60000, y.saturating_sub(start_y), &accent);
y
}
fn render_code_block(
&mut self,
lines: &[String],
title: Option<&str>,
lang: Option<&str>,
line_numbers: bool,
start_line: usize,
x: u32,
y_start: u32,
w: u32,
) -> u32 {
let theme = self.theme;
let title_h: u32 = if title.is_some() || lang.is_some() {
320000
} else {
0
};
let code_size = theme.code_size;
let line_h = (code_size as f32 * 1.30 * EMU_PER_PT / 100.0) as u32;
let pad = 180000;
let _body_h = line_h * (lines.len() as u32).max(1) + 2 * pad;
if title_h > 0 {
self.rect(x, y_start, w, title_h, &theme.divider.clone());
if let Some(t) = title {
self.text_line(
x + 220000,
y_start + 70000,
t,
1200,
&theme.title_color.clone(),
true,
false,
true,
TextAlign::Left,
w - 440000,
);
if let Some(l) = lang {
let title_w_pt = text_width_pt(self.fonts, t, FONT_COUR_BOLD, 12.0);
let label_x = x + 220000 + (title_w_pt * EMU_PER_PT) as u32 + 200000;
self.text_line(
label_x,
y_start + 80000,
&format!("ยท {}", l),
1100,
&theme.muted_color.clone(),
false,
false,
false,
TextAlign::Left,
w / 2,
);
}
} else if let Some(l) = lang {
self.text_line(
x + 220000,
y_start + 70000,
l,
1200,
&theme.muted_color.clone(),
true,
false,
false,
TextAlign::Left,
w - 440000,
);
}
}
let highlighted: Vec<Vec<Token>> = syntax::tokenize(lines, lang);
let last_line = start_line.saturating_add(lines.len().saturating_sub(1));
let gutter_w = if line_numbers {
Some(last_line.to_string().len())
} else {
None
};
let base_code_size = theme.code_size;
let mut max_line_content_pt: f32 = 0.0;
for line_tokens in &highlighted {
let mut line_w = 0.0_f32;
for token in line_tokens {
if token.text.is_empty() {
continue;
}
let style = theme.syntax_style(token.kind);
let font_idx = font_index(style.bold, style.italic, true);
line_w += text_width_pt(
self.fonts,
&token.text,
font_idx,
base_code_size as f32 / 100.0,
);
}
if line_w > max_line_content_pt {
max_line_content_pt = line_w;
}
}
let gutter_text_w_pt = if let Some(width) = gutter_w {
let n = format!("{:>w$}", last_line, w = width);
text_width_pt(self.fonts, &n, FONT_COUR, base_code_size as f32 / 100.0)
} else {
0.0
};
let gutter_gap_pt = if gutter_w.is_some() {
self.pt(240000)
} else {
0.0
};
let inner_w_pt =
(self.pt(w) - 2.0 * self.pt(pad) - gutter_text_w_pt - gutter_gap_pt).max(20.0);
let code_scale: f32 = if max_line_content_pt > inner_w_pt && max_line_content_pt > 0.0 {
(inner_w_pt / max_line_content_pt).max(0.55)
} else {
1.0
};
let code_size = ((base_code_size as f32) * code_scale).max(700.0) as u32;
let line_h_scaled = (code_size as f32 * 1.30 * EMU_PER_PT / 100.0) as u32;
let code_size_pt = code_size as f32 / 100.0;
let inner_after_gutter_pt = (self.pt(w)
- 2.0 * self.pt(pad)
- text_width_pt(
self.fonts,
&format!("{:>w$}", last_line, w = gutter_w.unwrap_or(0)),
FONT_COUR,
code_size_pt,
)
- gutter_gap_pt)
.max(20.0);
let token_width = |t: &Token| -> f32 {
let style = theme.syntax_style(t.kind);
text_width_pt(
self.fonts,
&t.text,
font_index(style.bold, style.italic, true),
code_size_pt,
)
};
let split_token_at_width = |t: &Token, budget_pt: f32| -> Option<(Token, Token)> {
if t.text.is_empty() || budget_pt <= 0.0 {
return None;
}
let style = theme.syntax_style(t.kind);
let font_idx = font_index(style.bold, style.italic, true);
let mut acc = 0.0_f32;
let mut split_idx = 0;
for (i, c) in t.text.char_indices() {
let cw = text_width_pt(self.fonts, &c.to_string(), font_idx, code_size_pt);
if acc + cw > budget_pt {
break;
}
acc += cw;
split_idx = i + c.len_utf8();
}
if split_idx == 0 || split_idx >= t.text.len() {
return None;
}
let (a, b) = t.text.split_at(split_idx);
Some((
Token {
text: a.to_string(),
kind: t.kind,
},
Token {
text: b.to_string(),
kind: t.kind,
},
))
};
let mut visual_lines: Vec<(Vec<Token>, bool)> = Vec::new(); for orig in &highlighted {
let mut current: Vec<Token> = Vec::new();
let mut current_w = 0.0_f32;
let mut is_continuation = false;
for token in orig {
if token.text.is_empty() {
continue;
}
let tw = token_width(token);
if !current.is_empty() && current_w + tw > inner_after_gutter_pt {
visual_lines.push((std::mem::take(&mut current), is_continuation));
current_w = 0.0;
is_continuation = true;
if token.text.chars().all(|c| c == ' ' || c == '\t') {
continue;
}
}
let mut remaining = token.clone();
let mut remaining_w = token_width(&remaining);
while !remaining.text.is_empty()
&& current_w + remaining_w > inner_after_gutter_pt
&& current_w == 0.0
{
let budget = inner_after_gutter_pt - current_w;
match split_token_at_width(&remaining, budget) {
Some((head, tail)) => {
current.push(head);
visual_lines.push((std::mem::take(&mut current), is_continuation));
is_continuation = true;
current_w = 0.0;
remaining = tail;
remaining_w = token_width(&remaining);
}
None => break,
}
}
if !remaining.text.is_empty() {
current.push(remaining);
current_w += remaining_w;
}
}
if current.is_empty() && !is_continuation {
visual_lines.push((Vec::new(), false));
} else if !current.is_empty() {
visual_lines.push((current, is_continuation));
}
}
let body_h_scaled = line_h_scaled * (visual_lines.len() as u32).max(1) + 2 * pad;
let total_h = body_h_scaled + title_h;
let body_y = y_start + title_h;
self.rect(x, body_y, w, body_h_scaled, &theme.code_bg.clone());
let muted = theme.muted_color.clone();
let continuation_indent_emu: u32 = (2.0 * 0.6 * code_size_pt * EMU_PER_PT) as u32;
let mut orig_line_idx = start_line.saturating_sub(1);
for (visual_idx, (line_tokens, is_continuation)) in visual_lines.iter().enumerate() {
let line_y = body_y + pad + visual_idx as u32 * line_h_scaled;
let mut text_x = x + pad;
if let Some(width) = gutter_w {
let gutter_text = if *is_continuation {
" ".repeat(width)
} else {
orig_line_idx += 1;
format!("{:>w$}", orig_line_idx, w = width)
};
let gutter_pt = text_width_pt(self.fonts, &gutter_text, FONT_COUR, code_size_pt);
let gutter_emu = (gutter_pt * EMU_PER_PT) as u32;
if !is_continuation {
self.text_line(
text_x,
line_y,
&gutter_text,
code_size,
&muted,
false,
false,
true,
TextAlign::Left,
gutter_emu,
);
}
text_x += gutter_emu + 240000;
} else if !*is_continuation {
orig_line_idx += 1;
}
if *is_continuation {
text_x += continuation_indent_emu;
}
let mut cursor_emu = text_x;
for token in line_tokens {
if token.text.is_empty() {
continue;
}
let style = theme.syntax_style(token.kind);
let color = style.color.clone();
self.text_line(
cursor_emu,
line_y,
&token.text,
code_size,
&color,
style.bold,
style.italic,
true,
TextAlign::Left,
0,
);
let w_pt = text_width_pt(
self.fonts,
&token.text,
font_index(style.bold, style.italic, true),
code_size_pt,
);
cursor_emu += (w_pt * EMU_PER_PT) as u32;
}
}
y_start + total_h
}
fn render_table(
&mut self,
headers: &[Vec<Run>],
rows: &[Vec<Vec<Run>>],
aligns: &[crate::ir::ColumnAlign],
x: u32,
y_start: u32,
w: u32,
) -> u32 {
let cols = headers
.len()
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
if cols == 0 {
return y_start;
}
let theme = self.theme;
let base_header_size = theme.body_size as i32 - 200;
let base_body_size = theme.body_size as i32 - 400;
let default_pad_x = 120000_u32;
let header_font_idx = font_index(true, false, false);
let body_font_idx = font_index(false, false, false);
let longest_token_pt = |size_centipt: i32| -> Vec<f32> {
let size_pt = size_centipt as f32 / 100.0;
(0..cols)
.map(|c| {
let mut longest = 0.0_f32;
if let Some(hr) = headers.get(c) {
for r in hr {
for tok in r.text.split_whitespace() {
let tw = text_width_pt(self.fonts, tok, header_font_idx, size_pt);
if tw > longest {
longest = tw;
}
}
}
}
for row in rows {
if let Some(cell) = row.get(c) {
for r in cell {
for tok in r.text.split_whitespace() {
let tw = text_width_pt(self.fonts, tok, body_font_idx, size_pt);
if tw > longest {
longest = tw;
}
}
}
}
}
longest
})
.collect()
};
let base_mins_pt: Vec<f32> = longest_token_pt(base_header_size);
let base_pad_pt = self.pt(default_pad_x);
let natural_total_pt: f32 =
base_mins_pt.iter().sum::<f32>() + cols as f32 * 2.0 * base_pad_pt;
let available_pt = self.pt(w);
let table_scale: f32 = if natural_total_pt > available_pt && natural_total_pt > 0.0 {
(available_pt / natural_total_pt).max(0.55)
} else {
1.0
};
let header_size = ((base_header_size as f32) * table_scale).max(800.0) as u32;
let body_size = ((base_body_size as f32) * table_scale).max(700.0) as u32;
let pad_x = ((default_pad_x as f32) * table_scale.max(0.7)) as u32;
let pad_pt = self.pt(pad_x);
let line_h_header = (header_size as f32 * 1.30 * EMU_PER_PT / 100.0) as u32;
let line_h_body = (body_size as f32 * 1.30 * EMU_PER_PT / 100.0) as u32;
let pad_y = (90000_u32 as f32 * table_scale.max(0.7)) as u32;
let scaled_mins_pt: Vec<f32> = (0..cols)
.map(|c| {
let mut longest = 0.0_f32;
if let Some(hr) = headers.get(c) {
for r in hr {
for tok in r.text.split_whitespace() {
let tw = text_width_pt(
self.fonts,
tok,
header_font_idx,
header_size as f32 / 100.0,
);
if tw > longest {
longest = tw;
}
}
}
}
for row in rows {
if let Some(cell) = row.get(c) {
for r in cell {
for tok in r.text.split_whitespace() {
let tw = text_width_pt(
self.fonts,
tok,
body_font_idx,
body_size as f32 / 100.0,
);
if tw > longest {
longest = tw;
}
}
}
}
}
longest
})
.collect();
let mins_with_pad_pt: Vec<f32> = scaled_mins_pt.iter().map(|m| m + 2.0 * pad_pt).collect();
let mins_sum_pt: f32 = mins_with_pad_pt.iter().sum();
let extra_pt = (available_pt - mins_sum_pt).max(0.0);
let content_weights: Vec<f32> = (0..cols)
.map(|c| {
let mut chars: usize = 0;
if let Some(hr) = headers.get(c) {
chars += hr.iter().map(|r| r.text.chars().count()).sum::<usize>();
}
for row in rows {
if let Some(cell) = row.get(c) {
chars += cell.iter().map(|r| r.text.chars().count()).sum::<usize>();
}
}
(chars as f32).max(1.0)
})
.collect();
let total_content_weight: f32 = content_weights.iter().sum();
let col_widths_pt: Vec<f32> = mins_with_pad_pt
.iter()
.zip(content_weights.iter())
.map(|(base, cw)| {
let share = if total_content_weight > 0.0 {
extra_pt * cw / total_content_weight
} else {
extra_pt / cols as f32
};
base + share
})
.collect();
let mut col_widths: Vec<u32> = col_widths_pt
.iter()
.map(|wp| (wp * EMU_PER_PT) as u32)
.collect();
let assigned: u32 = col_widths.iter().sum();
if assigned > 0 && assigned != w {
let last = col_widths.len() - 1;
if assigned > w {
col_widths[last] = col_widths[last].saturating_sub(assigned - w);
} else {
col_widths[last] += w - assigned;
}
}
let col_x: Vec<u32> = {
let mut v = Vec::with_capacity(cols);
let mut acc = 0u32;
for cw in &col_widths {
v.push(acc);
acc += cw;
}
v
};
let header_wrapped: Vec<Vec<Vec<Run>>> = (0..cols)
.map(|c| {
let runs = headers.get(c).map(|r| r.as_slice()).unwrap_or(&[]);
let inner_w_pt = self.pt(col_widths[c].saturating_sub(2 * pad_x));
wrap_runs(
self.fonts,
runs,
inner_w_pt,
header_size as f32 / 100.0,
true,
false,
)
})
.collect();
let header_lines = header_wrapped
.iter()
.map(|l| l.len().max(1))
.max()
.unwrap_or(1);
let header_h = (line_h_header * header_lines as u32) + 2 * pad_y;
let row_wrapped: Vec<Vec<Vec<Vec<Run>>>> = rows
.iter()
.map(|row| {
(0..cols)
.map(|c| {
let runs = row.get(c).map(|r| r.as_slice()).unwrap_or(&[]);
let inner_w_pt = self.pt(col_widths[c].saturating_sub(2 * pad_x));
wrap_runs(
self.fonts,
runs,
inner_w_pt,
body_size as f32 / 100.0,
false,
false,
)
})
.collect()
})
.collect();
let row_heights: Vec<u32> = row_wrapped
.iter()
.map(|cells| {
let max_lines = cells.iter().map(|l| l.len().max(1)).max().unwrap_or(1);
(line_h_body * max_lines as u32) + 2 * pad_y
})
.collect();
for c in 0..cols {
self.rect(
x + col_x[c],
y_start,
col_widths[c],
header_h,
&theme.accent.clone(),
);
for (i, line) in header_wrapped[c].iter().enumerate() {
let lw = runs_width_emu(self.fonts, line, header_size, true);
let tx = aligned_cell_x(
aligns.get(c).copied(),
x + col_x[c],
col_widths[c],
pad_x,
lw,
);
self.text_runs_line(
line,
tx,
y_start + pad_y + i as u32 * line_h_header,
header_size,
&theme.on_accent.clone(),
true,
false,
);
}
}
let mut ry = y_start + header_h;
let table_band_bg = theme.table_band_bg();
for (i, row_cells) in row_wrapped.iter().enumerate() {
let rh = row_heights[i];
let banded = i % 2 == 1;
let bg = if banded {
table_band_bg.clone()
} else {
theme.bg.clone()
};
for c in 0..cols {
self.rect(x + col_x[c], ry, col_widths[c], rh, &bg);
for (l, line) in row_cells[c].iter().enumerate() {
let lw = runs_width_emu(self.fonts, line, body_size, false);
let tx = aligned_cell_x(
aligns.get(c).copied(),
x + col_x[c],
col_widths[c],
pad_x,
lw,
);
self.text_runs_line(
line,
tx,
ry + pad_y + l as u32 * line_h_body,
body_size,
&theme.body_color.clone(),
false,
false,
);
}
}
ry += rh;
}
ry
}
fn render_image_block(
&mut self,
src: &str,
alt: &str,
x: u32,
y_start: u32,
w: u32,
imgs: &Imgs,
) -> u32 {
let caption_alt = crate::math::visible_image_alt(src, alt);
let caption_h = if caption_alt.is_empty() { 0 } else { 260000 };
let max_h: u32 = self.theme.slide_h * crate::theme::IMAGE_MAX_HEIGHT_FRACTION_NUM
/ crate::theme::IMAGE_MAX_HEIGHT_FRACTION_DEN;
let max_image_h = max_h.saturating_sub(caption_h + 80000);
let (display_w, display_h) = if let Some((iw, ih)) = imgs.dims(src) {
fit_image_for_block(src, alt, iw, ih, w, max_image_h, self.theme.slide_h)
} else {
(w, max_image_h)
};
let img_x = image_x_for_block(src, alt, x, w, display_w);
self.draw_image(src, img_x, y_start, display_w, display_h, imgs);
let mut y = y_start + display_h;
if !caption_alt.is_empty() {
y += 80000;
let muted = self.theme.muted_color.clone();
self.text_line(
x,
y,
caption_alt,
1300,
&muted,
false,
true,
false,
TextAlign::Center,
w,
);
y += caption_h;
}
y
}
}
fn break_token_to_width(
fonts: &PdfFonts,
tok: &str,
font_idx: usize,
size_pt: f32,
max_w_pt: f32,
) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut cur_w = 0.0;
for ch in tok.chars() {
let cw = text_width_pt(fonts, ch.encode_utf8(&mut [0u8; 4]), font_idx, size_pt);
if !cur.is_empty() && cur_w + cw > max_w_pt {
out.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
cur.push(ch);
cur_w += cw;
}
if !cur.is_empty() {
out.push(cur);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn wrap_text_simple(
fonts: &PdfFonts,
text: &str,
font_idx: usize,
size_pt: f32,
max_w_pt: f32,
) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_w: f32 = 0.0;
let mut tokens: Vec<String> = Vec::new();
let mut buf = String::new();
let mut in_space = false;
for ch in text.chars() {
let is_sp = ch == ' ' || ch == '\t';
if buf.is_empty() {
buf.push(ch);
in_space = is_sp;
} else if is_sp == in_space {
buf.push(ch);
} else {
tokens.push(std::mem::take(&mut buf));
buf.push(ch);
in_space = is_sp;
}
}
if !buf.is_empty() {
tokens.push(buf);
}
for tok in tokens {
let tok_w = text_width_pt(fonts, &tok, font_idx, size_pt);
let only_space = tok.chars().all(|c| c == ' ' || c == '\t');
if !only_space && tok_w > max_w_pt && max_w_pt > 0.0 {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_w = 0.0;
}
let chunks = break_token_to_width(fonts, &tok, font_idx, size_pt, max_w_pt);
let n = chunks.len();
for (i, chunk) in chunks.into_iter().enumerate() {
if i + 1 < n {
lines.push(chunk);
} else {
current_w = text_width_pt(fonts, &chunk, font_idx, size_pt);
current = chunk;
}
}
continue;
}
if !current.is_empty() && current_w + tok_w > max_w_pt && !only_space {
lines.push(std::mem::take(&mut current));
current_w = 0.0;
}
if current.is_empty() && only_space {
continue;
}
current.push_str(&tok);
current_w += tok_w;
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn wrap_runs(
fonts: &PdfFonts,
runs: &[Run],
max_w_pt: f32,
size_pt: f32,
base_bold: bool,
base_italic: bool,
) -> Vec<Vec<Run>> {
let mut lines: Vec<Vec<Run>> = vec![Vec::new()];
let mut cur_width: f32 = 0.0;
for r in runs {
let bold = base_bold || r.bold;
let italic = base_italic || r.italic;
let mono = r.code;
let font_idx = font_index(bold, italic, mono);
let mut tokens: Vec<String> = Vec::new();
let mut buf = String::new();
let mut in_space = false;
for ch in r.text.chars() {
let is_sp = ch == ' ';
if buf.is_empty() {
buf.push(ch);
in_space = is_sp;
} else if is_sp == in_space {
buf.push(ch);
} else {
tokens.push(std::mem::take(&mut buf));
buf.push(ch);
in_space = is_sp;
}
}
if !buf.is_empty() {
tokens.push(buf);
}
for tok in tokens {
let tok_w = text_width_pt(fonts, &tok, font_idx, size_pt);
let only_space = tok.chars().all(|c| c == ' ');
let mk_run = |text: String| Run {
text,
bold: r.bold,
italic: r.italic,
code: r.code,
strike: r.strike,
link: r.link.clone(),
};
if !only_space && tok_w > max_w_pt && max_w_pt > 0.0 {
if !lines.last().unwrap().is_empty() {
lines.push(Vec::new());
cur_width = 0.0;
}
let chunks = break_token_to_width(fonts, &tok, font_idx, size_pt, max_w_pt);
let n = chunks.len();
for (i, chunk) in chunks.into_iter().enumerate() {
let cw = text_width_pt(fonts, &chunk, font_idx, size_pt);
lines.last_mut().unwrap().push(mk_run(chunk));
if i + 1 < n {
lines.push(Vec::new());
cur_width = 0.0;
} else {
cur_width = cw;
}
}
continue;
}
if cur_width + tok_w > max_w_pt && !lines.last().unwrap().is_empty() && !only_space {
lines.push(Vec::new());
cur_width = 0.0;
if tok.chars().all(|c| c == ' ') {
continue;
}
}
lines.last_mut().unwrap().push(mk_run(tok));
cur_width += tok_w;
}
}
lines
}
#[derive(Clone, Copy)]
enum TextAlign {
Left,
Center,
Right,
}
struct PdfMathMetrics<'a> {
fonts: &'a PdfFonts,
font_idx: usize,
}
impl crate::math::GlyphMetrics for PdfMathMetrics<'_> {
fn advance_em(&self, ch: char, bold: bool) -> f32 {
let font_idx = if bold { FONT_HELV_BOLD } else { self.font_idx };
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
let w = text_width_pt(self.fonts, s, font_idx, 1.0);
if w > 0.0 {
w
} else {
crate::math::DejaVuMetrics.advance_em(ch, bold)
}
}
}
fn math_markup_line_layouts(
lines: &[String],
metrics: &dyn crate::math::GlyphMetrics,
) -> Vec<Option<crate::math::MathTextLayout>> {
lines
.iter()
.map(|line| {
if line.trim().is_empty() {
None
} else {
Some(crate::math::layout_markup_text_with(line, 100, metrics))
}
})
.collect()
}
fn pack_math_markup_line_layouts(
lines: &[String],
target_width: f32,
metrics: &dyn crate::math::GlyphMetrics,
) -> Vec<Option<crate::math::MathTextLayout>> {
let mut out = Vec::new();
let mut current = String::new();
let mut current_layout = None;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
if let Some(layout) = current_layout.take() {
out.push(Some(layout));
current.clear();
}
out.push(None);
continue;
}
if current.is_empty() {
current.push_str(trimmed);
current_layout = Some(crate::math::layout_markup_text_with(¤t, 100, metrics));
continue;
}
let candidate = format!("{current} {trimmed}");
let candidate_layout = crate::math::layout_markup_text_with(&candidate, 100, metrics);
if candidate_layout.width <= target_width {
current = candidate;
current_layout = Some(candidate_layout);
} else {
if let Some(layout) = current_layout.take() {
out.push(Some(layout));
}
current.clear();
current.push_str(trimmed);
current_layout = Some(crate::math::layout_markup_text_with(¤t, 100, metrics));
}
}
if let Some(layout) = current_layout {
out.push(Some(layout));
}
out
}
fn fit_packed_math_markup_line_layouts(
lines: &[String],
raw_layouts: Vec<Option<crate::math::MathTextLayout>>,
base_size: f32,
gap: f32,
page_ratio: f32,
metrics: &dyn crate::math::GlyphMetrics,
) -> Vec<Option<crate::math::MathTextLayout>> {
let (raw_max_line_w, raw_total_h) = math_markup_metrics(&raw_layouts, base_size, gap);
let raw_ratio = raw_max_line_w / raw_total_h.max(1.0);
let desired_ratio = page_ratio * 0.72;
if lines.len() <= 20 || raw_ratio >= desired_ratio * 0.75 {
return raw_layouts;
}
let mut best = raw_layouts.clone();
let mut best_err = (raw_ratio - desired_ratio).abs();
let mut low = raw_max_line_w;
let mut high = (raw_total_h * desired_ratio * 2.0).max(raw_max_line_w * 1.05);
for _ in 0..9 {
let target = (low + high) / 2.0;
let packed = pack_math_markup_line_layouts(lines, target, metrics);
let (packed_w, packed_h) = math_markup_metrics(&packed, base_size, gap);
let ratio = packed_w / packed_h.max(1.0);
let err = (ratio - desired_ratio).abs();
if err < best_err {
best = packed;
best_err = err;
}
if ratio < desired_ratio {
low = target;
} else {
high = target;
}
}
best
}
fn math_markup_metrics(
layouts: &[Option<crate::math::MathTextLayout>],
base_size: f32,
gap: f32,
) -> (f32, f32) {
let max_line_w = layouts
.iter()
.filter_map(|layout| layout.as_ref().map(|layout| layout.width))
.fold(1.0_f32, f32::max);
let total_h = layouts
.iter()
.map(|layout| {
layout
.as_ref()
.map(|layout| layout.height)
.unwrap_or(base_size * 0.65)
})
.sum::<f32>()
+ gap * layouts.len().saturating_sub(1) as f32;
(max_line_w, total_h)
}
fn hex_to_rgb_f(hex: &str) -> (f32, f32, f32) {
if hex.len() != 6 {
return (0.0, 0.0, 0.0);
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
}
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 fit_image_for_block(
src: &str,
alt: &str,
iw: u32,
ih: u32,
max_w: u32,
max_h: u32,
slide_h: u32,
) -> (u32, u32) {
let Some(math_meta) = crate::math::math_image_meta(src, alt) else {
return fit_image(iw, ih, max_w, max_h);
};
let natural_w = ((iw.max(1) as u64 * 12_700) / 2).min(u32::MAX as u64) as u32;
let natural_h = ((ih.max(1) as u64 * 12_700) / 2).min(u32::MAX as u64) as u32;
let configured_max_h = math_meta
.max_height_px
.map(|px| u32::from(px).saturating_mul(12_700));
let math_max_h = configured_max_h
.unwrap_or(slide_h * 28 / 100)
.min(max_h)
.max(1)
.min(natural_h.max(1));
fit_image(
natural_w,
natural_h,
max_w.min(natural_w.max(1)),
math_max_h,
)
}
fn image_x_for_block(src: &str, alt: &str, x: u32, w: u32, display_w: u32) -> u32 {
match crate::math::math_image_meta(src, alt).map(|meta| meta.align) {
Some(crate::math::MathBlockAlign::Left) => x,
Some(crate::math::MathBlockAlign::Right) => x + w.saturating_sub(display_w),
Some(crate::math::MathBlockAlign::Center) | None => x + w.saturating_sub(display_w) / 2,
}
}
fn progress_width(width: u32, num: usize, total: usize) -> u32 {
if width == 0 || total == 0 {
return 0;
}
((width as u64 * num.max(1) as u64) / total as u64)
.min(width as u64)
.max(1) as u32
}
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cff_outline_detection_picks_the_right_font_program() {
assert!(font_has_cff_outlines(b"OTTO\x00\x04\x00\x80"));
assert!(!font_has_cff_outlines(&[0x00, 0x01, 0x00, 0x00])); assert!(!font_has_cff_outlines(b"true")); for face in crate::font::FONTS {
assert!(
!font_has_cff_outlines(face),
"bundled faces are expected to be TrueType",
);
}
}
}