fix(pdf): preserve exported links and allow CLI PDF conversion (#954)

This commit is contained in:
derechopiano
2026-10-14 13:40:16 +01:00
parent 84acf6293b
commit 78ffd3b4c3
5 changed files with 239 additions and 8 deletions
+26 -3
View File
@@ -36,7 +36,7 @@ use serde_json::{Value, json};
const USAGE: &str = "usage:
gridcraft-cli info <file> [--json] sheets, used ranges, tables, charts, names
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
@@ -271,11 +271,17 @@ fn convert(args: &[String]) -> Result<(), String> {
let input = a.pos(0, "<in>")?;
let out = a.pos(1, "<out>")?;
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();
}
}
+92
View File
@@ -996,6 +996,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
pg.restore();
}
struct PendingPdfLink {
page: usize,
sheet: usize,
rect: (f32, f32, f32, f32),
target: String,
}
struct PrintedBlock {
page: usize,
sheet: usize,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
k: f32,
}
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
/// pages and receives one annotation on each of them.
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
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::<f32>()) * 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<Block> = 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";
+95 -3
View File
@@ -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<Vec<u8>>,
}
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<ImageData>,
links: Vec<LinkAnnotation>,
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<u8>)> = 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<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
+23
View File
@@ -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();
+3 -2
View File
@@ -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) |