Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix: align layer tree api with issue 364
  • Loading branch information
seo-rii committed Apr 28, 2026
commit 11f2ac8c26e34e55850dc392f974c1d615b1836d
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,14 @@ rhwp는 Rust + WebAssembly 기반의 오픈소스 HWP/HWPX 뷰어/에디터입

### Multi-Renderer Backends (멀티 렌더러 백엔드)
- `PageRenderTree` can be lowered into a `PageLayerTree` paint IR before backend replay.
- P1 public surfaces are Rust native `DocumentCore::build_page_layer_tree(page)` and WASM `getPageLayerTree(page)`.
- Layer JSON starts at `schemaVersion: 1`, uses `unit: "px"`, and uses `coordinateSystem: "page-top-left"` to match the existing page render coordinates.
- Compatible schema changes should be additive; incompatible JSON shape changes require a schema version bump.
- **Legacy SVG** remains the default compatibility output.
- **Layered SVG** can be exercised with `RHWP_RENDER_PATH=layer-svg`.
- The layered SVG path is a transition adapter that expands `PageLayerTree` back into the existing SVG renderer.
- Browser/native Canvas paths still use the legacy `PageRenderTree` renderer in this phase.
- 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 native Skia backends.

Expand Down
4 changes: 4 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,14 @@ See the [roadmap document](mydocs/eng/report/rhwp-milestone.md) for details.

### Multi-Renderer Backends
- `PageRenderTree` can be lowered into a `PageLayerTree` paint IR before backend replay.
- P1 public surfaces are Rust native `DocumentCore::build_page_layer_tree(page)` and WASM `getPageLayerTree(page)`.
- Layer JSON starts at `schemaVersion: 1`, uses `unit: "px"`, and uses `coordinateSystem: "page-top-left"` to match the existing page render coordinates.
- Compatible schema changes should be additive; incompatible JSON shape changes require a schema version bump.
- **Legacy SVG** remains the default compatibility output.
- **Layered SVG** can be exercised with `RHWP_RENDER_PATH=layer-svg`.
- The layered SVG path is a transition adapter that expands `PageLayerTree` back into the existing SVG renderer.
- Browser/native Canvas paths still use the legacy `PageRenderTree` renderer in this phase.
- 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 native Skia backends.

Expand Down
20 changes: 11 additions & 9 deletions src/document_core/queries/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::renderer::canvas::CanvasRenderer;
use crate::renderer::style_resolver::resolve_styles;
use crate::renderer::composer::{compose_section, compose_paragraph, ComposedParagraph};
use crate::renderer::page_layout::PageLayoutInfo;
use crate::paint::{LayerBuilder, PageLayerTree, RenderProfile};
use crate::document_core::DocumentCore;
use crate::error::HwpError;
use super::super::helpers::color_ref_to_css;
Expand All @@ -29,6 +30,14 @@ impl DocumentCore {
Ok(tree)
}

/// 페이지 레이어 트리를 생성하여 반환한다 (native bridge / backend replay용).
pub fn build_page_layer_tree(&self, page_num: u32) -> Result<PageLayerTree, HwpError> {
let tree = self.build_page_tree(page_num)?;
let _overflows = self.layout_engine.take_overflows();
let mut builder = LayerBuilder::new(RenderProfile::Screen);
Ok(builder.build(&tree))
}

/// 바이너리 데이터를 0-based `bin_data_content` 인덱스로 반환한다.
pub fn get_bin_data(&self, index: usize) -> Option<&[u8]> {
self.document
Expand Down Expand Up @@ -59,10 +68,7 @@ impl DocumentCore {
}

pub fn render_page_svg_layer_native(&self, page_num: u32) -> Result<String, HwpError> {
let tree = self.build_page_tree(page_num)?;
let _overflows = self.layout_engine.take_overflows();
let mut builder = crate::paint::LayerBuilder::new(crate::paint::RenderProfile::Screen);
let layer_tree = builder.build(&tree);
let layer_tree = self.build_page_layer_tree(page_num)?;
let mut renderer = SvgLayerRenderer::new();
renderer.inner_mut().show_paragraph_marks = self.show_paragraph_marks;
renderer.inner_mut().show_control_codes = self.show_control_codes;
Expand Down Expand Up @@ -127,11 +133,7 @@ impl DocumentCore {
}

pub fn get_page_layer_tree_native(&self, page_num: u32) -> Result<String, HwpError> {
let tree = self.build_page_tree(page_num)?;
let _overflows = self.layout_engine.take_overflows();
let mut builder = crate::paint::LayerBuilder::new(crate::paint::RenderProfile::Screen);
let layer_tree = builder.build(&tree);
Ok(layer_tree.to_json())
Ok(self.build_page_layer_tree(page_num)?.to_json())
}

/// 페이지 정보 (네이티브 에러 타입)
Expand Down
239 changes: 238 additions & 1 deletion src/paint/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ mod tests {
use crate::renderer::render_tree::{
BoundingBox, EllipseNode, EquationNode, FootnoteMarkerNode, FormObjectNode, ImageNode,
LineNode, PageBackgroundNode, PageNode, PathNode, PlaceholderNode, RawSvgNode,
RectangleNode, RenderNode, RenderNodeType, TableCellNode, TextRunNode,
RectangleNode, RenderNode, RenderNodeType, TableCellNode, TableNode, TextLineNode,
TextRunNode,
};
use crate::renderer::{LineStyle, PathCommand, ShapeStyle, TextStyle};

Expand Down Expand Up @@ -432,6 +433,242 @@ mod tests {
}
}

#[test]
fn preserves_structural_groups_and_clips_for_backend_replay() {
let mut tree = PageRenderTree::new(0, 800.0, 600.0);
tree.root.node_type = RenderNodeType::Page(PageNode {
page_index: 0,
width: 800.0,
height: 600.0,
section_index: 0,
});

let mut header = RenderNode::new(
1,
RenderNodeType::Header,
BoundingBox::new(0.0, 0.0, 800.0, 80.0),
);
let mut header_line = RenderNode::new(
2,
RenderNodeType::TextLine(TextLineNode::with_para_vpos(20.0, 15.0, 0, 0, 0, 120)),
BoundingBox::new(40.0, 20.0, 200.0, 20.0),
);
header_line.children.push(RenderNode::new(
3,
RenderNodeType::TextRun(text_run("머리말")),
BoundingBox::new(40.0, 20.0, 60.0, 20.0),
));
header.children.push(header_line);

let footer = RenderNode::new(
4,
RenderNodeType::Footer,
BoundingBox::new(0.0, 540.0, 800.0, 60.0),
);

let body_clip = BoundingBox::new(40.0, 90.0, 720.0, 420.0);
let mut body = RenderNode::new(
5,
RenderNodeType::Body {
clip_rect: Some(body_clip),
},
body_clip,
);
let mut column = RenderNode::new(
6,
RenderNodeType::Column(1),
BoundingBox::new(40.0, 90.0, 340.0, 420.0),
);
let mut table = RenderNode::new(
7,
RenderNodeType::Table(TableNode {
row_count: 1,
col_count: 1,
border_fill_id: 2,
section_index: Some(0),
para_index: Some(2),
control_index: Some(0),
}),
BoundingBox::new(60.0, 110.0, 180.0, 80.0),
);
let mut clipped_cell = RenderNode::new(
8,
RenderNodeType::TableCell(TableCellNode {
col: 0,
row: 0,
col_span: 1,
row_span: 1,
border_fill_id: 3,
text_direction: 0,
clip: true,
model_cell_index: Some(4),
}),
BoundingBox::new(60.0, 110.0, 180.0, 80.0),
);
let mut nested_table = RenderNode::new(
9,
RenderNodeType::Table(TableNode {
row_count: 1,
col_count: 1,
border_fill_id: 5,
section_index: Some(0),
para_index: Some(2),
control_index: Some(1),
}),
BoundingBox::new(70.0, 120.0, 80.0, 40.0),
);
nested_table.children.push(RenderNode::new(
10,
RenderNodeType::TableCell(TableCellNode {
col: 0,
row: 0,
col_span: 1,
row_span: 1,
border_fill_id: 6,
text_direction: 0,
clip: false,
model_cell_index: None,
}),
BoundingBox::new(70.0, 120.0, 80.0, 40.0),
));
clipped_cell.children.push(nested_table);
table.children.push(clipped_cell);
column.children.push(table);
body.children.push(column);

tree.root.children.push(header);
tree.root.children.push(footer);
tree.root.children.push(body);

let mut builder = LayerBuilder::new(RenderProfile::Screen);
let layer_tree = builder.build(&tree);

let LayerNodeKind::Group { children, .. } = &layer_tree.root.kind else {
panic!("expected root group");
};
assert_eq!(children.len(), 3);

let LayerNodeKind::Group {
group_kind,
cache_hint,
children: header_children,
} = &children[0].kind
else {
panic!("expected header group");
};
assert!(matches!(group_kind, GroupKind::Header));
assert_eq!(*cache_hint, CacheHint::StaticSubtree);
let LayerNodeKind::Group {
group_kind,
children: line_children,
..
} = &header_children[0].kind
else {
panic!("expected text line group");
};
match group_kind {
GroupKind::TextLine(line) => {
assert_eq!(line.para_index, Some(0));
assert_eq!(line.vpos, Some(120));
}
other => panic!("expected text line group kind, got {other:?}"),
}
assert!(matches!(&line_children[0].kind, LayerNodeKind::Leaf { .. }));

let LayerNodeKind::Group {
group_kind,
cache_hint,
..
} = &children[1].kind
else {
panic!("expected footer group");
};
assert!(matches!(group_kind, GroupKind::Footer));
assert_eq!(*cache_hint, CacheHint::StaticSubtree);

let LayerNodeKind::ClipRect {
clip,
clip_kind,
child,
} = &children[2].kind
else {
panic!("expected body clip");
};
assert_eq!(clip.x, body_clip.x);
assert_eq!(clip.y, body_clip.y);
assert_eq!(clip.width, body_clip.width);
assert_eq!(clip.height, body_clip.height);
assert_eq!(*clip_kind, ClipKind::Body);

let LayerNodeKind::Group {
group_kind,
children: body_children,
..
} = &child.kind
else {
panic!("expected clipped body group");
};
assert!(matches!(group_kind, GroupKind::Body));

let LayerNodeKind::Group {
group_kind,
children: column_children,
..
} = &body_children[0].kind
else {
panic!("expected column group");
};
assert!(matches!(group_kind, GroupKind::Column(1)));

let LayerNodeKind::Group {
group_kind,
children: table_children,
..
} = &column_children[0].kind
else {
panic!("expected table group");
};
match group_kind {
GroupKind::Table(table) => {
assert_eq!(table.row_count, 1);
assert_eq!(table.col_count, 1);
assert_eq!(table.control_index, Some(0));
}
other => panic!("expected table group kind, got {other:?}"),
}

let LayerNodeKind::ClipRect {
clip_kind, child, ..
} = &table_children[0].kind
else {
panic!("expected table cell clip");
};
assert_eq!(*clip_kind, ClipKind::TableCell);

let LayerNodeKind::Group {
group_kind,
children: cell_children,
..
} = &child.kind
else {
panic!("expected clipped table cell group");
};
match group_kind {
GroupKind::TableCell(cell) => {
assert!(cell.clip);
assert_eq!(cell.model_cell_index, Some(4));
}
other => panic!("expected table cell group kind, got {other:?}"),
}
assert!(matches!(
&cell_children[0].kind,
LayerNodeKind::Group {
group_kind: GroupKind::Table(_),
..
}
));
}

fn text_run(text: &str) -> TextRunNode {
TextRunNode {
text: text.to_string(),
Expand Down
Loading