feat(shell): PDF multi-page export via skia built-in backend

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.
This commit is contained in:
Kayshen-X 2026-05-14 14:06:18 +08:00
parent 5503eefeb1
commit 6d0d8d31cd
6 changed files with 122 additions and 20 deletions

View file

@ -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",

View file

@ -120,7 +120,7 @@ pub fn export_raster(
Ok(())
}
fn page_bounds(page: &openpencil_shell_core::document::Page) -> Option<Rect> {
pub(crate) fn page_bounds(page: &openpencil_shell_core::document::Page) -> Option<Rect> {
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<Vec<glam::Vec2>> {
}
fn paint_node(canvas: &Canvas, node: &Node) {
pub(crate) fn paint_node(canvas: &Canvas, node: &Node) {
if node.hidden {
return;
}

View file

@ -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<u8> = 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");
}
}

View file

@ -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;

View file

@ -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}");

View file

@ -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");
}
}