feat(export): render resolved pages and nodes to bytes

This commit is contained in:
Kayshen-X 2026-07-12 17:42:52 +08:00
parent 4542d62a98
commit 6fc717546a
3 changed files with 186 additions and 5 deletions

View file

@ -182,19 +182,45 @@ pub fn render_node_raster_bytes(
format: RasterFormat,
scale: f32,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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);
})
}

View file

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

View file

@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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);