From 6fc717546abe1ced2889906acde6c45a3e6ff1e2 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 12 Jul 2026 17:42:52 +0800 Subject: [PATCH] feat(export): render resolved pages and nodes to bytes --- crates/op-host-services/src/export.rs | 32 ++++++- crates/op-host-services/src/export/tests.rs | 59 +++++++++++- crates/op-host-services/src/export_pdf.rs | 100 +++++++++++++++++++- 3 files changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/op-host-services/src/export.rs b/crates/op-host-services/src/export.rs index 763bbe4a2..ba609ddef 100644 --- a/crates/op-host-services/src/export.rs +++ b/crates/op-host-services/src/export.rs @@ -182,19 +182,45 @@ pub fn render_node_raster_bytes( format: RasterFormat, scale: f32, ) -> Result, String> { - let scale = clamp_scale(scale); let Some(page) = scene.active_page() else { return Err("no active page".into()); }; + render_node_on_page_raster_bytes(page, node_id, format, scale) +} + +/// Render one resolved page to encoded raster bytes without changing or +/// consulting [`LayoutScene::active_page_index`]. +pub fn render_page_raster_bytes( + page: &ScenePage, + format: RasterFormat, + scale: f32, +) -> Result, String> { + let bounds = page_bounds(page) + .ok_or_else(|| format!("page {} has no visible content to export", page.id))?; + render_raster_bytes(bounds, format, clamp_scale(scale), MARGIN, |canvas| { + paint_nodes(canvas, &page.children); + }) +} + +/// Render one node from its resolved containing page to encoded raster bytes. +pub fn render_node_on_page_raster_bytes( + page: &ScenePage, + node_id: &str, + format: RasterFormat, + scale: f32, +) -> Result, String> { let node = page .find(node_id) - .ok_or_else(|| format!("node {node_id} not found on the active page"))?; + .ok_or_else(|| format!("node {node_id} not found on page {}", page.id))?; + if node.hidden { + return Err(format!("node {node_id} is hidden and cannot be exported")); + } let mut acc = BoundsAcc::new(); collect_bounds(node, glam::Affine2::IDENTITY, &mut acc); let bounds = acc .into_rect() .ok_or_else(|| format!("node {node_id} paints nothing"))?; - render_raster_bytes(bounds, format, scale, MARGIN, |canvas| { + render_raster_bytes(bounds, format, clamp_scale(scale), MARGIN, |canvas| { paint_node(canvas, node); }) } diff --git a/crates/op-host-services/src/export/tests.rs b/crates/op-host-services/src/export/tests.rs index e9383c6b3..c48aed670 100644 --- a/crates/op-host-services/src/export/tests.rs +++ b/crates/op-host-services/src/export/tests.rs @@ -4,7 +4,9 @@ use super::test_support::{filled_rect, scene_with}; use super::*; -use op_editor_ui::layout_scene::{NodeKind, SceneNode, SceneStroke, SceneStrokeAlign}; +use op_editor_ui::layout_scene::{ + LayoutScene, NodeKind, SceneNode, ScenePage, SceneStroke, SceneStrokeAlign, +}; use op_editor_ui::{Color, Rect}; #[test] @@ -69,6 +71,61 @@ fn export_raster_writes_png_for_minimal_scene() { let _ = std::fs::remove_file(&tmp); } +fn two_page_scene() -> LayoutScene { + LayoutScene { + pages: vec![ + ScenePage { + id: "page-one".into(), + name: "Page One".into(), + children: vec![filled_rect( + "page-one-node", + 0.0, + 0.0, + 20.0, + 20.0, + Color::BLACK, + )], + }, + ScenePage { + id: "page-two".into(), + name: "Page Two".into(), + children: vec![filled_rect( + "page-two-node", + 100.0, + 120.0, + 40.0, + 30.0, + Color::BLACK, + )], + }, + ], + active_page_index: 0, + } +} + +#[test] +fn render_page_raster_bytes_does_not_depend_on_active_page() { + let scene = two_page_scene(); + let bytes = render_page_raster_bytes(&scene.pages[1], RasterFormat::Png, 1.0) + .expect("render second page"); + assert_eq!( + &bytes[..8], + &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] + ); +} + +#[test] +fn render_node_on_page_raster_bytes_uses_resolved_page() { + let scene = two_page_scene(); + let bytes = + render_node_on_page_raster_bytes(&scene.pages[1], "page-two-node", RasterFormat::Png, 1.0) + .expect("render node on second page"); + assert_eq!( + &bytes[..8], + &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] + ); +} + #[test] fn export_raster_writes_jpeg_with_white_background() { let scene = scene_with(vec![filled_rect( diff --git a/crates/op-host-services/src/export_pdf.rs b/crates/op-host-services/src/export_pdf.rs index 88923dfd8..aba4c1de5 100644 --- a/crates/op-host-services/src/export_pdf.rs +++ b/crates/op-host-services/src/export_pdf.rs @@ -12,11 +12,62 @@ //! `export/scene_painter.rs`), so PDF / PNG / screenshots stay in //! lockstep with the editor canvas. -use op_editor_ui::layout_scene::LayoutScene; +use op_editor_ui::layout_scene::{LayoutScene, ScenePage}; +use op_editor_ui::Rect; use std::path::Path as StdPath; const PDF_MARGIN: f32 = 16.0; +fn render_pdf_item( + bounds: Rect, + paint: impl FnOnce(&skia_safe::Canvas), +) -> Result, String> { + let mut buf = Vec::new(); + { + let mut pdf = skia_safe::pdf::new_document(&mut buf, None); + let page_size = skia_safe::Size::new( + bounds.size.x + PDF_MARGIN * 2.0, + bounds.size.y + PDF_MARGIN * 2.0, + ); + let mut page = pdf.begin_page(page_size, None); + let canvas = page.canvas(); + canvas.translate((PDF_MARGIN - bounds.origin.x, PDF_MARGIN - bounds.origin.y)); + paint(canvas); + pdf = page.end_page(); + pdf.close(); + } + if buf.is_empty() { + Err("PDF encoder returned no bytes".into()) + } else { + Ok(buf) + } +} + +/// Render one resolved page as a single tightly cropped PDF page. +pub fn render_page_pdf_bytes(page: &ScenePage) -> Result, String> { + let bounds = crate::export::page_bounds(page) + .ok_or_else(|| format!("page {} has no visible content to export", page.id))?; + render_pdf_item(bounds, |canvas| { + crate::export::paint_nodes(canvas, &page.children) + }) +} + +/// Render one node from its resolved containing page as a single PDF page. +pub fn render_node_on_page_pdf_bytes(page: &ScenePage, node_id: &str) -> Result, String> { + let node = page + .find(node_id) + .ok_or_else(|| format!("node {node_id} not found on page {}", page.id))?; + if node.hidden { + return Err(format!("node {node_id} is hidden and cannot be exported")); + } + let mut acc = crate::export::BoundsAcc::new(); + crate::export::collect_bounds(node, glam::Affine2::IDENTITY, &mut acc); + let bounds = acc + .into_rect() + .ok_or_else(|| format!("node {node_id} paints nothing"))?; + render_pdf_item(bounds, |canvas| crate::export::paint_node(canvas, node)) +} + /// In-memory PDF variant: render each requested node as one PDF page /// (cropped to that node's painted bounds). Returns the raw PDF bytes /// (`%PDF-` magic through `%%EOF`) so the caller can base64-encode or @@ -121,6 +172,53 @@ mod tests { use op_editor_ui::layout_scene::{SceneNode, ScenePage}; use op_editor_ui::{Color, Rect}; + fn two_page_scene() -> LayoutScene { + LayoutScene { + pages: vec![ + ScenePage { + id: "page-one".into(), + name: "Page One".into(), + children: vec![filled_rect( + "page-one-node", + 0.0, + 0.0, + 20.0, + 20.0, + Color::BLACK, + )], + }, + ScenePage { + id: "page-two".into(), + name: "Page Two".into(), + children: vec![filled_rect( + "page-two-node", + 100.0, + 120.0, + 40.0, + 30.0, + Color::BLACK, + )], + }, + ], + active_page_index: 0, + } + } + + #[test] + fn render_page_pdf_bytes_crops_one_resolved_page() { + let scene = two_page_scene(); + let bytes = render_page_pdf_bytes(&scene.pages[1]).expect("page PDF"); + assert_eq!(&bytes[..5], b"%PDF-"); + } + + #[test] + fn render_node_on_page_pdf_bytes_accepts_non_active_page() { + let scene = two_page_scene(); + let bytes = + render_node_on_page_pdf_bytes(&scene.pages[1], "page-two-node").expect("node PDF"); + assert_eq!(&bytes[..5], b"%PDF-"); + } + #[test] fn pdf_export_emits_one_page_per_scene_page() { let mut ellipse = SceneNode::leaf("n20", NodeKind::Ellipse);