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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added pdf/issue1937/top_caption_rowbreak_p20-2024.pdf
Binary file not shown.
1 change: 1 addition & 0 deletions rhwp-studio/e2e/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ e2e 스크립트의 **단일 권위 목록**이다. 파일 추가/변경/폐기
| `issue-4030-footnote-goto-transition.test.mjs` | 상시 | active | #4030 실제 대형 HWP 각주에서 Option+G 200쪽 이동 시 본문 모드·상태 표시·viewport 전환 | 정책연구용역사업 중간진도보고서(살아있는 간장 기증자의 의학적 선별기준 연구).hwp | 수동 | |
| `issue-4158-char-overlap-boxed-pua-canvas2d.test.mjs` | 상시 | active | #4158 실제 CharOverlap의 사각 안 숫자 PUA를 Canvas2D가 숫자와 사각형으로 합성 | basic/issue2007_nested_cell_pagination_42065.hwp | npm e2e:issue-4158 | 17쪽·물리 10쪽 raw IR 보존 계약 포함 |
| `issue-4159-terminal-nested-bottom-border-canvas2d.test.mjs` | 상시 | active | #4159 실제 HWP 물리 3쪽 종료 재귀 중첩 표 bottom 선의 Canvas2D clip 포섭 | basic/issue2007_nested_cell_pagination_42065.hwp | npm e2e:issue-4159 | #4069 17쪽 pagination 계약 포함 |
| `issue-8125-hairline-canvas2d.test.mjs` | 상시 | active | #8125 장치 한 칸보다 가는 표 괘선을 Canvas2D 가 한 칸 굵기로 그림(Native `subpixel_stroke_hairline` 과 같은 판정) | basic/calendar_year.hwp | npm e2e:issue-8125-canvas2d | run-with-vite.mjs 경유, 수정 전 182곳 미달 |
| `issue-4252-nested-partial-table-object-selection.test.mjs` | 상시 | active | #4252 재귀 분할 중첩 표의 실제 IR cellPath·bbox 조회·Esc 객체 선택과 브라우저 성능 증적 | basic/issue2007_nested_cell_pagination_42065.hwp | npm e2e:issue-4252 | 물리 5쪽 외부→래퍼→자식 표 경로와 `output/4252/perf-*.json` |
| `issue-4694-chart-data-edit.test.mjs` | 상시 | active | #4694 차트 데이터 편집 — 메뉴 노출·더블클릭 진입·값 수정 반영·Ctrl+Z 원복·무편집 무흔적 | chart/세로막대형/묶은세로막대형.hwp | 수동 | |
| `issue-4969-shaping-replay.test.mjs` | 상시 | active | #4969 common GlyphRun의 strict CanvasKit 선택·Source Han exact face·affine advance와 √ratio ink 계약 | RHWPOldHangul-Regular.otf | npm e2e:issue-4969 | software CanvasKit pixel 대조 |
Expand Down
105 changes: 105 additions & 0 deletions rhwp-studio/e2e/issue-8125-hairline-canvas2d.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node

// #8125 WASM Canvas2D 의 장치 한 칸보다 가는 획은 한 칸 굵기로 그린다.
//
// 한컴 Print PDF 를 raster 로 읽으면 0.1 mm(약 0.38 px) 표 괘선도 장치 한 칸을 채운다.
// Canvas2D 가 선언 굵기(하한 0.5 px)대로 anti-alias 하면 괘선의 덮임이 절반 안팎에 그쳐 옅어진다.
// Native 검사(tests/cases/native_fractional_glyph_origin.rs `subpixel_stroke_hairline`)와 같은
// 입력·같은 판정이다: render tree 의 실제 괘선 위치에서 그 괘선을 가로지르는 화소 중 가장 진한
// 화소가 선 색 대비의 절반 이상인지 본다. 조판 좌표·선언 굵기는 그대로다.

import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { assert, runTest, setTestCase, waitForCanvas } from './helpers.mjs';

const E2E_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(E2E_DIR, '..', '..');
const FIXTURE = path.join(REPO_ROOT, 'samples', 'basic', 'calendar_year.hwp');
const OUTPUT_DIR = path.join(REPO_ROOT, 'output', '8125');
const OUTPUT_PAGE = path.join(OUTPUT_DIR, 'calendar_year_p001_canvas2d.png');

runTest('#8125 장치 한 칸보다 가는 괘선 Canvas2D', async ({ page }) => {
setTestCase('calendar_year 1쪽 가는 가로 괘선의 장치 한 칸 덮임');
const input = await page.$('#file-input');
if (!input) throw new Error('file-input not found');
await input.uploadFile(FIXTURE);
// 빈 새 문서도 한 쪽이라, 표본의 괘선이 layer tree 에 나타날 때까지 기다린다.
await page.waitForFunction(
() => window.__wasm?.getSourceFormat?.() === 'hwp' && window.__wasm.pageCount === 1
&& window.__wasm.doc?.getPageLayerTree(0).includes('"type":"line"'),
{ timeout: 30000 },
);
await waitForCanvas(page, 30000);

const result = await page.evaluate(() => {
const doc = window.__wasm?.doc;
if (!doc || typeof doc.renderPageToCanvas !== 'function') {
throw new Error('WASM Canvas2D renderer not found');
}

const tree = JSON.parse(doc.getPageLayerTree(0));
const lines = [];
const visit = (value) => {
if (!value || typeof value !== 'object') return;
if (value.type === 'line') lines.push(value);
for (const child of Object.values(value)) visit(child);
};
visit(tree);
const thin = lines.filter((line) => Math.abs(line.y1 - line.y2) < 0.01
&& Math.abs(line.x2 - line.x1) > 20
&& line.style.width > 0
&& line.style.width < 1);

const canvas = document.createElement('canvas');
doc.renderPageToCanvas(0, canvas, 1);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 투명 화소는 흰 종이 위에 합성한 회색으로 읽는다.
const ink = (x, y) => {
const at = (y * canvas.width + x) * 4;
const gray = (pixels[at] + pixels[at + 1] + pixels[at + 2]) / 3;
return (255 - gray) * (pixels[at + 3] / 255);
};

const faint = [];
let checked = 0;
for (const line of thin) {
const hex = /^#([0-9a-f]{6})$/i.exec(line.style.color);
if (!hex) continue;
const value = parseInt(hex[1], 16);
const lineGray = (((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff)) / 3;
const contrast = 255 - lineGray;
if (contrast < 64) continue;
checked += 1;
// 괘선 양 끝(교차 괘선)을 피해 가운데 세 지점을 본다.
for (const t of [0.3, 0.5, 0.7]) {
const x = Math.floor(line.x1 + (line.x2 - line.x1) * t);
let darkest = 0;
for (let y = Math.floor(line.y1 - 2); y <= Math.floor(line.y1 + 2); y += 1) {
if (y >= 0 && y < canvas.height) darkest = Math.max(darkest, ink(x, y));
}
if (darkest < contrast * 0.5) {
faint.push([+line.y1.toFixed(1), line.style.width, +(darkest / contrast).toFixed(2)]);
}
}
}

return {
pageDataUrl: canvas.toDataURL('image/png'),
thinCount: thin.length,
checked,
faint,
};
});

mkdirSync(OUTPUT_DIR, { recursive: true });
writeFileSync(OUTPUT_PAGE, Buffer.from(result.pageDataUrl.split(',')[1], 'base64'));

assert(result.checked >= 10, `가는 가로 괘선 ${result.checked}/${result.thinCount}개`);
assert(
result.faint.length === 0,
`장치 한 칸을 덮지 못한 괘선 ${result.faint.length}곳 (y, 굵기, 최대 덮임): ${JSON.stringify(result.faint.slice(0, 6))}`,
);
console.log(` Evidence: ${OUTPUT_PAGE}`);
});
1 change: 1 addition & 0 deletions rhwp-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"e2e:issue-4121": "node e2e/run-with-vite.mjs -- node e2e/header-footer-selection-issue4121.test.mjs --mode=headless",
"e2e:issue-2809": "node e2e/issue-2809-split-alignment.test.mjs --mode=headless",
"e2e:issue-4159": "node e2e/issue-4159-terminal-nested-bottom-border-canvas2d.test.mjs --mode=headless",
"e2e:issue-8125-canvas2d": "node e2e/run-with-vite.mjs -- node e2e/issue-8125-hairline-canvas2d.test.mjs --mode=headless",
"e2e:issue-4252": "node e2e/issue-4252-nested-partial-table-object-selection.test.mjs --mode=headless",
"e2e:issue-4272": "node e2e/issue-4272-nested-cell-text-selection.test.mjs --mode=headless",
"e2e:issue-4272-page11": "node e2e/issue-4272-page11-nested-cell-copy.test.mjs --mode=headless",
Expand Down
23 changes: 19 additions & 4 deletions rhwp-studio/src/view/canvaskit-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,8 @@ export class CanvasKitLayerRenderer {
private currentShowParagraphMarks = false;
private currentShowControlCodes = false;
private currentClipEnabled = true;
// Page-space length of one device pixel at the current scale; the floor for shape/line strokes (#8125).
private currentDeviceStrokeFloor = 0;
private currentReplayFeatureCounts: CanvasKitReplayFeatureCounts = {
dashedStrokes: 0,
glyphRuns: 0,
Expand Down Expand Up @@ -1013,8 +1015,10 @@ export class CanvasKitLayerRenderer {
this.selectedTextVariantOps = new WeakSet<LayerPaintOp>();
this.selectTextVariants(tree.root, canvas);
this.currentTreeFingerprint = replayFingerprint(tree);
this.currentDeviceStrokeFloor = Number.isFinite(scale) && scale > 0 ? 1 / scale : 0;
// The stroke floor follows the scale, so static pictures are recorded per scale.
this.currentPictureContext = this.currentTreeFingerprint
? `${this.currentTreeFingerprint}:${this.documentGeneration}:${this.localFontRevision}:${this.imageResourceRevision}:${JSON.stringify(this.glyphRunFonts.diagnostics())}`
? `${this.currentTreeFingerprint}:${scale}:${this.documentGeneration}:${this.localFontRevision}:${this.imageResourceRevision}:${JSON.stringify(this.glyphRunFonts.diagnostics())}`
: null;
if (this.currentTreeFingerprint) {
const fingerprints = this.treeFingerprints.get(inputTree as PageLayerTree) ?? new Set<string>();
Expand Down Expand Up @@ -1071,6 +1075,7 @@ export class CanvasKitLayerRenderer {
} finally {
this.currentTreeFingerprint = null;
this.currentPictureContext = null;
this.currentDeviceStrokeFloor = 0;
this.currentPageExtent = null;
this.currentOp = null;
this.currentProfile = 'screen';
Expand Down Expand Up @@ -5039,7 +5044,7 @@ export class CanvasKitLayerRenderer {
draw(shadowPaint);
shadowPaint.delete?.();
if (style?.strokeColor && (style.strokeWidth ?? 0) > 0) {
const shadowStroke = this.makeStrokePaint(shadow.color, style.strokeWidth ?? 1, shadow.opacity);
const shadowStroke = this.makeStrokePaint(shadow.color, this.deviceStrokeWidth(style.strokeWidth ?? 1), shadow.opacity);
try {
this.drawStrokeWithDash(style.strokeDash, shadowStroke, () => draw(shadowStroke));
} finally {
Expand All @@ -5066,7 +5071,7 @@ export class CanvasKitLayerRenderer {
paint.delete?.();
}
if (style?.strokeColor && (style.strokeWidth ?? 0) > 0) {
const paint = this.makeStrokePaint(style.strokeColor, style.strokeWidth ?? 1, style.opacity);
const paint = this.makeStrokePaint(style.strokeColor, this.deviceStrokeWidth(style.strokeWidth ?? 1), style.opacity);
try {
this.drawStrokeWithDash(style.strokeDash, paint, () => draw(paint));
} finally {
Expand Down Expand Up @@ -5244,7 +5249,7 @@ export class CanvasKitLayerRenderer {
const nx = lineLen > 0 ? -dy / lineLen : 0;
const ny = lineLen > 0 ? dx / lineLen : 1;
for (const [widthRatio, offsetRatio] of this.compoundLineSegments(style.lineType)) {
const paint = this.makeStrokePaint(color, Math.max(0.3, width * widthRatio), opacity);
const paint = this.makeStrokePaint(color, this.deviceStrokeWidth(Math.max(0.3, width * widthRatio)), opacity);
const ox = nx * width * offsetRatio;
const oy = ny * width * offsetRatio;
try {
Expand Down Expand Up @@ -5592,6 +5597,16 @@ export class CanvasKitLayerRenderer {
return paint;
}

/**
* Shape and line strokes thinner than one device pixel are drawn one pixel wide (#8125).
* A Hancom print raster fills a whole device pixel even for 0.1 mm table rules; anti-aliasing
* the declared width leaves them faint. Same rule as Canvas2D `floor_line_width_to_device`
* and Native Skia.
*/
private deviceStrokeWidth(width: number): number {
return Math.max(width, this.currentDeviceStrokeFloor);
}

private makeStrokePaint(color: string, width: number, opacity = 1): SkPaint {
const paint = new this.canvasKit.Paint();
paint.setAntiAlias?.(true);
Expand Down
Binary file added samples/issue1937/top_caption_rowbreak_p20.hwp
Binary file not shown.
29 changes: 28 additions & 1 deletion src/renderer/skia/line_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,33 @@ use crate::renderer::render_tree::LineNode;
use crate::renderer::LineRenderType;
use skia_safe::{Canvas, Paint};

pub(super) fn draw_line(canvas: &Canvas, line: &LineNode, paint: &Paint, antialias: bool) {
/// 현재 canvas 변환에서 장치 한 칸에 해당하는 local 길이. 벡터 출력이거나 변환이 퇴화하면 `None`.
fn raster_device_unit(canvas: &Canvas, raster_target: bool) -> Option<f32> {
if !raster_target {
return None;
}
let inverse = canvas.local_to_device_as_3x3().invert()?;
let x_unit = inverse.map_vector((1.0, 0.0)).length();
let y_unit = inverse.map_vector((0.0, 1.0)).length();
let device_unit = x_unit.min(y_unit);
(device_unit.is_finite() && device_unit > 0.0).then_some(device_unit)
}

/// raster 출력의 획은 장치 한 칸보다 가늘게 그리지 않는다(PDF raster 의 얇은 선 규칙).
/// 조판 좌표·선언 굵기·점선 간격은 바꾸지 않고 현재 장치 변환만 사용한다.
pub(super) fn floor_stroke_to_device(canvas: &Canvas, paint: &mut Paint, raster_target: bool) {
if let Some(device_unit) = raster_device_unit(canvas, raster_target) {
paint.set_stroke_width(paint.stroke_width().max(device_unit));
}
}

pub(super) fn draw_line(
canvas: &Canvas,
line: &LineNode,
paint: &Paint,
antialias: bool,
raster_target: bool,
) {
let (x1, y1, x2, y2) = (line.x1, line.y1, line.x2, line.y2);
let width = line.style.width.max(0.5);
let segments: &[(f64, f64)] = match line.style.line_type {
Expand All @@ -26,6 +52,7 @@ pub(super) fn draw_line(canvas: &Canvas, line: &LineNode, paint: &Paint, antiali
let mut segment = paint.clone();
segment.set_anti_alias(antialias);
segment.set_stroke_width((width * width_ratio).max(0.3) as f32);
floor_stroke_to_device(canvas, &mut segment, raster_target);
let offset = width * offset_ratio;
let (ox, oy) = (-dy / length * offset, dx / length * offset);
canvas.draw_line(
Expand Down
14 changes: 12 additions & 2 deletions src/renderer/skia/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ use super::glyph_replay::{
};
use super::image_cache::ImageResourceCache;
use super::image_conv::{draw_image_bytes, draw_svg_fragment};
use super::line_replay::draw_line;
use super::line_replay::{draw_line, floor_stroke_to_device};
use super::text_replay::SkiaTextReplay;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
Expand Down Expand Up @@ -1062,6 +1062,7 @@ impl SkiaLayerRenderer {
let active_layer = node.layer.or(inherited_layer);
let clip_enabled = output_options.clip_enabled;
let antialias = cache_context.policy.antialias();
let raster_target = cache_context.raster_target;
let apply_dash = |paint: &mut Paint, dash: StrokeDash| {
let mut intervals = dash.intervals(f64::from(paint.stroke_width()));
// 기존 범용 Skia 패턴은 종전 선 굵기 배율을 유지한다.
Expand Down Expand Up @@ -1094,6 +1095,7 @@ impl SkiaLayerRenderer {
});
paint.set_color(colorref_to_skia(style.stroke_color?, style.opacity as f32));
apply_dash(&mut paint, style.stroke_dash);
floor_stroke_to_device(canvas, &mut paint, raster_target);
Some(paint)
};
let make_line_paint = |style: &LineStyle| {
Expand All @@ -1111,6 +1113,7 @@ impl SkiaLayerRenderer {
let intervals = intervals.iter().map(|v| *v as f32).collect::<Vec<_>>();
paint.set_path_effect(PathEffect::dash(&intervals, 0.0));
}
floor_stroke_to_device(canvas, &mut paint, raster_target);
paint
};
let draw_placeholder = |bbox: crate::renderer::render_tree::BoundingBox, label: &str| {
Expand Down Expand Up @@ -1668,6 +1671,7 @@ impl SkiaLayerRenderer {
} else {
1.0
});
floor_stroke_to_device(canvas, &mut paint, raster_target);
paint.set_color(colorref_to_skia(color, 1.0));
canvas.draw_rect(rect, &paint);
}
Expand Down Expand Up @@ -1801,7 +1805,13 @@ impl SkiaLayerRenderer {
if line.transform.has_transform() {
open_shape_transform(line.transform, bbox);
}
draw_line(canvas, line, &make_line_paint(&line.style), antialias);
draw_line(
canvas,
line,
&make_line_paint(&line.style),
antialias,
raster_target,
);
draw_arrow_heads(
canvas,
&crate::renderer::arrow::line_heads(
Expand Down
10 changes: 2 additions & 8 deletions src/renderer/skia/synthetic_typeface.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::line_replay::floor_stroke_to_device;
use skia_safe::{
font_arguments::VariationPosition, Canvas, Data, Font, FontArguments, FontMgr, Paint, Point,
Typeface,
Expand Down Expand Up @@ -42,14 +43,7 @@ pub(super) fn draw_synthetic_bold_paths(
stroke.set_style(skia_safe::paint::Style::Stroke);
// PDF의 얇은 획은 raster 장치의 한 단위보다 작게 소비하지 않는다.
// 조판 좌표나 원래 em 획은 바꾸지 않고 현재 장치 변환만 사용한다.
if let Some(inverse) = canvas.local_to_device_as_3x3().invert() {
let x_unit = inverse.map_vector((1.0, 0.0)).length();
let y_unit = inverse.map_vector((0.0, 1.0)).length();
let device_unit = x_unit.min(y_unit);
if device_unit.is_finite() && device_unit > 0.0 {
stroke.set_stroke_width(stroke.stroke_width().max(device_unit));
}
}
floor_stroke_to_device(canvas, &mut stroke, true);
(fill, stroke)
});
for (path, position) in paths.iter().zip(positions) {
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/typeset/table/continuation/fragment/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,8 +665,11 @@ impl TypesetEngine {
{
// 유효성이 확인된 첫 개체 상자는 원점과 사용하지 않은 띠를 함께 소유한다.
// 표 앞에 제목 줄이 있는 경우에도 같은 소유 관계를 적용한다.
let top =
st.current_height + caption_extra + host_before_overhead + vert_offset_overhead;
// [#7732] 원점은 위 캡션을 포함한 개체 상자의 위끝이다. 예산(`budget.rs` 프레임 =
// 하단 − 원점 − 캡션), 확정(`commit_fragment` 의 캡션 + 조각 높이)과 그리기
// (`table_partial.rs` 표 = 원점 + 캡션 + 간격)가 모두 캡션을 원점 뒤에 한 번 더한다.
// 여기서 캡션을 원점에 넣으면 위 캡션 표가 캡션 높이만큼 내려 그려진다(issue1937 20쪽).
let top = st.current_height + host_before_overhead + vert_offset_overhead;
fragment_placement = Some(crate::renderer::float_placement::ParagraphFloatPlacement {
flow: crate::renderer::float_placement::ParagraphFloatFlow::NextLine,
anchor_y: st.current_height,
Expand Down
Loading
Loading