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:
parent
5503eefeb1
commit
6d0d8d31cd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
108
crates/openpencil-desktop/src/export_pdf.rs
Normal file
108
crates/openpencil-desktop/src/export_pdf.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue