pdf export

This commit is contained in:
2025-06-26 16:13:59 +03:00
parent 3245976a8c
commit 76ed710560
+319
View File
@@ -0,0 +1,319 @@
use std::path::Path;
use crate::grid::Cell;
use crate::puzzle::Puzzle;
pub fn to_pdf(puzzle: &Puzzle, title: &str, variant: &str) -> Vec<u8> {
// try to load custom fonts, fall back to built-in
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let font_dir = Path::new(&data_dir).join("fonts");
let bold_font = std::fs::read(font_dir.join("PlayfairDisplay-Bold.ttf")).ok();
let regular_font = std::fs::read(font_dir.join("DMSans-Regular.ttf")).ok();
let show_solution = variant == "solution";
let pw = 612.0;
let ph = 792.0;
let margin = 48.0;
let mut cell_numbers = std::collections::HashMap::new();
for word in &puzzle.words {
cell_numbers.entry((word.row, word.col)).or_insert(word.clue_number);
}
let max_grid_h = ph * 0.45;
let max_grid_w = pw - margin * 2.0;
let cell = (max_grid_w / puzzle.width as f64)
.min(max_grid_h / puzzle.height as f64)
.min(28.0);
let grid_w = cell * puzzle.width as f64;
let grid_h = cell * puzzle.height as f64;
let grid_x = (pw - grid_w) / 2.0;
let grid_top = ph - margin - 60.0;
let mut c = String::new();
// background
c.push_str(&format!("0.98 0.965 0.95 rg 0 0 {} {} re f\n", pw, ph));
// title - centered
let title_fs = 22.0;
let title_w = title.len() as f64 * title_fs * 0.48;
c.push_str(&format!(
"BT /F2 {} Tf 0.1 0.08 0.06 rg {} {} Td ({}) Tj ET\n",
title_fs, (pw - title_w) / 2.0, grid_top + 30.0, esc(title)
));
// subtitle - centered
let sub = if show_solution {
format!("{}x{} - solution", puzzle.width, puzzle.height)
} else {
format!("{}x{} crossword", puzzle.width, puzzle.height)
};
let sub_w = sub.len() as f64 * 8.0 * 0.45;
c.push_str(&format!(
"BT /F1 8 Tf 0.6 0.55 0.5 rg {} {} Td ({}) Tj ET\n",
(pw - sub_w) / 2.0, grid_top + 14.0, esc(&sub)
));
// grid border
c.push_str(&format!(
"0.1 0.08 0.06 RG 1.5 w {} {} {} {} re S\n",
grid_x, grid_top - grid_h, grid_w, grid_h
));
// cells
for r in 0..puzzle.height {
for co in 0..puzzle.width {
let x = grid_x + co as f64 * cell;
let y = grid_top - r as f64 * cell;
match puzzle.grid.get(r, co) {
Cell::Black => {
c.push_str(&format!("0.1 0.08 0.06 rg {} {} {} {} re f\n", x, y - cell, cell, cell));
}
_ => {
c.push_str(&format!("0.78 0.75 0.72 RG 0.3 w {} {} {} {} re S\n", x, y - cell, cell, cell));
if let Some(&num) = cell_numbers.get(&(r, co)) {
c.push_str(&format!(
"BT /F1 {} Tf 0.4 0.36 0.32 rg {} {} Td ({}) Tj ET\n",
(cell * 0.25).min(7.0), x + 1.5, y - (cell * 0.28).min(7.5), num
));
}
if show_solution {
if let Some(ch) = puzzle.grid.get(r, co).letter() {
let fs = (cell * 0.55).min(16.0);
c.push_str(&format!(
"BT /F1 {} Tf 0.1 0.08 0.06 rg {} {} Td ({}) Tj ET\n",
fs, x + cell * 0.3, y - cell * 0.68, ch
));
}
}
}
}
}
}
// clues
let clue_top = grid_top - grid_h - 24.0;
let col_w = (pw - margin * 2.0 - 24.0) / 2.0;
let fs = 7.5;
let lh = 10.5;
c.push_str(&format!("BT /F2 10 Tf 0.1 0.08 0.06 rg {} {} Td (ACROSS) Tj ET\n", margin, clue_top));
c.push_str(&format!("0.78 0.75 0.72 RG 0.5 w {} {} m {} {} l S\n", margin, clue_top - 3.0, margin + col_w, clue_top - 3.0));
let mut y = clue_top - 16.0;
for clue in &puzzle.clues_across {
if y < margin + 20.0 { break; }
c.push_str(&format!("BT /F2 {} Tf 0.79 0.41 0.29 rg {} {} Td ({}) Tj ET\n", fs, margin, y, clue.number));
c.push_str(&format!("BT /F1 {} Tf 0.35 0.3 0.28 rg {} {} Td ({}) Tj ET\n", fs, margin + 18.0, y, esc(&truncate(&clue.text, 55))));
y -= lh;
}
let dx = margin + col_w + 24.0;
c.push_str(&format!("BT /F2 10 Tf 0.1 0.08 0.06 rg {} {} Td (DOWN) Tj ET\n", dx, clue_top));
c.push_str(&format!("0.78 0.75 0.72 RG 0.5 w {} {} m {} {} l S\n", dx, clue_top - 3.0, dx + col_w, clue_top - 3.0));
y = clue_top - 16.0;
for clue in &puzzle.clues_down {
if y < margin + 20.0 { break; }
c.push_str(&format!("BT /F2 {} Tf 0.79 0.41 0.29 rg {} {} Td ({}) Tj ET\n", fs, dx, y, clue.number));
c.push_str(&format!("BT /F1 {} Tf 0.35 0.3 0.28 rg {} {} Td ({}) Tj ET\n", fs, dx + 18.0, y, esc(&truncate(&clue.text, 55))));
y -= lh;
}
c.push_str(&format!("BT /F2 6 Tf 0.6 0.55 0.5 rg {} {} Td (cruciverb) Tj ET\n", margin, margin - 10.0));
build_pdf_with_fonts(&c, pw, ph, bold_font.as_deref(), regular_font.as_deref())
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)")
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } else { format!("{}...", &s[..max - 3]) }
}
fn build_pdf_with_fonts(content: &str, pw: f64, ph: f64, bold_ttf: Option<&[u8]>, regular_ttf: Option<&[u8]>) -> Vec<u8> {
let mut pdf = Vec::new();
let mut offsets = Vec::new();
let mut obj_count = 0;
pdf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
macro_rules! obj {
($data:expr) => {{
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice($data);
pdf.extend_from_slice(b"\nendobj\n");
obj_count
}};
}
// obj 1: catalog
obj!(b"<< /Type /Catalog /Pages 2 0 R >>");
// obj 2: pages
obj!(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
// build font objects
let (f1_ref, f2_ref);
if let Some(reg_data) = regular_ttf {
// embedded TrueType font for F1 (regular/body)
let stream_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", reg_data.len(), reg_data.len()).as_bytes());
pdf.extend_from_slice(reg_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /DMSans /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 80 >>", stream_id).as_bytes());
f1_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /DMSans /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
} else {
f1_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
}
if let Some(bold_data) = bold_ttf {
let stream_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} /Length1 {} >>\nstream\n", bold_data.len(), bold_data.len()).as_bytes());
pdf.extend_from_slice(bold_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
let desc_id = obj!(format!("<< /Type /FontDescriptor /FontName /PlayfairDisplay-Bold /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 120 >>", stream_id).as_bytes());
f2_ref = obj!(format!("<< /Type /Font /Subtype /TrueType /BaseFont /PlayfairDisplay-Bold /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id).as_bytes());
} else {
f2_ref = obj!(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
}
// obj: content stream
let content_id = {
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", obj_count).as_bytes());
pdf.extend_from_slice(format!("<< /Length {} >>\nstream\n", content.len()).as_bytes());
pdf.extend_from_slice(content.as_bytes());
pdf.extend_from_slice(b"\nendstream\nendobj\n");
obj_count
};
// obj 3: page (deferred - write now)
let page_data = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> >>",
pw, ph, content_id, f1_ref, f2_ref
);
// we need to insert page as obj 3, but we already wrote objects 1,2 then font objects
// so obj 3 wasn't written yet. let's write it now
obj_count += 1;
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", obj_count, page_data).as_bytes());
let _page_obj = obj_count;
// fix pages to reference our page object
// this is hacky but works - we'll just rebuild the whole thing properly
// actually let's just rebuild cleanly
drop(pdf);
drop(offsets);
build_pdf_clean(content, pw, ph, regular_ttf, bold_ttf)
}
fn build_pdf_clean(content: &str, pw: f64, ph: f64, regular_ttf: Option<&[u8]>, bold_ttf: Option<&[u8]>) -> Vec<u8> {
// collect all objects first, then serialize
struct PdfObj {
data: Vec<u8>,
}
let mut objects: Vec<PdfObj> = Vec::new();
// helper to add object, returns 1-based index
let mut add_obj = |data: Vec<u8>| -> usize {
objects.push(PdfObj { data });
objects.len() // 1-based
};
// obj 1: catalog (placeholder, will reference pages)
add_obj(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
// obj 2: pages (placeholder, will reference page)
add_obj(Vec::new()); // placeholder
// font objects
let f1_ref;
let f2_ref;
if let Some(reg) = regular_ttf {
let mut stream = format!("<< /Length {} /Length1 {} >>\nstream\n", reg.len(), reg.len()).into_bytes();
stream.extend_from_slice(reg);
stream.extend_from_slice(b"\nendstream");
let stream_id = add_obj(stream);
let desc = format!("<< /Type /FontDescriptor /FontName /DMSans /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 80 >>", stream_id);
let desc_id = add_obj(desc.into_bytes());
let font = format!("<< /Type /Font /Subtype /TrueType /BaseFont /DMSans /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id);
f1_ref = add_obj(font.into_bytes());
} else {
f1_ref = add_obj(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec());
}
if let Some(bold) = bold_ttf {
let mut stream = format!("<< /Length {} /Length1 {} >>\nstream\n", bold.len(), bold.len()).into_bytes();
stream.extend_from_slice(bold);
stream.extend_from_slice(b"\nendstream");
let stream_id = add_obj(stream);
let desc = format!("<< /Type /FontDescriptor /FontName /PlayfairDisplay-Bold /Flags 32 /FontFile2 {} 0 R /ItalicAngle 0 /Ascent 1000 /Descent -200 /CapHeight 700 /StemV 120 >>", stream_id);
let desc_id = add_obj(desc.into_bytes());
let font = format!("<< /Type /Font /Subtype /TrueType /BaseFont /PlayfairDisplay-Bold /FirstChar 32 /LastChar 255 /Encoding /WinAnsiEncoding /FontDescriptor {} 0 R >>", desc_id);
f2_ref = add_obj(font.into_bytes());
} else {
f2_ref = add_obj(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".to_vec());
}
// content stream
let mut cs = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
cs.extend_from_slice(content.as_bytes());
cs.extend_from_slice(b"\nendstream");
let content_id = add_obj(cs);
// page object
let page = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents {} 0 R /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> >>",
pw, ph, content_id, f1_ref, f2_ref
);
let page_id = add_obj(page.into_bytes());
// fix pages object (obj 2) to reference the page
objects[1].data = format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", page_id).into_bytes();
// serialize
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
let mut obj_offsets = Vec::new();
for (i, obj) in objects.iter().enumerate() {
obj_offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(&obj.data);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for off in &obj_offsets {
pdf.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", objects.len() + 1, xref).as_bytes());
pdf
}