Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions src/renderer/skia/image_conv.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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],
Expand Down Expand Up @@ -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<Vec<u8>> {
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 xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.2}\" height=\"{height:.2}\" viewBox=\"0 0 {width:.2} {height:.2}\">{svg_fragment}</svg>"
);
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
}
94 changes: 88 additions & 6 deletions src/renderer/skia/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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");
}
}
}
}
}
Expand Down Expand Up @@ -1661,7 +1673,7 @@ mod tests {
PaintOp::RawSvg {
bbox: BoundingBox::new(16.0, 0.0, 14.0, 14.0),
raw: RawSvgNode {
svg: "<rect/>".to_string(),
svg: "<invalid".to_string(),
},
},
PaintOp::FormObject {
Expand All @@ -1679,6 +1691,76 @@ mod tests {
assert!(count_ink(&image) > 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: "<rect x=\"0\" y=\"0\" width=\"18\" height=\"12\" fill=\"#00ff00\"/>"
.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!(
"<image href=\"{}\" x=\"0\" y=\"0\" width=\"20\" height=\"16\"/>",
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(
Expand Down
Loading