mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
fix(pdf): preserve exported links and allow CLI PDF conversion (#954)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user