Skip to content

Commit 2267c1e

Browse files
Merge pull request #484 from gridaco/canary
Grida Canvas - Export Settings (`313`)
2 parents f9e39c9 + b3b2ef1 commit 2267c1e

File tree

29 files changed

+1701
-528
lines changed

29 files changed

+1701
-528
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:a26855f25af8aaf11af39419fcb537292e13bde94d6345bcd23502bd0cebbe1a
3-
size 12866529
2+
oid sha256:beea24d884591a1ea9e50ee1092a47d9c7fac7c36967f7357b0029f8a0563077
3+
size 12869282

crates/grida-canvas-wasm/lib/index.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,56 @@ export namespace types {
3939
};
4040

4141
export type ExportConstraints = {
42-
type: "SCALE" | "WIDTH" | "HEIGHT";
42+
/**
43+
* - none: as-is, no resizing, scaling
44+
* - scale: scale with factor
45+
* - scale-to-fit-width: scale to fit width (with same aspect ratio)
46+
* - scale-to-fit-height: scale to fit height (with same aspect ratio)
47+
*/
48+
type: "none" | "scale" | "scale-to-fit-width" | "scale-to-fit-height";
49+
/**
50+
* - scale: scale factor
51+
* - scale-to-fit-width: width in pixels
52+
* - scale-to-fit-height: height in pixels
53+
*/
4354
value: number;
4455
};
4556

4657
export type ExportAs = ExportAsImage | ExportAsPDF | ExportAsSVG;
4758
export type ExportAsPDF = { format: "PDF" };
4859
export type ExportAsSVG = { format: "SVG" };
49-
export type ExportAsImage = {
50-
format: "PNG" | "JPEG" | "WEBP" | "BMP";
60+
export type ExportAsPNG = {
61+
format: "PNG";
5162
constraints: ExportConstraints;
5263
};
64+
export type ExportAsJPEG = {
65+
format: "JPEG";
66+
constraints: ExportConstraints;
67+
/**
68+
* Quality setting for JPEG compression (0-100). Higher values mean better quality but larger file size.
69+
* @default 100
70+
*/
71+
quality?: number;
72+
};
73+
export type ExportAsWEBP = {
74+
format: "WEBP";
75+
constraints: ExportConstraints;
76+
/**
77+
* Quality setting for WEBP compression (0-100). Higher values mean better quality but larger file size.
78+
* Quality 100 is lossless. Lower values use lossy compression.
79+
* @default 75
80+
*/
81+
quality?: number;
82+
};
83+
export type ExportAsBMP = {
84+
format: "BMP";
85+
constraints: ExportConstraints;
86+
};
87+
export type ExportAsImage =
88+
| ExportAsPNG
89+
| ExportAsJPEG
90+
| ExportAsWEBP
91+
| ExportAsBMP;
5392

5493
export type FontKey = {
5594
family: string;

crates/grida-canvas-wasm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@grida/canvas-wasm",
33
"description": "WASM bindings for Grida Canvas",
4-
"version": "0.89.0-canary.2",
4+
"version": "0.89.0-canary.3",
55
"keywords": [
66
"grida",
77
"canvas",

crates/grida-canvas/src/export/export_as_image.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::node::schema::Size;
12
use crate::{
23
export::{ExportAsImage, ExportSize, Exported},
34
node::schema::Scene,
@@ -32,9 +33,19 @@ pub fn export_node_as_image(
3233
) -> Option<Exported> {
3334
let skfmt: EncodedImageFormat = format.clone().into();
3435

35-
let camera = Camera2D::new_from_bounds(rect);
36+
// Create camera with original bounds to determine world-space view
37+
let mut camera = Camera2D::new_from_bounds(rect);
38+
39+
// Scale the camera size to target resolution and adjust zoom to maintain same world-space view
40+
// When we increase the viewport size and zoom IN proportionally, we see the same world-space rect
41+
// but at higher resolution (scale = 2 means 2x zoom, 2x pixels, same world-space view)
42+
let scale = size.width / rect.width;
43+
camera.set_size(Size {
44+
width: size.width,
45+
height: size.height,
46+
});
47+
camera.set_zoom(scale);
3648

37-
// 2. create a renderer sharing the font repository's ByteStore
3849
let store = fonts.store();
3950
let mut r = Renderer::new_with_store(
4051
Backend::new_from_raster(size.width as i32, size.height as i32),
@@ -47,22 +58,29 @@ pub fn export_node_as_image(
4758
r.fonts = fonts.clone();
4859
r.images = images.clone();
4960
r.load_scene(scene.clone());
61+
62+
// Render directly at target resolution - this ensures fonts work correctly
5063
let image = r.snapshot();
64+
r.free();
5165

52-
let Some(data) = image.encode(None, skfmt, None) else {
53-
r.free();
66+
// Extract quality for JPEG and WEBP formats
67+
let quality = match &format {
68+
ExportAsImage::JPEG(jpeg_config) => jpeg_config.quality,
69+
ExportAsImage::WEBP(webp_config) => webp_config.quality,
70+
_ => None,
71+
};
72+
73+
let Some(data) = image.encode(None, skfmt, quality) else {
5474
return None;
5575
};
5676

57-
// 2. export node
77+
// Return the exported data
5878
let exported = match format {
5979
ExportAsImage::PNG(_) => Some(Exported::PNG(data.to_vec())),
6080
ExportAsImage::JPEG(_) => Some(Exported::JPEG(data.to_vec())),
6181
ExportAsImage::WEBP(_) => Some(Exported::WEBP(data.to_vec())),
6282
ExportAsImage::BMP(_) => Some(Exported::BMP(data.to_vec())),
6383
};
6484

65-
r.free();
66-
6785
exported
6886
}

crates/grida-canvas/src/export/export_as_pdf.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::{
44
runtime::{
55
camera::Camera2D,
66
font_repository::FontRepository,
7+
image_repository::ImageRepository,
78
scene::{Backend, Renderer, RendererOptions},
89
},
910
};
@@ -14,6 +15,7 @@ use std::io::Cursor;
1415
pub fn export_node_as_pdf(
1516
scene: &Scene,
1617
fonts: &FontRepository,
18+
images: &ImageRepository,
1719
rect: Rectangle,
1820
_options: ExportAsPDF,
1921
) -> Option<Exported> {
@@ -45,6 +47,7 @@ pub fn export_node_as_pdf(
4547
);
4648

4749
renderer.fonts = fonts.clone();
50+
renderer.images = images.clone();
4851

4952
// Load the scene
5053
renderer.load_scene(scene.clone());

crates/grida-canvas/src/export/export_as_svg.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::{
44
runtime::{
55
camera::Camera2D,
66
font_repository::FontRepository,
7+
image_repository::ImageRepository,
78
scene::{Backend, Renderer, RendererOptions},
89
},
910
};
@@ -13,6 +14,7 @@ use skia_safe::{svg, Rect as SkRect};
1314
pub fn export_node_as_svg(
1415
scene: &Scene,
1516
fonts: &FontRepository,
17+
images: &ImageRepository,
1618
rect: Rectangle,
1719
_options: ExportAsSVG,
1820
) -> Option<Exported> {
@@ -37,6 +39,7 @@ pub fn export_node_as_svg(
3739
);
3840

3941
renderer.fonts = fonts.clone();
42+
renderer.images = images.clone();
4043
renderer.load_scene(scene.clone());
4144

4245
renderer.render_to_canvas(&canvas, width, height);

crates/grida-canvas/src/export/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,13 @@ pub fn export_node_as(
101101
ExportAs::PDF(pdf_format) => pdf_format,
102102
_ => unreachable!(),
103103
};
104-
return export_node_as_pdf(scene, fonts, rect, format);
104+
return export_node_as_pdf(scene, fonts, images, rect, format);
105105
} else if format.is_format_svg() {
106106
let format: ExportAsSVG = match format {
107107
ExportAs::SVG(svg_format) => svg_format,
108108
_ => unreachable!(),
109109
};
110-
return export_node_as_svg(scene, fonts, rect, format);
110+
return export_node_as_svg(scene, fonts, images, rect, format);
111111
} else if format.is_format_image() {
112112
let format: ExportAsImage = format.clone().try_into().unwrap();
113113
return export_node_as_image(scene, fonts, images, size, rect, format);

crates/grida-canvas/src/export/types.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ use serde::Deserialize;
33
#[derive(Clone, Deserialize)]
44
#[serde(tag = "type", content = "value")]
55
pub enum ExportConstraints {
6-
#[serde(rename = "NONE")]
6+
#[serde(rename = "none")]
77
None,
8-
#[serde(rename = "SCALE")]
8+
#[serde(rename = "scale")]
99
Scale(f32),
10-
#[serde(rename = "WIDTH")]
10+
#[serde(rename = "scale-to-fit-width")]
1111
ScaleToWidth(f32),
12-
#[serde(rename = "HEIGHT")]
12+
#[serde(rename = "scale-to-fit-height")]
1313
ScaleToHeight(f32),
1414
}
1515

@@ -29,11 +29,19 @@ impl Default for ExportAsPNG {
2929
#[derive(Clone, Deserialize)]
3030
pub struct ExportAsJPEG {
3131
pub(crate) constraints: ExportConstraints,
32+
33+
/// 0-100, None means use Skia default (100)
34+
#[serde(default)]
35+
pub(crate) quality: Option<u32>,
3236
}
3337

3438
#[derive(Clone, Deserialize)]
3539
pub struct ExportAsWEBP {
3640
pub(crate) constraints: ExportConstraints,
41+
42+
/// 0-100, None means use Skia default (75)
43+
#[serde(default)]
44+
pub(crate) quality: Option<u32>,
3745
}
3846

3947
#[derive(Clone, Deserialize)]
@@ -106,8 +114,11 @@ impl ExportAs {
106114
Self::PNG(ExportAsPNG::default())
107115
}
108116

109-
pub fn jpeg(constraints: ExportConstraints) -> Self {
110-
Self::JPEG(ExportAsJPEG { constraints })
117+
pub fn jpeg(constraints: ExportConstraints, quality: Option<u32>) -> Self {
118+
Self::JPEG(ExportAsJPEG {
119+
constraints,
120+
quality,
121+
})
111122
}
112123

113124
pub fn pdf() -> Self {

editor/grida-canvas-react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
useNode,
66
useBrushState,
77
useComputedNode,
8+
useNodeMetadata,
89
useNodeActions,
910
useTransformState,
1011
useToolState,

editor/grida-canvas-react/provider.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function useNodeActions(node_id: string | undefined) {
101101
instance.commands.changeNodePropertyProps(node_id, key, value),
102102
// attributes
103103
userdata: (value: any) =>
104-
instance.commands.changeNodeUserData(node_id, value),
104+
instance.setUserData(node_id, value as Record<string, unknown> | null),
105105
name: (name: string) => {
106106
node.name = name;
107107
},
@@ -857,3 +857,24 @@ export function useTemplateDefinition(template_id: string) {
857857

858858
return templates![template_id];
859859
}
860+
861+
/**
862+
* Hook to access node metadata by namespace
863+
* @param node_id - The node ID
864+
* @param namespace - The metadata namespace ("export_settings" | "userdata")
865+
* @returns The metadata value for the specified namespace, or undefined if not set
866+
*/
867+
export function useNodeMetadata<NS extends "export_settings" | "userdata">(
868+
node_id: string,
869+
namespace: NS
870+
): NS extends "export_settings"
871+
? grida.program.document.NodeExportSettings[] | undefined
872+
: NS extends "userdata"
873+
? Record<string, unknown> | null | undefined
874+
: never {
875+
const editor = useCurrentEditor();
876+
return useEditorState(
877+
editor,
878+
(state) => state.document.metadata?.[node_id]?.[namespace]
879+
) as any;
880+
}

0 commit comments

Comments
 (0)