From 39924845c61093d563e4dd320532415537c60164 Mon Sep 17 00:00:00 2001 From: seorii Date: Tue, 5 May 2026 14:56:54 +0900 Subject: [PATCH] feat: replay raw svg in native skia --- Cargo.toml | 3 +- README.md | 5 +- README_EN.md | 5 +- src/renderer/skia/image_conv.rs | 82 ++++++++++++++++++++++++++++ src/renderer/skia/renderer.rs | 94 ++++++++++++++++++++++++++++++--- 5 files changed, 178 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 09a5b0872..f7577ebcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,12 +42,13 @@ console_error_panic_hook = { version = "0.1", optional = true } [features] default = ["console_error_panic_hook"] -native-skia = ["dep:skia-safe"] +native-skia = ["dep:resvg", "dep:skia-safe"] # PDF 내보내기 (네이티브 전용, Task #21) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] svg2pdf = "0.13" usvg = "0.45" +resvg = { version = "0.45", optional = true } pdf-writer = "0.12" subsetter = "0.2" ttf-parser = "0.25" diff --git a/README.md b/README.md index 5a22bef9b..3f34cf0c2 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,10 @@ v0.7.x 배포 주기 누적 외부 기여자: [@ahnbu](https://github.com/ahnbu) - The default render-diff fixtures cover basic text/table output, business-document layout, and treat-as-char object placement; override with `RHWP_RENDER_DIFF_FILES`, `RHWP_RENDER_DIFF_MAX_PAGES`, or `RHWP_RENDER_DIFF_ALL=1`. - P4 adds native-only `DocumentCore::render_page_png_native(page)` behind `--features native-skia`; it renders `PageLayerTree` to encoded PNG through `SkiaLayerRenderer`. - P5 adds native Skia equation replay from `EquationNode.layout_box`, so equations are no longer placeholder boxes in the PNG path. -- P5 replays the existing equation layout tree directly; it does not add SVG fragment rasterization, CanvasKit equation replay, or native raw-svg/form replay. +- P5 replays the existing equation layout tree directly; it does not add CanvasKit equation replay or native form replay. +- P6 adds native Skia `RawSvg` fragment rasterization through `resvg`, with external file href loading disabled. - CI covers the native Skia path with `cargo test --features native-skia skia --lib`; the feature is not available on `wasm32` targets. -- The initial native Skia path is a PNG raster backend with core image/equation replay; CanvasKit, resource interning/cache, complex text shaping, advanced image parity, and native raw-svg/form replay stay as follow-up work. +- The initial native Skia path is a PNG raster backend with core image/equation/raw-svg replay; CanvasKit, resource interning/cache, complex text shaping, advanced image parity, and native form replay stay as follow-up work. - C ABI export is intentionally left for a later PR. - `ResourceArena` is reserved in `PageLayerTree`; binary resource interning is not implemented yet. - This phase establishes the frontend/backend boundary for later CanvasKit and fuller native Skia backends. diff --git a/README_EN.md b/README_EN.md index 00f80be42..53541ed15 100644 --- a/README_EN.md +++ b/README_EN.md @@ -210,9 +210,10 @@ See the [roadmap document](mydocs/eng/report/rhwp-milestone.md) for details. - The default render-diff fixtures cover basic text/table output, business-document layout, and treat-as-char object placement; override with `RHWP_RENDER_DIFF_FILES`, `RHWP_RENDER_DIFF_MAX_PAGES`, or `RHWP_RENDER_DIFF_ALL=1`. - P4 adds native-only `DocumentCore::render_page_png_native(page)` behind `--features native-skia`; it renders `PageLayerTree` to encoded PNG through `SkiaLayerRenderer`. - P5 adds native Skia equation replay from `EquationNode.layout_box`, so equations are no longer placeholder boxes in the PNG path. -- P5 replays the existing equation layout tree directly; it does not add SVG fragment rasterization, CanvasKit equation replay, or native raw-svg/form replay. +- P5 replays the existing equation layout tree directly; it does not add CanvasKit equation replay or native form replay. +- P6 adds native Skia `RawSvg` fragment rasterization through `resvg`, with external file href loading disabled. - CI covers the native Skia path with `cargo test --features native-skia skia --lib`; the feature is not available on `wasm32` targets. -- The initial native Skia path is a PNG raster backend with core image/equation replay; CanvasKit, resource interning/cache, complex text shaping, advanced image parity, and native raw-svg/form replay stay as follow-up work. +- The initial native Skia path is a PNG raster backend with core image/equation/raw-svg replay; CanvasKit, resource interning/cache, complex text shaping, advanced image parity, and native form replay stay as follow-up work. - C ABI export is intentionally left for a later PR. - `ResourceArena` is reserved in `PageLayerTree`; binary resource interning is not implemented yet. - This phase establishes the frontend/backend boundary for later CanvasKit and fuller native Skia backends. diff --git a/src/renderer/skia/image_conv.rs b/src/renderer/skia/image_conv.rs index 2f87dd669..b4dba6bfe 100644 --- a/src/renderer/skia/image_conv.rs +++ b/src/renderer/skia/image_conv.rs @@ -1,3 +1,4 @@ +use resvg::{tiny_skia, usvg}; use skia_safe::{ canvas::SrcRectConstraint, color_filters, image::RequiredProperties, Color, Data, FilterMode, IRect, Image, Matrix, MipmapMode, Paint, Rect, SamplingOptions, TileMode, @@ -6,6 +7,9 @@ use skia_safe::{ use crate::model::image::ImageEffect; use crate::model::style::ImageFillMode; +const MAX_SVG_FRAGMENT_BYTES: usize = 4 * 1024 * 1024; +const MAX_SVG_RASTER_PIXELS: u64 = 67_108_864; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ImageSampling { filter_mode: FilterMode, @@ -25,6 +29,34 @@ impl ImageSampling { } } +pub fn draw_svg_fragment( + canvas: &skia_safe::Canvas, + svg_fragment: &str, + x: f32, + y: f32, + width: f32, + height: f32, + sampling: ImageSampling, +) -> bool { + let Some(png) = rasterize_svg_fragment_to_png(svg_fragment, width, height) else { + return false; + }; + draw_image_bytes( + canvas, + &png, + x, + y, + width, + height, + Some(ImageFillMode::FitToSize), + None, + None, + ImageEffect::RealPic, + sampling, + ); + true +} + pub fn draw_image_bytes( canvas: &skia_safe::Canvas, bytes: &[u8], @@ -286,3 +318,53 @@ pub fn draw_image_bytes( canvas.restore(); } + +fn rasterize_svg_fragment_to_png(svg_fragment: &str, width: f32, height: f32) -> Option> { + if svg_fragment.is_empty() + || svg_fragment.len() > MAX_SVG_FRAGMENT_BYTES + || !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + { + return None; + } + let raster_width = width.ceil() as u64; + let raster_height = height.ceil() as u64; + if raster_width + .checked_mul(raster_height) + .is_none_or(|pixels| pixels > MAX_SVG_RASTER_PIXELS) + { + return None; + } + + let svg = format!( + "{svg_fragment}" + ); + let options = svg_parse_options(); + let tree = usvg::Tree::from_str(&svg, &options).ok()?; + let size = tree.size().to_int_size(); + let pixels = u64::from(size.width()).checked_mul(u64::from(size.height()))?; + if pixels == 0 || pixels > MAX_SVG_RASTER_PIXELS { + return None; + } + + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + pixmap.encode_png().ok() +} + +fn svg_parse_options() -> usvg::Options<'static> { + let mut options = usvg::Options::default(); + options.resources_dir = None; + options.image_href_resolver = usvg::ImageHrefResolver { + resolve_data: usvg::ImageHrefResolver::default_data_resolver(), + resolve_string: Box::new(|_, _| None), + }; + let fontdb = options.fontdb_mut(); + fontdb.load_system_fonts(); + fontdb.set_sans_serif_family("Noto Sans CJK KR"); + fontdb.set_serif_family("Noto Serif CJK KR"); + fontdb.set_monospace_family("D2Coding"); + options +} diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index e31a8d887..87dfc5d7c 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -15,7 +15,7 @@ use crate::renderer::layer_renderer::{ use crate::renderer::{svg_arc_to_beziers, LineStyle, PathCommand, ShapeStyle, StrokeDash}; use super::equation_conv::render_equation; -use super::image_conv::{draw_image_bytes, ImageSampling}; +use super::image_conv::{draw_image_bytes, draw_svg_fragment, ImageSampling}; pub struct SkiaLayerRenderer { font_mgr: FontMgr, @@ -113,9 +113,9 @@ impl SkiaLayerRenderer { "invalid raster max pixel count: 0".to_string(), )); } - let pixel_count = (width as u64).checked_mul(height as u64).ok_or_else(|| { - HwpError::RenderError("raster pixel count overflow".to_string()) - })?; + let pixel_count = (width as u64) + .checked_mul(height as u64) + .ok_or_else(|| HwpError::RenderError("raster pixel count overflow".to_string()))?; if pixel_count > options.max_pixels { return Err(HwpError::RenderError(format!( "raster pixel count out of range: {pixel_count}" @@ -760,7 +760,19 @@ impl SkiaLayerRenderer { PaintOp::Placeholder { bbox, placeholder } => { draw_placeholder(*bbox, placeholder.label.as_str()); } - PaintOp::RawSvg { bbox, .. } => draw_placeholder(*bbox, "svg"), + PaintOp::RawSvg { bbox, raw } => { + if !draw_svg_fragment( + canvas, + raw.svg.as_str(), + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ImageSampling::linear(), + ) { + draw_placeholder(*bbox, "svg"); + } + } } } } @@ -1661,7 +1673,7 @@ mod tests { PaintOp::RawSvg { bbox: BoundingBox::new(16.0, 0.0, 14.0, 14.0), raw: RawSvgNode { - svg: "".to_string(), + svg: " 40); } + #[test] + fn renders_raw_svg_fragment_as_colored_ink() { + let tree = PageLayerTree::new( + 32.0, + 24.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 32.0, 24.0), + None, + vec![PaintOp::RawSvg { + bbox: BoundingBox::new(4.0, 4.0, 18.0, 12.0), + raw: RawSvgNode { + svg: "" + .to_string(), + }, + }], + ), + ); + let output = SkiaLayerRenderer::new() + .render_raster_with_options(&tree, RasterRenderOptions::default()) + .expect("render raw svg"); + let image = decode_rgba(&output.bytes); + let green_ink = image + .pixels() + .filter(|pixel| pixel[0] < 48 && pixel[1] > 180 && pixel[2] < 48 && pixel[3] > 0) + .count(); + + assert!( + green_ink > 100, + "raw SVG fragment should render as green ink" + ); + } + + #[test] + fn raw_svg_replay_does_not_load_external_file_hrefs() { + let external_path = std::env::temp_dir().join(format!( + "rhwp-skia-raw-svg-external-{}.png", + std::process::id() + )); + std::fs::write(&external_path, solid_png([255, 0, 0, 255])).expect("write external png"); + let external_href = external_path.to_string_lossy(); + let tree = PageLayerTree::new( + 32.0, + 24.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 32.0, 24.0), + None, + vec![PaintOp::RawSvg { + bbox: BoundingBox::new(4.0, 4.0, 20.0, 16.0), + raw: RawSvgNode { + svg: format!( + "", + external_href + ), + }, + }], + ), + ); + let output = SkiaLayerRenderer::new() + .render_raster_with_options(&tree, RasterRenderOptions::default()) + .expect("render raw svg with external href"); + let _ = std::fs::remove_file(&external_path); + let image = decode_rgba(&output.bytes); + let red_ink = image + .pixels() + .filter(|pixel| pixel[0] > 180 && pixel[1] < 48 && pixel[2] < 48 && pixel[3] > 0) + .count(); + + assert_eq!(red_ink, 0, "raw SVG replay must not load file hrefs"); + } + #[test] fn group_children_replay_in_order() { let red = LayerNode::leaf(