fix(renderer): render icon fonts in raster export

This commit is contained in:
Fini 2026-06-07 16:51:16 +08:00
parent f0955609d7
commit 4bd85aee24
2 changed files with 149 additions and 7 deletions

View file

@ -25,7 +25,9 @@
use op_editor_ui::layout_scene::{regular_polygon_points, LayoutScene, SceneNode, ScenePage};
use op_editor_ui::layout_scene::{Effect, NodeKind};
use op_editor_ui::{Color, Point2D, Rect};
use op_editor_ui::{
Color, ImageAdjustments, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout,
};
use skia_safe::{Canvas, EncodedImageFormat, Paint, PaintStyle, Path, PathBuilder};
use std::path::Path as StdPath;
@ -311,10 +313,21 @@ fn own_paint_corners(n: &SceneNode) -> Option<Vec<glam::Vec2>> {
nr.origin.y + nr.size.y,
)
}
NodeKind::Other(tag) if tag == "icon_font" => {
if n.text.as_ref().is_none_or(|s| s.trim().is_empty()) {
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.
// Unknown tagged kinds paint no own silhouette in export;
// their bounds still contribute when authored with fill/stroke.
if n.fill.is_none() && n.stroke.is_none() {
return None;
}
@ -408,9 +421,12 @@ pub(crate) fn paint_node(canvas: &Canvas, node: &SceneNode) {
NodeKind::Rect | NodeKind::Frame => {
paint_rect(canvas, world_rect, node);
}
NodeKind::Other(tag) if tag == "icon_font" => {
paint_icon_font(canvas, world_rect, node);
}
NodeKind::Other(_) => {
// Tagged kinds (e.g. `icon_font`) have no rect silhouette
// in export; skip own paint, still recurse children.
// Unknown tagged kinds have no rect silhouette in export;
// skip own paint, still recurse children.
}
NodeKind::Ellipse => {
paint_oval(canvas, world_rect, node);
@ -577,6 +593,85 @@ fn paint_text(canvas: &Canvas, rect: Rect, node: &SceneNode) {
}
}
fn paint_icon_font(canvas: &Canvas, rect: Rect, node: &SceneNode) {
let mut backend = IconExportBackend { canvas };
op_editor_ui::widgets::icons::paint_icon_font_node(
&mut backend,
node.font_family.as_str(),
node.text.as_deref().unwrap_or(""),
normalize_rect(rect),
node.fill,
);
}
struct IconExportBackend<'a> {
canvas: &'a Canvas,
}
impl RenderBackend for IconExportBackend<'_> {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {}
fn clip_rect(&mut self, _rect: Rect) {}
fn stroke_line(&mut self, _from: Point2D, _to: Point2D, _color: Color, _width: f32) {}
fn fill_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {}
fn stroke_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color, _width: f32) {}
fn stroke_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, color: Color, width: f32) {
let Some(path) = skia_safe::utils::parse_path::from_svg(d) else {
return;
};
let scale = size / 24.0;
let mut matrix = skia_safe::Matrix::new_identity();
matrix.set_scale_translate((scale, scale), (top_left.x, top_left.y));
let path = path.with_transform(&matrix);
let mut paint = make_stroke(color, width);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_stroke_join(skia_safe::PaintJoin::Round);
self.canvas.draw_path(&path, &paint);
}
fn fill_svg_path(&mut self, d: &str, top_left: Point2D, size: f32, viewbox: f32, color: Color) {
let Some(path) = skia_safe::utils::parse_path::from_svg(d) else {
return;
};
let scale = size / viewbox.max(1.0);
let mut matrix = skia_safe::Matrix::new_identity();
matrix.set_scale_translate((scale, scale), (top_left.x, top_left.y));
let path = path.with_transform(&matrix);
self.canvas.draw_path(&path, &make_fill(color));
}
fn draw_image(&mut self, _rect: Rect, _image_id: u64, _encoded: &[u8]) {}
fn draw_image_with_mode(
&mut self,
_rect: Rect,
_image_id: u64,
_encoded: &[u8],
_mode: ImageDrawMode,
) {
}
fn draw_image_with_options(
&mut self,
_rect: Rect,
_image_id: u64,
_encoded: &[u8],
_mode: ImageDrawMode,
_adjustments: ImageAdjustments,
_opacity: f32,
) {
}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _offset: Point2D) {}
fn resize(&mut self, _width: u32, _height: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
fn paint_polyline(canvas: &Canvas, node: &SceneNode) {
if node.points.len() < 2 {
return;

View file

@ -4,7 +4,7 @@
use super::test_support::{filled_rect, scene_with};
use super::*;
use op_editor_ui::layout_scene::SceneNode;
use op_editor_ui::layout_scene::{NodeKind, SceneNode};
#[test]
fn raster_format_extension_lookup() {
@ -246,6 +246,53 @@ fn export_node_raster_crops_to_the_named_node() {
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_node_raster_paints_icon_font_glyphs() {
let mut icon = SceneNode::leaf("home-icon", NodeKind::Other("icon_font".into()));
icon.bounds = Rect::xywh(0.0, 0.0, 32.0, 32.0);
icon.text = Some("home".into());
icon.font_family = "lucide".into();
icon.fill = Some(Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
});
let scene = scene_with(vec![icon]);
let tmp = std::env::temp_dir().join(format!("op-export-icon-{}.png", std::process::id()));
let res = export_node_raster(&scene, "home-icon", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "export_node_raster icon failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
let image = skia_safe::Image::from_encoded(skia_safe::Data::new_copy(&bytes))
.expect("decode exported icon PNG");
let width = image.width();
let height = image.height();
let info = skia_safe::ImageInfo::new(
(width, height),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let stride = width as usize * 4;
let mut pixels = vec![0u8; stride * height as usize];
let ok = image.read_pixels(
&info,
pixels.as_mut_slice(),
stride,
(0, 0),
skia_safe::image::CachingHint::Allow,
);
assert!(ok, "read exported icon pixels");
let painted = pixels.chunks_exact(4).filter(|rgba| rgba[3] > 0).count();
assert!(
painted > 20,
"expected icon export to contain painted glyph pixels, got {painted}"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_node_raster_errors_on_unknown_id() {
let scene = scene_with(vec![filled_rect(