diff --git a/cruciverb-core/src/export/pdf.rs b/cruciverb-core/src/export/pdf.rs new file mode 100644 index 0000000..bc6b6ec --- /dev/null +++ b/cruciverb-core/src/export/pdf.rs @@ -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 { + // 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 { + 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 { + // collect all objects first, then serialize + struct PdfObj { + data: Vec, + } + + let mut objects: Vec = Vec::new(); + + // helper to add object, returns 1-based index + let mut add_obj = |data: Vec| -> 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 +}