feat(export): render resolved pages and nodes to bytes
This commit is contained in:
parent
4542d62a98
commit
6fc717546a
|
|
@ -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);
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue