pdf export
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user