diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 152ddbcbe..0b763e378 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -36,7 +36,7 @@ use serde_json::{Value, json}; const USAGE: &str = "usage: gridcraft-cli info [--json] sheets, used ranges, tables, charts, names - gridcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html by extension + gridcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension gridcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] gridcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] gridcraft-cli check ... [--json] [--tolerance 1e-9] [--max-report N] @@ -271,11 +271,17 @@ fn convert(args: &[String]) -> Result<(), String> { let input = a.pos(0, "")?; let out = a.pos(1, "")?; a.no_extra(2)?; - if gridcraft_engine::io::FileKind::from_path(out).is_none() { - return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)")); + let pdf = out.to_ascii_lowercase().ends_with(".pdf"); + if !pdf && gridcraft_engine::io::FileKind::from_path(out).is_none() { + return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json, .html or .pdf)")); } let mut s = open(input)?; activate_sheet(&mut s, a.opt("sheet"))?; + if pdf { + let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?; + eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]); + return Ok(()); + } let d = s.doc().map_err(|e| e.to_string())?; let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?; gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; @@ -583,4 +589,21 @@ mod tests { assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err()); assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err()); } + + #[test] + fn convert_xlsx_to_pdf_keeps_links() { + let dir = std::env::temp_dir().join(format!("gridcraft-cli-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("links.xlsx"); + let output = dir.join("links.pdf"); + let mut s = blank(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org"})).unwrap(); + let bytes = gridcraft_engine::io::save_file_bytes(&s.doc().unwrap().wb, input.to_str().unwrap()).unwrap(); + gridcraft_engine::io::write_file(input.to_str().unwrap(), &bytes).unwrap(); + convert(&[input.to_string_lossy().into_owned(), output.to_string_lossy().into_owned()]).unwrap(); + let pdf = std::fs::read(&output).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + assert!(pdf.windows(b"/URI (https://example.org)".len()).any(|w| w == b"/URI (https://example.org)")); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..aefa948cc 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -996,6 +996,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { + let range = b.range(); + let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return }; + for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) { + if !range.contains(*cell) { + continue; + } + let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell)); + let (x, y, w, h) = b.rect(cell_range); + let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom)); + if x1 <= x0 || y1 <= y0 { + continue; + } + let rect = (x0, y0, x1 - x0, y1 - y0); + match crate::links::classify(&link.target) { + crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), + crate::links::LinkKind::Internal(target) if !link.external => { + pending.push(PendingPdfLink { page, sheet: b.si, rect, target }); + } + _ => {} + } + } +} + +fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> { + let target = target.trim().trim_start_matches('#').trim_start_matches('='); + let (sheet_name, range) = split_sheet(target); + let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?; + let cell = RangeRef::parse(&range.replace('$', ""))?.start; + let sh = wb.sheet(sheet)?; + let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; + let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; + let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; + Some((block.page, x, y)) +} + /// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio. fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) { let (x, y, w, h) = rect; @@ -1462,6 +1518,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let total: usize = layouts.iter().map(|l| l.pages.len()).sum(); let (date, time) = hf_date_time(wb); let mut page_no = 0usize; + let mut pending_links = Vec::new(); + let mut printed_blocks = Vec::new(); for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; @@ -1476,6 +1534,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::()) * k; let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 }; let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 }; + let page_index = doc.page_count(); let pg = doc.add_page(lay.page_w, lay.page_h); let mut y = oy + lay.heading_h * k; let mut body: Option = None; @@ -1484,6 +1543,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str for (csi, cs) in csegs.iter().enumerate() { let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay); draw_block(pg, &b, lay.settings.gridlines, &mut cf); + draw_block_links(pg, &b, page_index, &mut pending_links); + printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k }); if lay.settings.headings { draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k)); } @@ -1514,6 +1575,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx); } } + for link in pending_links { + if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target) + && let Some(page) = doc.page_mut(link.page) + { + let (lx, ly, w, h) = link.rect; + page.link_page(lx, ly, w, h, target_page, x, y); + } + } let n = doc.page_count(); (doc.finish(), n) } @@ -1759,6 +1828,29 @@ mod tests { hay.windows(needle.len()).any(|w| w == needle.as_bytes()) } + #[test] + fn pdf_export_keeps_safe_links_on_their_printed_pages() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); + s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap(); + s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap(); + s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 2); + let text = String::from_utf8_lossy(&pdf); + assert_eq!(text.matches("/Subtype /Link").count(), 2); + assert!(text.contains("/URI (https://example.org/report)")); + assert!(text.contains("/Dest [") && text.contains("/XYZ ")); + assert!(!text.contains("/URI (file:")); + + // A link to a cell omitted by the selected print range must not point to a nonexistent + // destination. The external link in the selected range remains clickable. + let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"})); + let cropped = String::from_utf8_lossy(&cropped); + assert_eq!(cropped.matches("/Subtype /Link").count(), 1); + assert!(!cropped.contains("/Dest [")); + } + #[test] fn header_sections_split_join_and_field_names() { let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b55f247cf..a0a5afde1 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -210,6 +210,19 @@ fn pdf_string(text: &str) -> String { s } +/// URI actions use byte strings. Percent-encode non-ASCII UTF-8 before PDF string escaping. +fn pdf_uri_string(uri: &str) -> String { + let mut ascii = String::with_capacity(uri.len()); + for byte in uri.bytes() { + if byte.is_ascii() { + ascii.push(byte as char); + } else { + let _ = write!(ascii, "%{byte:02X}"); + } + } + pdf_string(&ascii) +} + /// A number for a content stream: finite, short. fn n(v: f32) -> String { let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 }; @@ -260,6 +273,17 @@ struct ImageData { alpha: Option>, } +enum LinkTarget { + Uri(String), + Page { index: usize, x: f32, y: f32 }, +} + +struct LinkAnnotation { + /// PDF coordinates, with the origin at the bottom left. + rect: [f32; 4], + target: LinkTarget, +} + /// A page under construction. Obtain one with [`PdfDoc::add_page`]. pub struct Page { width: f32, @@ -267,6 +291,7 @@ pub struct Page { content: String, fonts: [bool; 12], images: Vec, + links: Vec, depth: u32, } @@ -275,6 +300,8 @@ pub struct Page { const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024; /// Largest image (pixels) embedded. const MAX_IMAGE_PIXELS: u64 = 40_000_000; +const MAX_PAGE_LINKS: usize = 4096; +const MAX_LINK_URI_BYTES: usize = 4096; impl Page { pub fn width(&self) -> f32 { @@ -296,6 +323,42 @@ impl Page { } } + fn link_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<[f32; 4]> { + if !finite(&[x, y, w, h]) || !extent(w) || !extent(h) || self.links.len() >= MAX_PAGE_LINKS { + return None; + } + let (right, bottom) = (x + w, y + h); + if !finite(&[right, bottom]) { + return None; + } + let (left, right) = (x.max(0.0), right.min(self.width)); + let (top, bottom) = (y.max(0.0), bottom.min(self.height)); + (right > left && bottom > top).then(|| [left, self.y(bottom), right, self.y(top)]) + } + + /// Adds an invisible clickable area for an external URI. Invalid or excessive links are ignored. + pub fn link_uri(&mut self, x: f32, y: f32, w: f32, h: f32, uri: &str) { + let allowed = uri + .split_once(':') + .is_some_and(|(scheme, rest)| matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") && !rest.is_empty()); + if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) { + return; + } + if let Some(rect) = self.link_rect(x, y, w, h) { + self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) }); + } + } + + /// Adds a link to a location on another page of this document (zero-based page index). + pub fn link_page(&mut self, x: f32, y: f32, w: f32, h: f32, page: usize, target_x: f32, target_y: f32) { + if !finite(&[target_x, target_y]) { + return; + } + if let Some(rect) = self.link_rect(x, y, w, h) { + self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target_x, y: target_y } }); + } + } + /// Saves the graphics state (pair with [`Page::restore`]). pub fn save(&mut self) { self.depth = self.depth.saturating_add(1); @@ -583,7 +646,7 @@ impl PdfDoc { pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page { let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 }; let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 }; - self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), depth: 0 }); + self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), links: Vec::new(), depth: 0 }); let i = self.pages.len() - 1; &mut self.pages[i] } @@ -619,6 +682,8 @@ impl PdfDoc { } let mut objects: Vec<(u32, Vec)> = Vec::new(); let mut kids = Vec::new(); + let mut annotations = Vec::new(); + let page_count = self.pages.len(); let mut font_dict = String::new(); for (i, id) in font_ids.iter().enumerate() { if *id != 0 { @@ -678,20 +743,47 @@ impl PdfDoc { o.extend_from_slice(b"endstream"); objects.push((content_id, o)); let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") }; + let mut link_refs = String::new(); + for link in std::mem::take(&mut page.links) { + if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) { + continue; + } + let id = next; + next += 1; + let _ = write!(link_refs, "{id} 0 R "); + annotations.push((id, link)); + } + let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") }; objects.push(( page_id, format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>", n(page.width), n(page.height), font_dict, xo, - content_id + content_id, + annots ) .into_bytes(), )); kids.push(page_id); } + for (id, link) in annotations { + let [x0, y0, x1, y1] = link.rect; + let action = match link.target { + LinkTarget::Uri(uri) => format!("/A << /S /URI /URI {} >>", pdf_uri_string(&uri)), + LinkTarget::Page { index, x, y } => { + let Some(&page_id) = kids.get(index) else { continue }; + let height = self.pages.get(index).map_or(0.0, |p| p.height); + format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y)) + } + }; + objects.push(( + id, + format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(), + )); + } objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec())); let kids_s: Vec = kids.iter().map(|k| format!("{k} 0 R")).collect(); objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes())); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 7d6a61a45..b0b2d14c6 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -76,6 +76,29 @@ fn drawing_and_text() { assert!(find(&pdf, b"0 792 m 612 0 l S").is_some()); } +#[test] +fn uri_and_page_links_are_real_pdf_annotations() { + let mut d = PdfDoc::new(); + let first = d.add_page(200.0, 300.0); + first.link_uri(10.0, 20.0, 80.0, 30.0, "https://example.org/a(b)?q=£"); + first.link_page(100.0, 40.0, 50.0, 20.0, 1, 35.0, 60.0); + first.link_uri(-20.0, -20.0, 40.0, 40.0, "mailto:a@example.org"); + first.link_uri(0.0, 0.0, f32::NAN, 10.0, "https://bad.example"); + first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)"); + let second = d.add_page(100.0, 200.0); + second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0); + let pdf = d.finish(); + check_xref(&pdf); + let s = String::from_utf8_lossy(&pdf); + assert_eq!(s.matches("/Subtype /Link").count(), 3); + assert!(s.contains("/Annots [")); + assert!(s.contains("/Rect [10 250 90 280]")); + assert!(s.contains("/Rect [0 280 20 300]")); + assert!(s.contains("/URI (https://example.org/a\\(b\\)?q=%C2%A3)")); + assert!(s.contains("/XYZ 35 140 null")); + assert!(!s.contains("https://bad.example")); +} + #[test] fn images_with_alpha() { let mut d = PdfDoc::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 988472414..2360fd1a6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (PDF export preserves safe cell hyperlinks to URLs and printed destinations, #954) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -56,7 +56,7 @@ and repair-free opening remain unverified. | Format | Excel | GridCraft | Notes | |---|---|---|---| -| PDF | Save As / Export | yes (own PDF 1.7 writer) | Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) | +| PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) | | XPS | Windows | no | Low priority | | Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | | | Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command | @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | PDF export writes clickable annotations for safe cell URLs and destinations on printed pages; CLI `convert` accepts `.pdf` output (#954) | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |