refactor(electron): migrate export renderers off Document onto LayoutScene
Rewrite raster (PNG/JPEG/WEBP), SVG and PDF export to consume the layout-resolved `LayoutScene` instead of shell-core's `Document`. The export call site builds the scene from the live `EditorState` via `op_pen_loader::editor_state_to_layout_scene`, so jian's flex pass and `$ref` fill resolution run once and the exported pixels match the on-screen canvas painter. Rotation now pivots around `aggregate_bounds` to match `canvas_viewport_paint.rs`; drop shadows render behind Frame / Rect / Ellipse fills. Drops the native host's dead `document()` / `derive_paint_doc` path now that nothing derives a paint `Document`. Splits the SVG serializer into `export/export_svg.rs` to stay under the 800-line cap.
This commit is contained in:
parent
5dde9e8741
commit
43cbf2131d
|
|
@ -1,29 +1,51 @@
|
|||
//! Raster + SVG export for the active page.
|
||||
//!
|
||||
//! Renders top-level nodes on the active page into an off-screen
|
||||
//! `skia_safe::Surface`, encodes as one of PNG/JPEG/WEBP, writes to
|
||||
//! the user-chosen path. SVG goes through a separate hand-rolled
|
||||
//! serializer (`export_svg`). Coverage:
|
||||
//! Renders top-level nodes on the active page of a [`LayoutScene`]
|
||||
//! into an off-screen `skia_safe::Surface`, encodes as one of
|
||||
//! PNG/JPEG/WEBP, writes to the user-chosen path. SVG goes through a
|
||||
//! separate hand-rolled serializer (`export_svg`). Coverage:
|
||||
//! - Rect / Frame / Group : fill + stroke + corner_radius
|
||||
//! - Ellipse : fill + stroke
|
||||
//! - Polygon : default-triangle fill + stroke
|
||||
//! - Line : stroke from `bounds.origin` to `+size`
|
||||
//! - Line : diagonal stroke across `bounds`
|
||||
//! - Path : polyline through `node.points`
|
||||
//! - Text : skipped (font plumbing pending)
|
||||
//! - Text : authored content at the resolved bounds
|
||||
//! Per-node rotation honoured via `Canvas::rotate`.
|
||||
//!
|
||||
//! The scene is layout-resolved: every `SceneNode::bounds` is the
|
||||
//! absolute doc-space AABB jian's flex pass produced, and fills are
|
||||
//! already `$ref`-resolved. Export draws straight from `bounds` with
|
||||
//! no second layout pass — same input the on-screen canvas paints
|
||||
//! (`canvas_viewport_paint.rs`).
|
||||
//!
|
||||
//! Background: PNG / WEBP transparent; JPEG forced white (TS parity
|
||||
//! — JPEG has no alpha so a "transparent" JPEG would read as black).
|
||||
//! Scale: caller picks @1x / @2x / @3x (TS export dialog parity).
|
||||
|
||||
use openpencil_shell_core::document::{Document, Node, NodeKind};
|
||||
use openpencil_shell_core::document::{Effect, NodeKind};
|
||||
use openpencil_shell_core::layout_scene::{LayoutScene, ScenePage, SceneNode};
|
||||
use openpencil_shell_core::{Color, Point2D, Rect};
|
||||
use skia_safe::{Canvas, EncodedImageFormat, Paint, PaintStyle, Path, PathBuilder};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
mod export_svg;
|
||||
|
||||
pub use export_svg::export_svg;
|
||||
|
||||
const MARGIN: f32 = 16.0;
|
||||
|
||||
/// Mirrors `canvas_viewport_paint.rs` text paint constants so PNG
|
||||
/// export matches what the user sees on the editor canvas at zoom 1.
|
||||
/// `canvas_viewport_paint.rs` uses a 13 px default and a baseline at
|
||||
/// `(size + 1) * zoom`, with `1.35 × size` line height.
|
||||
const TEXT_DEFAULT_FONT_SIZE: f32 = 13.0;
|
||||
const TEXT_DEFAULT_FILL: Color = Color {
|
||||
r: 0.08,
|
||||
g: 0.08,
|
||||
b: 0.08,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
/// Raster export format. Matches TS ExportDialog's PNG / JPEG / WEBP
|
||||
/// options; SVG has its own entry point (`export_svg`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -56,8 +78,7 @@ impl RasterFormat {
|
|||
!matches!(self, RasterFormat::Jpeg)
|
||||
}
|
||||
/// Lookup by file extension (lowercase). Returns None for unknown
|
||||
/// extensions. Used by future drag-drop / scripted-export paths
|
||||
/// (current UI carries the format via `Document.ui.export_format`).
|
||||
/// extensions. Used by future drag-drop / scripted-export paths.
|
||||
#[allow(dead_code)]
|
||||
pub fn from_extension(ext: &str) -> Option<RasterFormat> {
|
||||
match ext {
|
||||
|
|
@ -79,16 +100,15 @@ impl RasterFormat {
|
|||
|
||||
/// Raster export with explicit format + scale. Scale clamped to
|
||||
/// [0.5, 8.0] to keep surface allocation sane; NaN / inf fall back
|
||||
/// to 2× (codex CONCERN — NaN reaching `canvas.scale` produces a
|
||||
/// garbage transform).
|
||||
/// to 2× (NaN reaching `canvas.scale` produces a garbage transform).
|
||||
pub fn export_raster(
|
||||
doc: &Document,
|
||||
scene: &LayoutScene,
|
||||
target: &StdPath,
|
||||
format: RasterFormat,
|
||||
scale: f32,
|
||||
) -> Result<(), String> {
|
||||
let scale = if scale.is_finite() { scale.clamp(0.5, 8.0) } else { 2.0 };
|
||||
let Some(page) = doc.pages.get(doc.active_page_index) else {
|
||||
let Some(page) = scene.active_page() else {
|
||||
return Err("no active page".into());
|
||||
};
|
||||
let bounds = page_bounds(page).ok_or("nothing to export")?;
|
||||
|
|
@ -120,7 +140,12 @@ pub fn export_raster(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn page_bounds(page: &openpencil_shell_core::document::Page) -> Option<Rect> {
|
||||
/// Bounding rect over every paintable node on `page`. The scene is
|
||||
/// already layout-resolved, so each node's `bounds` is its absolute
|
||||
/// doc-space AABB — but rotation, strokes and `node.points` can push
|
||||
/// painted pixels outside `bounds`, so traversal mirrors `paint_node`
|
||||
/// exactly and threads the cumulative transform.
|
||||
pub(crate) fn page_bounds(page: &ScenePage) -> Option<Rect> {
|
||||
let mut acc = BoundsAcc::new();
|
||||
for n in &page.children {
|
||||
collect_bounds(n, glam::Affine2::IDENTITY, &mut acc);
|
||||
|
|
@ -163,28 +188,22 @@ impl BoundsAcc {
|
|||
|
||||
/// Mirror of `paint_node`'s traversal — visits the SAME nodes paint
|
||||
/// visits, in the SAME order, threading the cumulative parent
|
||||
/// transform so a child painted under a rotated container ends up
|
||||
/// in the same world coords paint will emit. Bounds stay in
|
||||
/// lockstep with paint, so the surface never clips a row a
|
||||
/// downstream draw would touch (including children inside rotated
|
||||
/// frames / groups).
|
||||
fn collect_bounds(n: &Node, parent_xform: glam::Affine2, acc: &mut BoundsAcc) {
|
||||
/// transform so a child painted under a rotated container ends up in
|
||||
/// the same world coords paint will emit. `canvas_viewport_paint.rs`
|
||||
/// pivots rotation around the node's `aggregate_bounds()` (own bounds
|
||||
/// when bounded, child union otherwise); this mirrors that exactly so
|
||||
/// the surface never clips a row a downstream draw would touch.
|
||||
fn collect_bounds(n: &SceneNode, parent_xform: glam::Affine2, acc: &mut BoundsAcc) {
|
||||
if n.hidden {
|
||||
return;
|
||||
}
|
||||
// Compose this node's rotation around its own centre into the
|
||||
// cumulative parent transform. Mirrors `paint_node`'s guard
|
||||
// EXACTLY — paint checks the RAW (unnormalized) size, so a
|
||||
// negative-size rect (drag-from-right-to-left) skips rotation
|
||||
// even though `normalize_rect` would report a positive extent.
|
||||
// Using the normalized size here would rotate the AABB while
|
||||
// paint stayed unrotated → mismatch.
|
||||
let raw = n.bounds;
|
||||
let rotate_self = n.rotation != 0.0 && raw.size.x > 0.0 && raw.size.y > 0.0;
|
||||
let pivot_rect = n.aggregate_bounds();
|
||||
let rotate_self = n.rotation.abs() > f32::EPSILON
|
||||
&& (pivot_rect.size.x != 0.0 || pivot_rect.size.y != 0.0);
|
||||
let local_xform = if rotate_self {
|
||||
let pivot = glam::Vec2::new(
|
||||
raw.origin.x + raw.size.x * 0.5,
|
||||
raw.origin.y + raw.size.y * 0.5,
|
||||
pivot_rect.origin.x + pivot_rect.size.x * 0.5,
|
||||
pivot_rect.origin.y + pivot_rect.size.y * 0.5,
|
||||
);
|
||||
parent_xform
|
||||
* glam::Affine2::from_translation(pivot)
|
||||
|
|
@ -206,13 +225,13 @@ fn collect_bounds(n: &Node, parent_xform: glam::Affine2, acc: &mut BoundsAcc) {
|
|||
|
||||
/// Local-space corner points that bound `n`'s own paint (NOT its
|
||||
/// children — those visit through `collect_bounds`). The caller
|
||||
/// applies the cumulative parent+self transform; each returned
|
||||
/// point gets pushed into the BoundsAcc as a world-space coord.
|
||||
/// applies the cumulative parent+self transform; each returned point
|
||||
/// gets pushed into the BoundsAcc as a world-space coord.
|
||||
///
|
||||
/// Returns `None` for invisible kinds: Group/Text never paint own
|
||||
/// Returns `None` for invisible kinds: Group never paints own
|
||||
/// content; Frame/Other contribute only when fill or stroke is set;
|
||||
/// Path with empty `points` is invisible.
|
||||
fn own_paint_corners(n: &Node) -> Option<Vec<glam::Vec2>> {
|
||||
fn own_paint_corners(n: &SceneNode) -> Option<Vec<glam::Vec2>> {
|
||||
let stroke_pad = n.stroke.map(|s| s.width * 0.5).unwrap_or(0.0);
|
||||
let (x0, y0, x1, y1) = match &n.kind {
|
||||
NodeKind::Rect | NodeKind::Ellipse | NodeKind::Polygon | NodeKind::Line => {
|
||||
|
|
@ -224,7 +243,22 @@ fn own_paint_corners(n: &Node) -> Option<Vec<glam::Vec2>> {
|
|||
nr.origin.y + nr.size.y,
|
||||
)
|
||||
}
|
||||
NodeKind::Frame | NodeKind::Other(_) => {
|
||||
NodeKind::Frame => {
|
||||
if n.fill.is_none() && n.stroke.is_none() {
|
||||
return None;
|
||||
}
|
||||
let nr = normalize_rect(n.bounds);
|
||||
(
|
||||
nr.origin.x,
|
||||
nr.origin.y,
|
||||
nr.origin.x + nr.size.x,
|
||||
nr.origin.y + nr.size.y,
|
||||
)
|
||||
}
|
||||
NodeKind::Other(_) => {
|
||||
// `icon_font` and any other tagged kind paint no fill rect
|
||||
// in export; their bounds still bound the glyph for the
|
||||
// surface size when a fill is present.
|
||||
if n.fill.is_none() && n.stroke.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -237,8 +271,8 @@ fn own_paint_corners(n: &Node) -> Option<Vec<glam::Vec2>> {
|
|||
)
|
||||
}
|
||||
NodeKind::Text => {
|
||||
// Text bounds are the user-set "where the glyphs sit"
|
||||
// rect. Real glyph extents can overshoot for tails /
|
||||
// Text bounds are the layout-resolved "where the glyphs
|
||||
// sit" rect. Real glyph extents can overshoot for tails /
|
||||
// accents, but `bounds` is the right approximation
|
||||
// without doing a per-glyph metric pass.
|
||||
let has_text = n.text.as_ref().is_some_and(|s| !s.is_empty());
|
||||
|
|
@ -281,22 +315,44 @@ fn own_paint_corners(n: &Node) -> Option<Vec<glam::Vec2>> {
|
|||
])
|
||||
}
|
||||
|
||||
|
||||
pub(crate) fn paint_node(canvas: &Canvas, node: &Node) {
|
||||
/// Recursively paint one resolved [`SceneNode`] and its subtree onto
|
||||
/// `canvas`. Mirrors `canvas_viewport_paint.rs::paint_node` at zoom 1
|
||||
/// so the exported pixels match the on-screen canvas.
|
||||
pub(crate) fn paint_node(canvas: &Canvas, node: &SceneNode) {
|
||||
if node.hidden {
|
||||
return;
|
||||
}
|
||||
let world_rect = node.bounds;
|
||||
let pre_rotate = canvas.save();
|
||||
if node.rotation != 0.0 && world_rect.size.x > 0.0 && world_rect.size.y > 0.0 {
|
||||
let cx = world_rect.origin.x + world_rect.size.x / 2.0;
|
||||
let cy = world_rect.origin.y + world_rect.size.y / 2.0;
|
||||
canvas.rotate(node.rotation.to_degrees(), Some((cx, cy).into()));
|
||||
// Rotation pivots around `aggregate_bounds` — own bounds when the
|
||||
// node is bounded, child union for unbounded containers — exactly
|
||||
// like the editor canvas painter.
|
||||
if node.rotation.abs() > f32::EPSILON {
|
||||
let pivot = node.aggregate_bounds();
|
||||
if pivot.size.x != 0.0 || pivot.size.y != 0.0 {
|
||||
let cx = pivot.origin.x + pivot.size.x / 2.0;
|
||||
let cy = pivot.origin.y + pivot.size.y / 2.0;
|
||||
canvas.rotate(node.rotation.to_degrees(), Some((cx, cy).into()));
|
||||
}
|
||||
}
|
||||
// Drop shadows paint behind the node's own fill — only for kinds
|
||||
// a rounded rect / ellipse silhouette can represent (parity with
|
||||
// canvas_viewport_paint.rs).
|
||||
if !node.effects.is_empty()
|
||||
&& world_rect.size.x.abs() > 0.0
|
||||
&& world_rect.size.y.abs() > 0.0
|
||||
&& matches!(node.kind, NodeKind::Frame | NodeKind::Rect | NodeKind::Ellipse)
|
||||
{
|
||||
paint_drop_shadows(canvas, node, world_rect);
|
||||
}
|
||||
match &node.kind {
|
||||
NodeKind::Rect | NodeKind::Frame | NodeKind::Other(_) => {
|
||||
NodeKind::Rect | NodeKind::Frame => {
|
||||
paint_rect(canvas, world_rect, node);
|
||||
}
|
||||
NodeKind::Other(_) => {
|
||||
// Tagged kinds (e.g. `icon_font`) have no rect silhouette
|
||||
// in export; skip own paint, still recurse children.
|
||||
}
|
||||
NodeKind::Ellipse => {
|
||||
paint_oval(canvas, world_rect, node);
|
||||
}
|
||||
|
|
@ -304,18 +360,19 @@ pub(crate) fn paint_node(canvas: &Canvas, node: &Node) {
|
|||
paint_triangle(canvas, world_rect, node);
|
||||
}
|
||||
NodeKind::Line => {
|
||||
if let Some(stroke) = node.stroke {
|
||||
let mut paint = make_stroke(stroke.color, stroke.width);
|
||||
canvas.draw_line(
|
||||
(world_rect.origin.x, world_rect.origin.y),
|
||||
(
|
||||
world_rect.origin.x + world_rect.size.x,
|
||||
world_rect.origin.y + world_rect.size.y,
|
||||
),
|
||||
&paint,
|
||||
);
|
||||
paint.set_anti_alias(true);
|
||||
}
|
||||
let (color, width) = match node.stroke {
|
||||
Some(s) => (s.color, s.width),
|
||||
None => (node.fill.unwrap_or(Color::BLACK), 1.5),
|
||||
};
|
||||
let paint = make_stroke(color, width);
|
||||
canvas.draw_line(
|
||||
(world_rect.origin.x, world_rect.origin.y),
|
||||
(
|
||||
world_rect.origin.x + world_rect.size.x,
|
||||
world_rect.origin.y + world_rect.size.y,
|
||||
),
|
||||
&paint,
|
||||
);
|
||||
}
|
||||
NodeKind::Path => {
|
||||
paint_polyline(canvas, node);
|
||||
|
|
@ -330,7 +387,45 @@ pub(crate) fn paint_node(canvas: &Canvas, node: &Node) {
|
|||
canvas.restore_to_count(pre_rotate);
|
||||
}
|
||||
|
||||
fn paint_rect(canvas: &Canvas, rect: Rect, node: &Node) {
|
||||
/// Paint every `Effect::DropShadow` as a blurred shape behind the
|
||||
/// node's fill. Mirrors `canvas_viewport_paint.rs::paint_drop_shadows`
|
||||
/// at zoom 1.
|
||||
fn paint_drop_shadows(canvas: &Canvas, node: &SceneNode, world_rect: Rect) {
|
||||
let nrect = normalize_rect(world_rect);
|
||||
let radius = if node.kind == NodeKind::Ellipse {
|
||||
nrect.size.x.min(nrect.size.y) / 2.0
|
||||
} else {
|
||||
node.corner_radius
|
||||
};
|
||||
for effect in &node.effects {
|
||||
let Effect::DropShadow(s) = effect;
|
||||
let shadow_rect = Rect {
|
||||
origin: Point2D::new(
|
||||
nrect.origin.x + s.offset_x,
|
||||
nrect.origin.y + s.offset_y,
|
||||
),
|
||||
size: nrect.size,
|
||||
};
|
||||
let mut paint = make_fill(s.color);
|
||||
if s.blur > 0.0 {
|
||||
paint.set_mask_filter(skia_safe::MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
s.blur * 0.5,
|
||||
false,
|
||||
));
|
||||
}
|
||||
let sk_rect = to_sk_rect(shadow_rect);
|
||||
if node.kind == NodeKind::Ellipse {
|
||||
canvas.draw_oval(sk_rect, &paint);
|
||||
} else if radius > 0.5 {
|
||||
canvas.draw_round_rect(sk_rect, radius, radius, &paint);
|
||||
} else {
|
||||
canvas.draw_rect(sk_rect, &paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_rect(canvas: &Canvas, rect: Rect, node: &SceneNode) {
|
||||
let nrect = normalize_rect(rect);
|
||||
if nrect.size.x == 0.0 && nrect.size.y == 0.0 {
|
||||
return;
|
||||
|
|
@ -355,7 +450,7 @@ fn paint_rect(canvas: &Canvas, rect: Rect, node: &Node) {
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_oval(canvas: &Canvas, rect: Rect, node: &Node) {
|
||||
fn paint_oval(canvas: &Canvas, rect: Rect, node: &SceneNode) {
|
||||
let nrect = normalize_rect(rect);
|
||||
if nrect.size.x == 0.0 && nrect.size.y == 0.0 {
|
||||
return;
|
||||
|
|
@ -369,7 +464,7 @@ fn paint_oval(canvas: &Canvas, rect: Rect, node: &Node) {
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_triangle(canvas: &Canvas, rect: Rect, node: &Node) {
|
||||
fn paint_triangle(canvas: &Canvas, rect: Rect, node: &SceneNode) {
|
||||
let rect = normalize_rect(rect);
|
||||
if rect.size.x == 0.0 || rect.size.y == 0.0 {
|
||||
return;
|
||||
|
|
@ -393,7 +488,7 @@ fn paint_triangle(canvas: &Canvas, rect: Rect, node: &Node) {
|
|||
}
|
||||
}
|
||||
|
||||
fn paint_text(canvas: &Canvas, rect: Rect, node: &Node) {
|
||||
fn paint_text(canvas: &Canvas, rect: Rect, node: &SceneNode) {
|
||||
let Some(text) = node.text.as_deref() else { return };
|
||||
if text.is_empty() {
|
||||
return;
|
||||
|
|
@ -404,43 +499,41 @@ fn paint_text(canvas: &Canvas, rect: Rect, node: &Node) {
|
|||
else {
|
||||
return;
|
||||
};
|
||||
// Match editor exactly (canvas_viewport.rs §NodeKind::Text):
|
||||
// 13pt font, baseline at origin.y + 14, fill defaults to the
|
||||
// near-black `(0.08, 0.08, 0.08)` ink colour — NOT pure black.
|
||||
let font = skia_safe::Font::from_typeface(typeface, TEXT_FONT_SIZE);
|
||||
// Match the editor canvas (canvas_viewport_paint.rs): authored
|
||||
// font size with a 13 px default, baseline at origin.y + (size+1),
|
||||
// fill defaults to the near-black ink colour. Multi-line text
|
||||
// splits on `\n` with a 1.35 × size line height.
|
||||
let base_size = if node.font_size > 0.0 {
|
||||
node.font_size
|
||||
} else {
|
||||
TEXT_DEFAULT_FONT_SIZE
|
||||
};
|
||||
let font = skia_safe::Font::from_typeface(typeface, base_size);
|
||||
let color = node.fill.unwrap_or(TEXT_DEFAULT_FILL);
|
||||
let paint = make_fill(color);
|
||||
canvas.draw_str(
|
||||
text,
|
||||
(r.origin.x, r.origin.y + TEXT_BASELINE_OFFSET),
|
||||
&font,
|
||||
&paint,
|
||||
);
|
||||
let line_h = base_size * 1.35;
|
||||
let mut baseline_y = r.origin.y + base_size + 1.0;
|
||||
for line in text.split('\n') {
|
||||
canvas.draw_str(line, (r.origin.x, baseline_y), &font, &paint);
|
||||
baseline_y += line_h;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors `canvas_viewport.rs` text paint constants so PNG export
|
||||
/// matches what the user sees on the editor canvas at zoom 1.
|
||||
const TEXT_FONT_SIZE: f32 = 13.0;
|
||||
const TEXT_BASELINE_OFFSET: f32 = 14.0;
|
||||
const TEXT_DEFAULT_FILL: Color = Color {
|
||||
r: 0.08,
|
||||
g: 0.08,
|
||||
b: 0.08,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
fn paint_polyline(canvas: &Canvas, node: &Node) {
|
||||
fn paint_polyline(canvas: &Canvas, node: &SceneNode) {
|
||||
if node.points.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(stroke) = node.stroke else { return };
|
||||
let (color, width) = match node.stroke {
|
||||
Some(s) => (s.color, s.width),
|
||||
None => (node.fill.unwrap_or(Color::BLACK), 1.5),
|
||||
};
|
||||
let mut builder = PathBuilder::new();
|
||||
builder.move_to((node.points[0].x, node.points[0].y));
|
||||
for p in &node.points[1..] {
|
||||
builder.line_to((p.x, p.y));
|
||||
}
|
||||
let path: Path = builder.detach();
|
||||
canvas.draw_path(&path, &make_stroke(stroke.color, stroke.width));
|
||||
canvas.draw_path(&path, &make_stroke(color, width));
|
||||
}
|
||||
|
||||
fn make_fill(c: Color) -> Paint {
|
||||
|
|
@ -465,8 +558,8 @@ fn to_sk_rect(r: Rect) -> skia_safe::Rect {
|
|||
skia_safe::Rect::from_xywh(r.origin.x, r.origin.y, r.size.x, r.size.y)
|
||||
}
|
||||
|
||||
/// Negative-size bounds come from drag-from-right-to-left creates;
|
||||
/// normalise so paint always has top-left origin + positive extent.
|
||||
/// Defensive normalisation — the layout pass yields positive-extent
|
||||
/// rects, but a negative size would otherwise paint nothing.
|
||||
fn normalize_rect(r: Rect) -> Rect {
|
||||
let x0 = r.origin.x.min(r.origin.x + r.size.x);
|
||||
let y0 = r.origin.y.min(r.origin.y + r.size.y);
|
||||
|
|
@ -476,227 +569,41 @@ fn normalize_rect(r: Rect) -> Rect {
|
|||
}
|
||||
}
|
||||
|
||||
// ── SVG ───────────────────────────────────────────────────────────
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
//! Shared helpers for the export test modules — build a
|
||||
//! `LayoutScene` directly without going through `op-pen-loader`.
|
||||
|
||||
pub fn export_svg(doc: &Document, target: &StdPath) -> Result<(), String> {
|
||||
let Some(page) = doc.pages.get(doc.active_page_index) else {
|
||||
return Err("no active page".into());
|
||||
};
|
||||
let bounds = page_bounds(page).ok_or("nothing to export")?;
|
||||
let view_x = bounds.origin.x - MARGIN;
|
||||
let view_y = bounds.origin.y - MARGIN;
|
||||
let view_w = bounds.size.x + MARGIN * 2.0;
|
||||
let view_h = bounds.size.y + MARGIN * 2.0;
|
||||
let mut svg = String::with_capacity(4096);
|
||||
let _ = write!(
|
||||
svg,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view_x} {view_y} {view_w} {view_h}" width="{view_w}" height="{view_h}">"#
|
||||
);
|
||||
for node in &page.children {
|
||||
emit_node(&mut svg, node);
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
std::fs::write(target, svg).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
use openpencil_shell_core::document::NodeKind;
|
||||
use openpencil_shell_core::layout_scene::{LayoutScene, ScenePage, SceneNode};
|
||||
use openpencil_shell_core::{Color, Rect};
|
||||
|
||||
fn emit_node(out: &mut String, n: &Node) {
|
||||
if n.hidden {
|
||||
return;
|
||||
}
|
||||
let raw = n.bounds;
|
||||
// SVG groups inherit transform onto children — same composition
|
||||
// model as `paint_node` + `collect_bounds`. Match paint's guard.
|
||||
let needs_g = n.rotation != 0.0 && raw.size.x > 0.0 && raw.size.y > 0.0;
|
||||
if needs_g {
|
||||
let cx = raw.origin.x + raw.size.x * 0.5;
|
||||
let cy = raw.origin.y + raw.size.y * 0.5;
|
||||
let deg = n.rotation.to_degrees();
|
||||
let _ = write!(out, r#"<g transform="rotate({deg} {cx} {cy})">"#);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Rect | NodeKind::Frame | NodeKind::Other(_) => emit_rect(out, n),
|
||||
NodeKind::Ellipse => emit_ellipse(out, n),
|
||||
NodeKind::Polygon => emit_polygon(out, n),
|
||||
NodeKind::Line => emit_line(out, n),
|
||||
NodeKind::Path => emit_path(out, n),
|
||||
NodeKind::Text => emit_text(out, n),
|
||||
NodeKind::Group => {}
|
||||
}
|
||||
for child in &n.children {
|
||||
emit_node(out, child);
|
||||
}
|
||||
if needs_g {
|
||||
out.push_str("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rect(out: &mut String, n: &Node) {
|
||||
if n.fill.is_none() && n.stroke.is_none() && !matches!(n.kind, NodeKind::Rect) {
|
||||
return;
|
||||
}
|
||||
let r = normalize_rect(n.bounds);
|
||||
if r.size.x == 0.0 && r.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let rx = if n.corner_radius > 0.0 {
|
||||
format!(r#" rx="{}""#, n.corner_radius)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<rect x="{}" y="{}" width="{}" height="{}"{rx}{}/>"#,
|
||||
r.origin.x,
|
||||
r.origin.y,
|
||||
r.size.x,
|
||||
r.size.y,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_ellipse(out: &mut String, n: &Node) {
|
||||
let r = normalize_rect(n.bounds);
|
||||
if r.size.x == 0.0 || r.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let cx = r.origin.x + r.size.x * 0.5;
|
||||
let cy = r.origin.y + r.size.y * 0.5;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<ellipse cx="{cx}" cy="{cy}" rx="{}" ry="{}"{}/>"#,
|
||||
r.size.x * 0.5,
|
||||
r.size.y * 0.5,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_polygon(out: &mut String, n: &Node) {
|
||||
let r = normalize_rect(n.bounds);
|
||||
if r.size.x == 0.0 || r.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let cx = r.origin.x + r.size.x * 0.5;
|
||||
let top = r.origin.y;
|
||||
let left = r.origin.x;
|
||||
let right = r.origin.x + r.size.x;
|
||||
let bottom = r.origin.y + r.size.y;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<polygon points="{cx},{top} {left},{bottom} {right},{bottom}"{}/>"#,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_line(out: &mut String, n: &Node) {
|
||||
let Some(stroke) = n.stroke else { return };
|
||||
let r = n.bounds;
|
||||
let x2 = r.origin.x + r.size.x;
|
||||
let y2 = r.origin.y + r.size.y;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<line x1="{}" y1="{}" x2="{x2}" y2="{y2}"{}/>"#,
|
||||
r.origin.x,
|
||||
r.origin.y,
|
||||
stroke_attrs(stroke.color, stroke.width),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_text(out: &mut String, n: &Node) {
|
||||
let Some(text) = n.text.as_deref() else { return };
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let r = normalize_rect(n.bounds);
|
||||
// Same 13/14 constants as `paint_text` + editor canvas so the
|
||||
// SVG renders identically to what the user sees on screen.
|
||||
let color = n.fill.unwrap_or(TEXT_DEFAULT_FILL);
|
||||
let baseline_y = r.origin.y + TEXT_BASELINE_OFFSET;
|
||||
let fill_attr = if color.a < 0.999 {
|
||||
format!(r#" fill="{}" fill-opacity="{}""#, color_to_rgb(color), color.a)
|
||||
} else {
|
||||
format!(r#" fill="{}""#, color_to_rgb(color))
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<text x="{}" y="{baseline_y}" font-family="system-ui, sans-serif" font-size="{TEXT_FONT_SIZE}"{fill_attr}>{}</text>"#,
|
||||
r.origin.x,
|
||||
xml_escape(text),
|
||||
);
|
||||
}
|
||||
|
||||
fn xml_escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
other => out.push(other),
|
||||
/// A single-page scene holding `children` as top-level nodes.
|
||||
pub fn scene_with(children: Vec<SceneNode>) -> LayoutScene {
|
||||
LayoutScene {
|
||||
pages: vec![ScenePage {
|
||||
id: "p1".into(),
|
||||
name: "Page 1".into(),
|
||||
children,
|
||||
}],
|
||||
active_page_index: 0,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_path(out: &mut String, n: &Node) {
|
||||
if n.points.len() < 2 {
|
||||
return;
|
||||
/// A filled rectangle scene node at `(x, y, w, h)`.
|
||||
pub fn filled_rect(id: &str, x: f32, y: f32, w: f32, h: f32, fill: Color) -> SceneNode {
|
||||
let mut n = SceneNode::leaf(id, NodeKind::Rect);
|
||||
n.bounds = Rect::xywh(x, y, w, h);
|
||||
n.fill = Some(fill);
|
||||
n
|
||||
}
|
||||
let Some(stroke) = n.stroke else { return };
|
||||
let mut d = String::with_capacity(n.points.len() * 16);
|
||||
let _ = write!(d, "M{} {}", n.points[0].x, n.points[0].y);
|
||||
for p in &n.points[1..] {
|
||||
let _ = write!(d, " L{} {}", p.x, p.y);
|
||||
}
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<path d="{d}" fill="none"{}/>"#,
|
||||
stroke_attrs(stroke.color, stroke.width),
|
||||
);
|
||||
}
|
||||
|
||||
fn fill_stroke_attrs(n: &Node) -> String {
|
||||
let mut s = String::new();
|
||||
if let Some(fill) = n.fill {
|
||||
let _ = write!(s, r#" fill="{}""#, color_to_rgb(fill));
|
||||
if fill.a < 0.999 {
|
||||
let _ = write!(s, r#" fill-opacity="{}""#, fill.a);
|
||||
}
|
||||
} else {
|
||||
s.push_str(r#" fill="none""#);
|
||||
}
|
||||
if let Some(stroke) = n.stroke {
|
||||
s.push_str(&stroke_attrs(stroke.color, stroke.width));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Stroke `color` + `width` attributes for any element that paints
|
||||
/// a stroke (rect/ellipse/polygon/line/path). Emits `stroke-opacity`
|
||||
/// when the colour's alpha < 1 so semi-transparent strokes round-
|
||||
/// trip through SVG instead of collapsing to opaque.
|
||||
fn stroke_attrs(color: Color, width: f32) -> String {
|
||||
let mut s = format!(r#" stroke="{}" stroke-width="{}""#, color_to_rgb(color), width);
|
||||
if color.a < 0.999 {
|
||||
let _ = write!(s, r#" stroke-opacity="{}""#, color.a);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn color_to_rgb(c: Color) -> String {
|
||||
format!(
|
||||
"rgb({},{},{})",
|
||||
(c.r.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(c.g.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(c.b.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::{filled_rect, scene_with};
|
||||
use super::*;
|
||||
use openpencil_shell_core::layout_scene::SceneNode;
|
||||
|
||||
#[test]
|
||||
fn raster_format_extension_lookup() {
|
||||
|
|
@ -725,37 +632,36 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn export_raster_writes_png_for_minimal_doc() {
|
||||
use openpencil_shell_core::document::{Node, NodeId, NodeKind};
|
||||
let mut doc = openpencil_shell_core::document::Document::empty();
|
||||
let page = doc.pages.get_mut(0).unwrap();
|
||||
page.children.clear();
|
||||
let mut n = Node::leaf("n10", NodeKind::Rect, "r");
|
||||
n.bounds = Rect::xywh(0.0, 0.0, 100.0, 50.0);
|
||||
n.fill = Some(Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 });
|
||||
page.children.push(n);
|
||||
fn export_raster_writes_png_for_minimal_scene() {
|
||||
let scene = scene_with(vec![filled_rect(
|
||||
"n10",
|
||||
0.0,
|
||||
0.0,
|
||||
100.0,
|
||||
50.0,
|
||||
Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 },
|
||||
)]);
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.png", std::process::id()));
|
||||
let res = export_raster(&doc, &tmp, RasterFormat::Png, 2.0);
|
||||
let res = export_raster(&scene, &tmp, RasterFormat::Png, 2.0);
|
||||
assert!(res.is_ok(), "export_raster PNG failed: {res:?}");
|
||||
let bytes = std::fs::read(&tmp).unwrap();
|
||||
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
assert_eq!(&bytes[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let _ = NodeId::new("n10"); // silence import-used warning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_raster_writes_jpeg_with_white_background() {
|
||||
use openpencil_shell_core::document::{Node, NodeKind};
|
||||
let mut doc = openpencil_shell_core::document::Document::empty();
|
||||
let page = doc.pages.get_mut(0).unwrap();
|
||||
page.children.clear();
|
||||
let mut n = Node::leaf("n10", NodeKind::Rect, "r");
|
||||
n.bounds = Rect::xywh(0.0, 0.0, 80.0, 40.0);
|
||||
n.fill = Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
|
||||
page.children.push(n);
|
||||
let scene = scene_with(vec![filled_rect(
|
||||
"n10",
|
||||
0.0,
|
||||
0.0,
|
||||
80.0,
|
||||
40.0,
|
||||
Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 },
|
||||
)]);
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.jpg", std::process::id()));
|
||||
let res = export_raster(&doc, &tmp, RasterFormat::Jpeg, 1.0);
|
||||
let res = export_raster(&scene, &tmp, RasterFormat::Jpeg, 1.0);
|
||||
assert!(res.is_ok(), "export_raster JPEG failed: {res:?}");
|
||||
let bytes = std::fs::read(&tmp).unwrap();
|
||||
// JPEG SOI marker: FF D8 FF
|
||||
|
|
@ -765,19 +671,99 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn export_raster_scale_clamps_extreme_values() {
|
||||
use openpencil_shell_core::document::{Node, NodeKind};
|
||||
let mut doc = openpencil_shell_core::document::Document::empty();
|
||||
let page = doc.pages.get_mut(0).unwrap();
|
||||
page.children.clear();
|
||||
let mut n = Node::leaf("n10", NodeKind::Rect, "r");
|
||||
n.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0);
|
||||
n.fill = Some(Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 });
|
||||
page.children.push(n);
|
||||
let scene = scene_with(vec![filled_rect(
|
||||
"n10",
|
||||
0.0,
|
||||
0.0,
|
||||
10.0,
|
||||
10.0,
|
||||
Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
|
||||
)]);
|
||||
// Both extremes should succeed (clamped silently) rather than
|
||||
// allocating a gigapixel surface or zero-sized output.
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-clamp-{}.png", std::process::id()));
|
||||
assert!(export_raster(&doc, &tmp, RasterFormat::Png, 0.001).is_ok());
|
||||
assert!(export_raster(&doc, &tmp, RasterFormat::Png, 1000.0).is_ok());
|
||||
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 0.001).is_ok());
|
||||
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 1000.0).is_ok());
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_raster_fails_on_empty_scene() {
|
||||
let scene = scene_with(Vec::new());
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-empty-{}.png", std::process::id()));
|
||||
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
|
||||
assert!(res.is_err(), "expected Err on empty scene, got {res:?}");
|
||||
assert_eq!(res.unwrap_err(), "nothing to export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_raster_applies_flex_layout_from_editor_state() {
|
||||
// A vertical flex frame with a `fill_container`-width child:
|
||||
// the child's authored width is the collapsed flex token, so
|
||||
// the resolved width (375 px = the root frame width) only
|
||||
// appears after jian's flex pass. Export must render the
|
||||
// RESOLVED geometry — proven here by `page_bounds` over the
|
||||
// built `LayoutScene` covering the full 375 px root width.
|
||||
let src = r##"{
|
||||
"version":"1.0.0",
|
||||
"pages":[{
|
||||
"id":"p1","name":"Page 1",
|
||||
"children":[{
|
||||
"type":"frame","id":"root","width":375,"height":200,
|
||||
"layout":"vertical","gap":16,
|
||||
"children":[
|
||||
{"type":"rectangle","id":"r1","width":"fill_container","height":40,
|
||||
"fill":[{"type":"solid","color":"#3366FF"}]}
|
||||
]
|
||||
}]
|
||||
}],
|
||||
"children":[]
|
||||
}"##;
|
||||
let parsed = jian_ops_schema::load_str(src).expect("parse .op fixture");
|
||||
let state = op_editor_core::EditorState::from_document(parsed.value);
|
||||
let scene = op_pen_loader::editor_state_to_layout_scene(&state);
|
||||
// Flex stretched the child to the 375 px root width.
|
||||
let child = &scene.pages[0].children[0].children[0];
|
||||
assert_eq!(child.id, "r1");
|
||||
assert_eq!(child.bounds.size.x, 375.0, "fill_container stretched via taffy");
|
||||
// page_bounds covers the resolved 375 px-wide root.
|
||||
let b = page_bounds(scene.active_page().unwrap()).expect("paintable bounds");
|
||||
assert_eq!(b.size.x, 375.0, "page bounds reflect resolved layout width");
|
||||
// And the export succeeds against the layout-resolved scene.
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-flex-{}.png", std::process::id()));
|
||||
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
|
||||
assert!(res.is_ok(), "flex export failed: {res:?}");
|
||||
let bytes = std::fs::read(&tmp).unwrap();
|
||||
assert_eq!(&bytes[..8], &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_bounds_covers_layout_resolved_child_geometry() {
|
||||
use openpencil_shell_core::document::NodeKind;
|
||||
use openpencil_shell_core::Rect;
|
||||
// A frame at (10,10) 200x100 with a child the layout pass
|
||||
// resolved to the frame's full width — page_bounds must cover
|
||||
// the resolved child bounds, not authored coords.
|
||||
let mut frame = SceneNode::leaf("frame", NodeKind::Frame);
|
||||
frame.bounds = Rect::xywh(10.0, 10.0, 200.0, 100.0);
|
||||
frame.fill = Some(Color { r: 0.9, g: 0.9, b: 0.9, a: 1.0 });
|
||||
let mut child = filled_rect(
|
||||
"child",
|
||||
10.0,
|
||||
10.0,
|
||||
200.0,
|
||||
40.0,
|
||||
Color { r: 0.1, g: 0.2, b: 0.3, a: 1.0 },
|
||||
);
|
||||
child.bounds = Rect::xywh(10.0, 10.0, 200.0, 40.0);
|
||||
frame.children = vec![child];
|
||||
let scene = scene_with(vec![frame]);
|
||||
let page = scene.active_page().unwrap();
|
||||
let b = page_bounds(page).expect("page has paintable bounds");
|
||||
assert_eq!(b.origin.x, 10.0);
|
||||
assert_eq!(b.origin.y, 10.0);
|
||||
assert_eq!(b.size.x, 200.0);
|
||||
assert_eq!(b.size.y, 100.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
303
crates/openpencil-desktop/src/export/export_svg.rs
Normal file
303
crates/openpencil-desktop/src/export/export_svg.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
//! Hand-rolled SVG serializer for the active page of a [`LayoutScene`].
|
||||
//!
|
||||
//! Walks the same `SceneNode` tree the raster path paints and emits
|
||||
//! one SVG element per node kind. Geometry is layout-resolved, so the
|
||||
//! element coordinates are absolute doc-space — only the `viewBox` is
|
||||
//! offset by the page bounds + margin. Rotation wraps the element (and
|
||||
//! its children) in a `<g transform="rotate(...)">`, matching how the
|
||||
//! raster path composes `Canvas::rotate`.
|
||||
|
||||
use openpencil_shell_core::document::NodeKind;
|
||||
use openpencil_shell_core::layout_scene::{LayoutScene, SceneNode};
|
||||
use openpencil_shell_core::Color;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
use super::{MARGIN, TEXT_DEFAULT_FILL, TEXT_DEFAULT_FONT_SIZE};
|
||||
|
||||
/// Serialize the scene's active page to an SVG file at `target`.
|
||||
pub fn export_svg(scene: &LayoutScene, target: &StdPath) -> Result<(), String> {
|
||||
let Some(page) = scene.active_page() else {
|
||||
return Err("no active page".into());
|
||||
};
|
||||
let bounds = super::page_bounds(page).ok_or("nothing to export")?;
|
||||
let view_x = bounds.origin.x - MARGIN;
|
||||
let view_y = bounds.origin.y - MARGIN;
|
||||
let view_w = bounds.size.x + MARGIN * 2.0;
|
||||
let view_h = bounds.size.y + MARGIN * 2.0;
|
||||
let mut svg = String::with_capacity(4096);
|
||||
let _ = write!(
|
||||
svg,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view_x} {view_y} {view_w} {view_h}" width="{view_w}" height="{view_h}">"#
|
||||
);
|
||||
for node in &page.children {
|
||||
emit_node(&mut svg, node);
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
std::fs::write(target, svg).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_node(out: &mut String, n: &SceneNode) {
|
||||
if n.hidden {
|
||||
return;
|
||||
}
|
||||
// SVG groups inherit transform onto children — same composition
|
||||
// model as the raster `paint_node`. Rotation pivots around the
|
||||
// node's `aggregate_bounds` centre, matching the raster path.
|
||||
let pivot = n.aggregate_bounds();
|
||||
let needs_g = n.rotation.abs() > f32::EPSILON
|
||||
&& (pivot.size.x != 0.0 || pivot.size.y != 0.0);
|
||||
if needs_g {
|
||||
let cx = pivot.origin.x + pivot.size.x * 0.5;
|
||||
let cy = pivot.origin.y + pivot.size.y * 0.5;
|
||||
let deg = n.rotation.to_degrees();
|
||||
let _ = write!(out, r#"<g transform="rotate({deg} {cx} {cy})">"#);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Rect | NodeKind::Frame => emit_rect(out, n),
|
||||
NodeKind::Ellipse => emit_ellipse(out, n),
|
||||
NodeKind::Polygon => emit_polygon(out, n),
|
||||
NodeKind::Line => emit_line(out, n),
|
||||
NodeKind::Path => emit_path(out, n),
|
||||
NodeKind::Text => emit_text(out, n),
|
||||
// Group + tagged kinds (`icon_font`) emit no own element.
|
||||
NodeKind::Group | NodeKind::Other(_) => {}
|
||||
}
|
||||
for child in &n.children {
|
||||
emit_node(out, child);
|
||||
}
|
||||
if needs_g {
|
||||
out.push_str("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rect(out: &mut String, n: &SceneNode) {
|
||||
if n.fill.is_none() && n.stroke.is_none() && !matches!(n.kind, NodeKind::Rect) {
|
||||
return;
|
||||
}
|
||||
let r = normalize(n);
|
||||
if r.0.size.x == 0.0 && r.0.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let rx = if n.corner_radius > 0.0 {
|
||||
format!(r#" rx="{}""#, n.corner_radius)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<rect x="{}" y="{}" width="{}" height="{}"{rx}{}/>"#,
|
||||
r.0.origin.x,
|
||||
r.0.origin.y,
|
||||
r.0.size.x,
|
||||
r.0.size.y,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_ellipse(out: &mut String, n: &SceneNode) {
|
||||
let (r, _) = normalize(n);
|
||||
if r.size.x == 0.0 || r.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let cx = r.origin.x + r.size.x * 0.5;
|
||||
let cy = r.origin.y + r.size.y * 0.5;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<ellipse cx="{cx}" cy="{cy}" rx="{}" ry="{}"{}/>"#,
|
||||
r.size.x * 0.5,
|
||||
r.size.y * 0.5,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_polygon(out: &mut String, n: &SceneNode) {
|
||||
let (r, _) = normalize(n);
|
||||
if r.size.x == 0.0 || r.size.y == 0.0 {
|
||||
return;
|
||||
}
|
||||
let cx = r.origin.x + r.size.x * 0.5;
|
||||
let top = r.origin.y;
|
||||
let left = r.origin.x;
|
||||
let right = r.origin.x + r.size.x;
|
||||
let bottom = r.origin.y + r.size.y;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<polygon points="{cx},{top} {left},{bottom} {right},{bottom}"{}/>"#,
|
||||
fill_stroke_attrs(n),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_line(out: &mut String, n: &SceneNode) {
|
||||
let (color, width) = match n.stroke {
|
||||
Some(s) => (s.color, s.width),
|
||||
None => (n.fill.unwrap_or(Color::BLACK), 1.5),
|
||||
};
|
||||
let r = n.bounds;
|
||||
let x2 = r.origin.x + r.size.x;
|
||||
let y2 = r.origin.y + r.size.y;
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<line x1="{}" y1="{}" x2="{x2}" y2="{y2}"{}/>"#,
|
||||
r.origin.x,
|
||||
r.origin.y,
|
||||
stroke_attrs(color, width),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_text(out: &mut String, n: &SceneNode) {
|
||||
let Some(text) = n.text.as_deref() else { return };
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (r, _) = normalize(n);
|
||||
// Same defaults as the raster `paint_text` + editor canvas so the
|
||||
// SVG renders identically to what the user sees on screen.
|
||||
let color = n.fill.unwrap_or(TEXT_DEFAULT_FILL);
|
||||
let base_size = if n.font_size > 0.0 {
|
||||
n.font_size
|
||||
} else {
|
||||
TEXT_DEFAULT_FONT_SIZE
|
||||
};
|
||||
let line_h = base_size * 1.35;
|
||||
let fill_attr = if color.a < 0.999 {
|
||||
format!(r#" fill="{}" fill-opacity="{}""#, color_to_rgb(color), color.a)
|
||||
} else {
|
||||
format!(r#" fill="{}""#, color_to_rgb(color))
|
||||
};
|
||||
let weight_attr = if n.font_weight > 0 {
|
||||
format!(r#" font-weight="{}""#, n.font_weight)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut baseline_y = r.origin.y + base_size + 1.0;
|
||||
for line in text.split('\n') {
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<text x="{}" y="{baseline_y}" font-family="system-ui, sans-serif" font-size="{base_size}"{weight_attr}{fill_attr}>{}</text>"#,
|
||||
r.origin.x,
|
||||
xml_escape(line),
|
||||
);
|
||||
baseline_y += line_h;
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_path(out: &mut String, n: &SceneNode) {
|
||||
if n.points.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let (color, width) = match n.stroke {
|
||||
Some(s) => (s.color, s.width),
|
||||
None => (n.fill.unwrap_or(Color::BLACK), 1.5),
|
||||
};
|
||||
let mut d = String::with_capacity(n.points.len() * 16);
|
||||
let _ = write!(d, "M{} {}", n.points[0].x, n.points[0].y);
|
||||
for p in &n.points[1..] {
|
||||
let _ = write!(d, " L{} {}", p.x, p.y);
|
||||
}
|
||||
let _ = write!(
|
||||
out,
|
||||
r#"<path d="{d}" fill="none"{}/>"#,
|
||||
stroke_attrs(color, width),
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalised bounds for `n` (defensive against negative extents) —
|
||||
/// returns the rect and a unit tuple so callers can `(r, _)`-destructure.
|
||||
fn normalize(n: &SceneNode) -> (openpencil_shell_core::Rect, ()) {
|
||||
let r = n.bounds;
|
||||
let x0 = r.origin.x.min(r.origin.x + r.size.x);
|
||||
let y0 = r.origin.y.min(r.origin.y + r.size.y);
|
||||
(
|
||||
openpencil_shell_core::Rect::xywh(x0, y0, r.size.x.abs(), r.size.y.abs()),
|
||||
(),
|
||||
)
|
||||
}
|
||||
|
||||
fn xml_escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_stroke_attrs(n: &SceneNode) -> String {
|
||||
let mut s = String::new();
|
||||
if let Some(fill) = n.fill {
|
||||
let _ = write!(s, r#" fill="{}""#, color_to_rgb(fill));
|
||||
if fill.a < 0.999 {
|
||||
let _ = write!(s, r#" fill-opacity="{}""#, fill.a);
|
||||
}
|
||||
} else {
|
||||
s.push_str(r#" fill="none""#);
|
||||
}
|
||||
if let Some(stroke) = n.stroke {
|
||||
s.push_str(&stroke_attrs(stroke.color, stroke.width));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Stroke `color` + `width` attributes for any element that paints a
|
||||
/// stroke. Emits `stroke-opacity` when alpha < 1 so semi-transparent
|
||||
/// strokes round-trip through SVG instead of collapsing to opaque.
|
||||
fn stroke_attrs(color: Color, width: f32) -> String {
|
||||
let mut s = format!(r#" stroke="{}" stroke-width="{}""#, color_to_rgb(color), width);
|
||||
if color.a < 0.999 {
|
||||
let _ = write!(s, r#" stroke-opacity="{}""#, color.a);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn color_to_rgb(c: Color) -> String {
|
||||
format!(
|
||||
"rgb({},{},{})",
|
||||
(c.r.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(c.g.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(c.b.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::export::test_support::{filled_rect, scene_with};
|
||||
|
||||
#[test]
|
||||
fn export_svg_writes_rect_element() {
|
||||
let scene = scene_with(vec![filled_rect(
|
||||
"r1",
|
||||
5.0,
|
||||
5.0,
|
||||
120.0,
|
||||
60.0,
|
||||
Color { r: 0.2, g: 0.4, b: 0.6, a: 1.0 },
|
||||
)]);
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-svg-{}.svg", std::process::id()));
|
||||
let res = export_svg(&scene, &tmp);
|
||||
assert!(res.is_ok(), "export_svg failed: {res:?}");
|
||||
let body = std::fs::read_to_string(&tmp).unwrap();
|
||||
assert!(body.starts_with("<svg "), "missing svg root: {body}");
|
||||
assert!(body.contains("<rect "), "missing rect element: {body}");
|
||||
assert!(body.contains(r#"width="120""#), "rect width wrong: {body}");
|
||||
assert!(body.ends_with("</svg>"), "missing svg close: {body}");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_svg_fails_on_empty_scene() {
|
||||
let scene = scene_with(Vec::new());
|
||||
let tmp = std::env::temp_dir().join(format!("op-svg-empty-{}.svg", std::process::id()));
|
||||
let res = export_svg(&scene, &tmp);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(res.unwrap_err(), "nothing to export");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,29 @@
|
|||
//! PDF multi-page export. Skia ships a built-in PDF backend
|
||||
//! (`skia_safe::pdf::new_document`) — each `PenPage` becomes one
|
||||
//! (`skia_safe::pdf::new_document`) — each [`ScenePage`] 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.
|
||||
//! DCTDecode JPEG blobs); skia's backend emits real vector PDF ops,
|
||||
//! which keeps glyphs + shapes selectable + zoom-clean.
|
||||
//!
|
||||
//! The export consumes a layout-resolved [`LayoutScene`] — every node
|
||||
//! kind paints through the shared `crate::export::paint_node`, the
|
||||
//! same painter the raster path uses, so PDF / PNG / SVG stay in
|
||||
//! lockstep.
|
||||
|
||||
use openpencil_shell_core::document::Document;
|
||||
use openpencil_shell_core::layout_scene::LayoutScene;
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
const PDF_MARGIN: f32 = 16.0;
|
||||
|
||||
/// Emit every page in `doc` as one PDF page each. Every page emits
|
||||
/// Emit every page in `scene` as one PDF page each. Every page emits
|
||||
/// at the SAME size — the union of all page bounds plus a 16-pt
|
||||
/// margin — so PDF viewers don't jump between heterogeneous page
|
||||
/// sizes on scroll/zoom (codex CONCERN: mixed page sizes were a
|
||||
/// papercut). Each page's content is positioned within that frame
|
||||
/// at its own (origin.x, origin.y); empty pages are skipped.
|
||||
/// Returns Err when no page has paintable content.
|
||||
pub fn export_pdf(doc: &Document, target: &StdPath) -> Result<(), String> {
|
||||
let bounds_per_page: Vec<_> = doc
|
||||
/// sizes on scroll/zoom. Each page's content is positioned within
|
||||
/// that frame at its own `(origin.x, origin.y)`; empty pages are
|
||||
/// skipped. Returns Err when no page has paintable content.
|
||||
pub fn export_pdf(scene: &LayoutScene, target: &StdPath) -> Result<(), String> {
|
||||
let bounds_per_page: Vec<_> = scene
|
||||
.pages
|
||||
.iter()
|
||||
.map(crate::export::page_bounds)
|
||||
|
|
@ -40,7 +44,7 @@ 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);
|
||||
for (page, bounds_opt) in doc.pages.iter().zip(bounds_per_page.iter()) {
|
||||
for (page, bounds_opt) in scene.pages.iter().zip(bounds_per_page.iter()) {
|
||||
let Some(bounds) = bounds_opt else { continue };
|
||||
let mut on_page = pdf.begin_page(skia_safe::Size::new(page_w, page_h), None);
|
||||
let canvas = on_page.canvas();
|
||||
|
|
@ -58,37 +62,40 @@ pub fn export_pdf(doc: &Document, target: &StdPath) -> Result<(), String> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openpencil_shell_core::document::{Node, NodeKind};
|
||||
use crate::export::test_support::filled_rect;
|
||||
use openpencil_shell_core::document::NodeKind;
|
||||
use openpencil_shell_core::layout_scene::{ScenePage, SceneNode};
|
||||
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("n10", 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("n2", "Page 2", Vec::new());
|
||||
let mut n1 = Node::leaf("n20", 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);
|
||||
fn pdf_export_emits_one_page_per_scene_page() {
|
||||
let mut ellipse = SceneNode::leaf("n20", NodeKind::Ellipse);
|
||||
ellipse.bounds = Rect::xywh(0.0, 0.0, 40.0, 40.0);
|
||||
ellipse.fill = Some(Color { r: 0.8, g: 0.2, b: 0.4, a: 1.0 });
|
||||
let scene = LayoutScene {
|
||||
pages: vec![
|
||||
ScenePage {
|
||||
id: "p1".into(),
|
||||
name: "Page 1".into(),
|
||||
children: vec![filled_rect(
|
||||
"n10",
|
||||
0.0,
|
||||
0.0,
|
||||
60.0,
|
||||
40.0,
|
||||
Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0 },
|
||||
)],
|
||||
},
|
||||
ScenePage {
|
||||
id: "p2".into(),
|
||||
name: "Page 2".into(),
|
||||
children: vec![ellipse],
|
||||
},
|
||||
],
|
||||
active_page_index: 0,
|
||||
};
|
||||
let tmp = std::env::temp_dir().join(format!("op-export-pdf-{}.pdf", std::process::id()));
|
||||
let res = export_pdf(&doc, &tmp);
|
||||
let res = export_pdf(&scene, &tmp);
|
||||
assert!(res.is_ok(), "export_pdf failed: {res:?}");
|
||||
let bytes = std::fs::read(&tmp).unwrap();
|
||||
// PDF header magic: %PDF-
|
||||
|
|
@ -104,13 +111,15 @@ mod tests {
|
|||
|
||||
#[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 scene = LayoutScene {
|
||||
pages: vec![
|
||||
ScenePage { id: "p1".into(), name: "P1".into(), children: Vec::new() },
|
||||
ScenePage { id: "p2".into(), name: "P2".into(), children: Vec::new() },
|
||||
],
|
||||
active_page_index: 0,
|
||||
};
|
||||
let tmp = std::env::temp_dir().join(format!("op-pdf-empty-{}.pdf", std::process::id()));
|
||||
let res = export_pdf(&doc, &tmp);
|
||||
let res = export_pdf(&scene, &tmp);
|
||||
assert!(res.is_err(), "expected Err on all-empty, got {res:?}");
|
||||
assert_eq!(res.unwrap_err(), "nothing to export");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use op_editor_core::EditorState;
|
||||
use openpencil_shell_core::document::Document;
|
||||
use openpencil_shell_native::WidgetHostNative;
|
||||
|
||||
/// Editor view-state persisted alongside the canonical `.op` file.
|
||||
|
|
@ -341,31 +340,33 @@ pub fn run_action(
|
|||
.set_file_name(&default_name)
|
||||
.save_file()
|
||||
{
|
||||
// The export renderers read a `&Document` — derive an
|
||||
// owned paint snapshot once and hand them a borrow.
|
||||
let doc: Document = host.document();
|
||||
let doc = &doc;
|
||||
// The export renderers consume a layout-resolved
|
||||
// `LayoutScene` — build one from the live editor state
|
||||
// (runs jian's flex pass + `$ref` fill resolution).
|
||||
let scene =
|
||||
op_pen_loader::editor_state_to_layout_scene(host.editor_state());
|
||||
let scene = &scene;
|
||||
let result: Result<(), String> = match fmt {
|
||||
Fmt::Png => crate::export::export_raster(
|
||||
doc,
|
||||
scene,
|
||||
&path,
|
||||
crate::export::RasterFormat::Png,
|
||||
scale,
|
||||
),
|
||||
Fmt::Jpeg => crate::export::export_raster(
|
||||
doc,
|
||||
scene,
|
||||
&path,
|
||||
crate::export::RasterFormat::Jpeg,
|
||||
scale,
|
||||
),
|
||||
Fmt::Webp => crate::export::export_raster(
|
||||
doc,
|
||||
scene,
|
||||
&path,
|
||||
crate::export::RasterFormat::Webp,
|
||||
scale,
|
||||
),
|
||||
Fmt::Svg => crate::export::export_svg(doc, &path),
|
||||
Fmt::Pdf => crate::export_pdf::export_pdf(doc, &path),
|
||||
Fmt::Svg => crate::export::export_svg(scene, &path),
|
||||
Fmt::Pdf => crate::export_pdf::export_pdf(scene, &path),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("[export-image] {e}");
|
||||
|
|
|
|||
|
|
@ -440,14 +440,6 @@ impl WidgetHostNative {
|
|||
self.editor_state_dirty = true;
|
||||
}
|
||||
|
||||
/// Derive an owned, paint-only `Document` from the live editor
|
||||
/// state. Used by the desktop binary's file-I/O / export code,
|
||||
/// whose raster / PDF renderers still read a `&Document`. Not on
|
||||
/// the input path — built fresh per call.
|
||||
pub fn document(&self) -> openpencil_shell_core::document::Document {
|
||||
derive_paint_doc(&self.editor_state)
|
||||
}
|
||||
|
||||
/// Borrow the canonical-model editor state — the host's single
|
||||
/// source of truth.
|
||||
pub fn editor_state(&self) -> &op_editor_core::EditorState {
|
||||
|
|
@ -520,17 +512,6 @@ impl WidgetHostNative {
|
|||
}
|
||||
}
|
||||
|
||||
/// Derive a faithful paint-only `Document` from an `EditorState`:
|
||||
/// node tree + geometry from the canonical doc, then the chrome /
|
||||
/// chat / components / variable / scalar state layered on.
|
||||
pub(in crate::widget_host) fn derive_paint_doc(
|
||||
state: &op_editor_core::EditorState,
|
||||
) -> openpencil_shell_core::document::Document {
|
||||
let mut d = op_pen_loader::pen_document_to_document(&state.doc);
|
||||
op_pen_loader::apply_editor_state_ui(&mut d, state);
|
||||
d
|
||||
}
|
||||
|
||||
impl Default for WidgetHostNative {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
|
|||
Loading…
Reference in a new issue