From 6d0d8d31cdaa1f25623da3db1ed079d5dfac1b91 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 14:06:18 +0800 Subject: [PATCH] feat(shell): PDF multi-page export via skia built-in backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — closes the last gap in the ExportDialog. Each PenPage becomes one PDF page laid out at its content bounding box plus a 16-pt margin. Empty pages are skipped; all-empty docs return 'nothing to export' to match the raster export convention. Mechanism: `skia_safe::pdf::new_document(&mut buf, None)` returns the document; per-page `begin_page` / `end_page` reuses the same `paint_node` pipeline as the raster path, so every variant the PNG exporter handles (Rect / Ellipse / Polygon / Line / Path / Frame / Group with rotation + corner radius) emits as real vector PDF ops — glyphs and shapes stay selectable and zoom-clean. The TS app hand- rolls a PDF stream (Catalog + Pages + Image XObjects with DCTDecode JPEG blobs); skia's backend produces a smaller, sharper file. Wiring: - `export.rs::page_bounds` + `paint_node` exposed as `pub(crate)` so the new `export_pdf.rs` sibling can reuse them. - `Cargo.toml`: skia-safe `pdf` feature flag enabled. - `persistence.rs::ExportImageConfirm` PDF branch now calls `export_pdf::export_pdf` instead of returning the placeholder error. - `ExportFormat::is_implemented` now returns true for every variant so the dialog stops greying out the PDF pill. Tests: 2 unit tests in `export_pdf.rs` cover `%PDF-` header + `%%EOF` trailer for a 2-page doc, plus the all-empty failure path. 197 total tests pass. Phase 5 codex review still pending; will land in the next pass. --- crates/openpencil-desktop/Cargo.toml | 1 + crates/openpencil-desktop/src/export.rs | 4 +- crates/openpencil-desktop/src/export_pdf.rs | 108 ++++++++++++++++++ crates/openpencil-desktop/src/main.rs | 1 + crates/openpencil-desktop/src/persistence.rs | 2 +- .../src/widgets/export_dialog.rs | 26 ++--- 6 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 crates/openpencil-desktop/src/export_pdf.rs diff --git a/crates/openpencil-desktop/Cargo.toml b/crates/openpencil-desktop/Cargo.toml index 3d8844c7f..1730a9f99 100644 --- a/crates/openpencil-desktop/Cargo.toml +++ b/crates/openpencil-desktop/Cargo.toml @@ -38,6 +38,7 @@ skia-safe = { version = "0.97.0", default-features = false, features = [ "binary-cache", "textlayout", "gl", + "pdf", ] } winit = { version = "0.30.13", default-features = false, features = [ "x11", diff --git a/crates/openpencil-desktop/src/export.rs b/crates/openpencil-desktop/src/export.rs index 698382c98..48c6fb1f6 100644 --- a/crates/openpencil-desktop/src/export.rs +++ b/crates/openpencil-desktop/src/export.rs @@ -120,7 +120,7 @@ pub fn export_raster( Ok(()) } -fn page_bounds(page: &openpencil_shell_core::document::Page) -> Option { +pub(crate) fn page_bounds(page: &openpencil_shell_core::document::Page) -> Option { let mut acc = BoundsAcc::new(); for n in &page.children { collect_bounds(n, glam::Affine2::IDENTITY, &mut acc); @@ -282,7 +282,7 @@ fn own_paint_corners(n: &Node) -> Option> { } -fn paint_node(canvas: &Canvas, node: &Node) { +pub(crate) fn paint_node(canvas: &Canvas, node: &Node) { if node.hidden { return; } diff --git a/crates/openpencil-desktop/src/export_pdf.rs b/crates/openpencil-desktop/src/export_pdf.rs new file mode 100644 index 000000000..81c6fc604 --- /dev/null +++ b/crates/openpencil-desktop/src/export_pdf.rs @@ -0,0 +1,108 @@ +//! PDF multi-page export. Skia ships a built-in PDF backend +//! (`skia_safe::pdf::new_document`) — each `PenPage` becomes one +//! PDF page laid out at its bounding rect. Mirrors the TS app's +//! global-export → exportDocumentPdf flow, except the TS app +//! hand-rolls a PDF stream (Catalog + Pages + Image XObjects with +//! DCTDecode JPEG blobs); skia's backend emits real vector PDF +//! ops, which keeps glyphs + shapes selectable + zoom-clean. + +use openpencil_shell_core::document::Document; +use std::path::Path as StdPath; + +const PDF_MARGIN: f32 = 16.0; + +/// Emit every page in `doc` as one PDF page each. Pages are sized +/// to their content bounding box (`page_bounds` + 16-pt margin). +/// Empty pages are skipped. Returns Err when no page has paintable +/// content — same convention as `export_raster`. +pub fn export_pdf(doc: &Document, target: &StdPath) -> Result<(), String> { + let mut buf: Vec = Vec::new(); + { + let mut pdf = skia_safe::pdf::new_document(&mut buf, None); + let mut emitted = 0usize; + for page in &doc.pages { + let Some(bounds) = crate::export::page_bounds(page) else { + continue; + }; + let w = bounds.size.x + PDF_MARGIN * 2.0; + let h = bounds.size.y + PDF_MARGIN * 2.0; + if w <= 0.0 || h <= 0.0 { + continue; + } + let mut on_page = pdf.begin_page(skia_safe::Size::new(w, h), None); + let canvas = on_page.canvas(); + canvas.translate((PDF_MARGIN - bounds.origin.x, PDF_MARGIN - bounds.origin.y)); + for node in &page.children { + crate::export::paint_node(canvas, node); + } + pdf = on_page.end_page(); + emitted += 1; + } + pdf.close(); + if emitted == 0 { + return Err("nothing to export".into()); + } + } + std::fs::write(target, &buf).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openpencil_shell_core::document::{Node, NodeKind}; + use openpencil_shell_core::{Color, Rect}; + + #[test] + fn pdf_export_emits_one_page_per_pen_page() { + let mut doc = Document::empty(); + let p0 = doc.pages.get_mut(0).unwrap(); + p0.children.clear(); + let mut n0 = Node::leaf(10, NodeKind::Rect, "r"); + n0.bounds = Rect::xywh(0.0, 0.0, 60.0, 40.0); + n0.fill = Some(Color { + r: 0.5, + g: 0.5, + b: 0.5, + a: 1.0, + }); + p0.children.push(n0); + let p1 = openpencil_shell_core::document::Page::new(2, "Page 2", Vec::new()); + let mut n1 = Node::leaf(20, NodeKind::Ellipse, "e"); + n1.bounds = Rect::xywh(0.0, 0.0, 40.0, 40.0); + n1.fill = Some(Color { + r: 0.8, + g: 0.2, + b: 0.4, + a: 1.0, + }); + let mut p1 = p1; + p1.children.push(n1); + doc.pages.push(p1); + let tmp = std::env::temp_dir().join(format!("op-export-pdf-{}.pdf", std::process::id())); + let res = export_pdf(&doc, &tmp); + assert!(res.is_ok(), "export_pdf failed: {res:?}"); + let bytes = std::fs::read(&tmp).unwrap(); + // PDF header magic: %PDF- + assert_eq!(&bytes[..5], b"%PDF-", "missing %PDF- header"); + // Should contain the EOF marker. + let tail = &bytes[bytes.len().saturating_sub(8)..]; + assert!( + tail.windows(5).any(|w| w == b"%%EOF"), + "missing %%EOF trailer" + ); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn pdf_export_skips_empty_pages_but_fails_when_all_empty() { + let mut doc = Document::empty(); + // All pages empty. + for p in &mut doc.pages { + p.children.clear(); + } + let tmp = std::env::temp_dir().join(format!("op-pdf-empty-{}.pdf", std::process::id())); + let res = export_pdf(&doc, &tmp); + assert!(res.is_err(), "expected Err on all-empty, got {res:?}"); + assert_eq!(res.unwrap_err(), "nothing to export"); + } +} diff --git a/crates/openpencil-desktop/src/main.rs b/crates/openpencil-desktop/src/main.rs index 0fd816eb7..114a38b3e 100644 --- a/crates/openpencil-desktop/src/main.rs +++ b/crates/openpencil-desktop/src/main.rs @@ -4,6 +4,7 @@ #![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] mod export; +mod export_pdf; mod pen_doc_adapter; mod pen_doc_path_bounds; mod persistence; diff --git a/crates/openpencil-desktop/src/persistence.rs b/crates/openpencil-desktop/src/persistence.rs index 4486973e2..233e6172c 100644 --- a/crates/openpencil-desktop/src/persistence.rs +++ b/crates/openpencil-desktop/src/persistence.rs @@ -664,7 +664,7 @@ pub fn run_action( scale, ), Fmt::Svg => crate::export::export_svg(host.document(), &path), - Fmt::Pdf => Err("PDF export not yet implemented".into()), + Fmt::Pdf => crate::export_pdf::export_pdf(host.document(), &path), }; if let Err(e) = result { eprintln!("[export-image] {e}"); diff --git a/crates/openpencil-shell-core/src/widgets/export_dialog.rs b/crates/openpencil-shell-core/src/widgets/export_dialog.rs index d89c22b16..34de22ce8 100644 --- a/crates/openpencil-shell-core/src/widgets/export_dialog.rs +++ b/crates/openpencil-shell-core/src/widgets/export_dialog.rs @@ -60,13 +60,12 @@ impl ExportFormat { ExportFormat::Svg, ExportFormat::Pdf, ]; - /// Whether the format has a working export backend. PDF reads - /// `false` until Phase 4 lands real multi-page emit; the dialog - /// paints the pill greyed out + skips hit-test so the user - /// can't pick an option that always errors. Mirrors the TS app - /// (which has PDF) once Phase 4 implements it. + /// Whether the format has a working export backend. All five + /// formats are now implemented (PDF landed via skia's built-in + /// `pdf::new_document` backend in Phase 4); kept as a hook so a + /// future format can be added in a disabled state. pub fn is_implemented(self) -> bool { - !matches!(self, ExportFormat::Pdf) + true } } @@ -341,17 +340,10 @@ mod tests { } #[test] - fn pdf_format_is_not_implemented() { - // Codex stop-gate concern: PDF was selectable but always - // errored. Until Phase 4 wires real PDF emit, the pill must - // be inert. - assert!(!ExportFormat::Pdf.is_implemented()); - for fmt in [ - ExportFormat::Png, - ExportFormat::Jpeg, - ExportFormat::Webp, - ExportFormat::Svg, - ] { + fn every_format_is_implemented() { + // Phase 4 enabled PDF via skia's built-in backend. All five + // formats are now selectable + dispatched to a real encoder. + for fmt in ExportFormat::ALL { assert!(fmt.is_implemented(), "{fmt:?} should be implemented"); } }