diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_overlay.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_overlay.png new file mode 100644 index 0000000000..52583a7e90 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_overlay.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_review.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_review.png new file mode 100644 index 0000000000..188bb7ab8f Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_base_review.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_overlay.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_overlay.png new file mode 100644 index 0000000000..fb11a6d73f Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_overlay.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_review.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_review.png new file mode 100644 index 0000000000..0804751a11 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_native_head_review.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_overlay.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_overlay.png new file mode 100644 index 0000000000..3f5b86c9d8 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_overlay.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_review.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_review.png new file mode 100644 index 0000000000..425daf45e9 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_base_review.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_overlay.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_overlay.png new file mode 100644 index 0000000000..a41d662f82 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_overlay.png differ diff --git a/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_review.png b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_review.png new file mode 100644 index 0000000000..96c92c8b30 Binary files /dev/null and b/mydocs/report/issue7732_top_caption_origin/p20_p003_wasm_head_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_overlay.png new file mode 100644 index 0000000000..bb100b21f2 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_review.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_review.png new file mode 100644 index 0000000000..1360fb259a Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_base_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_overlay.png new file mode 100644 index 0000000000..d77b411b64 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_review.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_review.png new file mode 100644 index 0000000000..8a34522875 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_head_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_overlay.png new file mode 100644 index 0000000000..3d5579c868 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_review.png b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_review.png new file mode 100644 index 0000000000..8a8b11d9ab Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/calendar_year_p1_wasm_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_overlay.png new file mode 100644 index 0000000000..66fe282ed2 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_review.png b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_review.png new file mode 100644 index 0000000000..1eb6265cd7 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_base_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_overlay.png new file mode 100644 index 0000000000..efe002df7d Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_review.png b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_review.png new file mode 100644 index 0000000000..1fb30884b7 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/issue1937_p49_head_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_overlay.png new file mode 100644 index 0000000000..77b08cfe0a Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_review.png b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_review.png new file mode 100644 index 0000000000..bcbc1f6683 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_base_review.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_overlay.png b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_overlay.png new file mode 100644 index 0000000000..8c8ac7d350 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_overlay.png differ diff --git a/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_review.png b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_review.png new file mode 100644 index 0000000000..78dc528c62 Binary files /dev/null and b/mydocs/report/skia_subpixel_stroke_hairline/sungeo_p34_head_review.png differ diff --git a/pdf/issue1937/top_caption_rowbreak_p20-2024.pdf b/pdf/issue1937/top_caption_rowbreak_p20-2024.pdf new file mode 100644 index 0000000000..2c6ffcb103 Binary files /dev/null and b/pdf/issue1937/top_caption_rowbreak_p20-2024.pdf differ diff --git a/rhwp-studio/e2e/MANIFEST.md b/rhwp-studio/e2e/MANIFEST.md index 9fb15471ba..bc9c3526b3 100644 --- a/rhwp-studio/e2e/MANIFEST.md +++ b/rhwp-studio/e2e/MANIFEST.md @@ -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 대조 | diff --git a/rhwp-studio/e2e/issue-8125-hairline-canvas2d.test.mjs b/rhwp-studio/e2e/issue-8125-hairline-canvas2d.test.mjs new file mode 100644 index 0000000000..6a41a67ffb --- /dev/null +++ b/rhwp-studio/e2e/issue-8125-hairline-canvas2d.test.mjs @@ -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}`); +}); diff --git a/rhwp-studio/package.json b/rhwp-studio/package.json index e532fbc27b..af733b4573 100644 --- a/rhwp-studio/package.json +++ b/rhwp-studio/package.json @@ -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", diff --git a/rhwp-studio/src/view/canvaskit-renderer.ts b/rhwp-studio/src/view/canvaskit-renderer.ts index 7a8c2e14e1..7a3596e933 100644 --- a/rhwp-studio/src/view/canvaskit-renderer.ts +++ b/rhwp-studio/src/view/canvaskit-renderer.ts @@ -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, @@ -1013,8 +1015,10 @@ export class CanvasKitLayerRenderer { this.selectedTextVariantOps = new WeakSet(); 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(); @@ -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'; @@ -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 { @@ -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 { @@ -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 { @@ -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); diff --git a/samples/issue1937/top_caption_rowbreak_p20.hwp b/samples/issue1937/top_caption_rowbreak_p20.hwp new file mode 100644 index 0000000000..8c497ad123 Binary files /dev/null and b/samples/issue1937/top_caption_rowbreak_p20.hwp differ diff --git a/src/renderer/skia/line_replay.rs b/src/renderer/skia/line_replay.rs index 82dc9c855b..a1a50e6c55 100644 --- a/src/renderer/skia/line_replay.rs +++ b/src/renderer/skia/line_replay.rs @@ -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 { + 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 { @@ -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( diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index b31f335246..8c15c61222 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -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)] @@ -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 패턴은 종전 선 굵기 배율을 유지한다. @@ -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| { @@ -1111,6 +1113,7 @@ impl SkiaLayerRenderer { let intervals = intervals.iter().map(|v| *v as f32).collect::>(); 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| { @@ -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); } @@ -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( diff --git a/src/renderer/skia/synthetic_typeface.rs b/src/renderer/skia/synthetic_typeface.rs index 4ecdf1a554..844f152fdc 100644 --- a/src/renderer/skia/synthetic_typeface.rs +++ b/src/renderer/skia/synthetic_typeface.rs @@ -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, @@ -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) { diff --git a/src/renderer/typeset/table/continuation/fragment/emit.rs b/src/renderer/typeset/table/continuation/fragment/emit.rs index 5ce4eeeb08..577ca08349 100644 --- a/src/renderer/typeset/table/continuation/fragment/emit.rs +++ b/src/renderer/typeset/table/continuation/fragment/emit.rs @@ -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, diff --git a/src/renderer/web_canvas.rs b/src/renderer/web_canvas.rs index e46af679c3..97a6700edb 100644 --- a/src/renderer/web_canvas.rs +++ b/src/renderer/web_canvas.rs @@ -1597,6 +1597,25 @@ impl WebCanvasRenderer { node.bbox.x < body_left || node.bbox.x + node.bbox.width > body_right } + /// raster canvas 의 획은 장치 한 칸보다 가늘게 그리지 않는다(Native `floor_stroke_to_device` 와 + /// 같은 PDF raster 의 얇은 선 규칙). 현재 변환의 역으로 장치 한 칸의 local 길이를 구하며, + /// 조판 좌표·선언 굵기는 바꾸지 않는다. 점선 간격은 하한 전 굵기로 정한 뒤 호출한다. + fn floor_line_width_to_device(&self) { + let Ok(matrix) = self.ctx.get_transform() else { + return; + }; + let det = (matrix.a() * matrix.d() - matrix.b() * matrix.c()).abs(); + if !det.is_finite() || det == 0.0 { + return; + } + let x_unit = matrix.d().hypot(matrix.b()) / det; + let y_unit = matrix.c().hypot(matrix.a()) / det; + let device_unit = x_unit.min(y_unit); + if device_unit.is_finite() && device_unit > 0.0 && self.ctx.line_width() < device_unit { + self.ctx.set_line_width(device_unit); + } + } + /// 선 대시 패턴 설정 fn set_line_dash(&self, dash: &StrokeDash) { let pattern = js_sys::Array::new(); @@ -1871,6 +1890,7 @@ impl WebCanvasRenderer { self.ctx .set_line_width(style.stroke_dash.canvas_width(style.stroke_width)); self.set_line_dash(&style.stroke_dash); + self.floor_line_width_to_device(); self.ctx.stroke(); let _ = self.ctx.set_line_dash(&js_sys::Array::new()); self.ctx.set_line_cap("butt"); @@ -1898,6 +1918,7 @@ impl WebCanvasRenderer { self.ctx .set_line_width(style.stroke_dash.canvas_width(style.stroke_width)); self.set_line_dash(&style.stroke_dash); + self.floor_line_width_to_device(); self.ctx.stroke_rect(x, y, w, h); let _ = self.ctx.set_line_dash(&js_sys::Array::new()); self.ctx.set_line_cap("butt"); @@ -1951,6 +1972,7 @@ impl WebCanvasRenderer { self.ctx .set_line_width(style.stroke_dash.canvas_width(style.stroke_width)); self.set_line_dash(&style.stroke_dash); + self.floor_line_width_to_device(); self.ctx.stroke(); let _ = self.ctx.set_line_dash(&js_sys::Array::new()); self.ctx.set_line_cap("butt"); @@ -2074,6 +2096,7 @@ impl WebCanvasRenderer { self.ctx .set_line_width(style.stroke_dash.canvas_width(style.stroke_width)); self.set_line_dash(&style.stroke_dash); + self.floor_line_width_to_device(); self.ctx.stroke(); let _ = self.ctx.set_line_dash(&js_sys::Array::new()); self.ctx.set_line_cap("butt"); @@ -2742,6 +2765,7 @@ impl Renderer for WebCanvasRenderer { let ox = nx * off; let oy = ny * off; self.ctx.set_line_width(lw); + self.floor_line_width_to_device(); self.ctx.begin_path(); self.ctx.move_to(lx1 + ox, ly1 + oy); self.ctx.line_to(lx2 + ox, ly2 + oy); @@ -2751,6 +2775,7 @@ impl Renderer for WebCanvasRenderer { _ => { // Single line self.ctx.set_line_width(width); + self.floor_line_width_to_device(); self.ctx.begin_path(); self.ctx.move_to(lx1, ly1); self.ctx.line_to(lx2, ly2); diff --git a/tests/cases/issue_7732_source_frame_top_caption_origin.rs b/tests/cases/issue_7732_source_frame_top_caption_origin.rs new file mode 100644 index 0000000000..c12c61ce56 --- /dev/null +++ b/tests/cases/issue_7732_source_frame_top_caption_origin.rs @@ -0,0 +1,94 @@ +//! [#7732] 저장 첫 프레임 경로의 위 캡션 표 원점 — 캡션을 원점에 두 번 더하지 않는다. +//! +//! 쪽 끝에서 나뉘는 쪽 나눔 표의 첫 조각이 저장 첫 프레임(`stored_complete_extent`·첫 조각 빈 밴드) +//! 경로로 확정되고 앞선 배치 계획이 없으면, 조판이 원점을 새로 만든다. 예산(프레임 = 하단 − 원점 − +//! 캡션), 확정(캡션 + 조각 높이)과 그리기(표 = 원점 + 캡션 + 간격)는 모두 원점을 **위 캡션을 포함한 +//! 개체 상자의 위끝**으로 쓴다. 그 원점에 캡션을 넣으면 위 캡션 표가 캡션 높이만큼 내려 그려진다. +//! +//! 입력: `samples/issue1937/top_caption_rowbreak_p20.hwp` — 공공 보고서 +//! `samples/issue1937_rowbreak_footnote_overpagination.hwp`(50쪽)의 문단 126–145(18–21쪽)만 남겨 +//! 한/글 2024 로 다시 저장한 5쪽 문서(저장 줄·표 첫 프레임은 한/글이 다시 썼다). 정본은 같은 저장본의 +//! 한/글 2024 Print `pdf/issue1937/top_caption_rowbreak_p20-2024.pdf` 다. 3쪽 끝 표 pi=18 은 위 캡션 +//! «< 부담금 개별법령상 납부의무 완화 규정 종합 >» 을 달고 쪽 끝에서 나뉜다. 정본은 표 위 괘선 +//! 834.1px · 첫 조각 아래 괘선 1005.0px 다. 수정 전 rhwp 는 표를 캡션 높이만큼 내려 위 괘선을 +//! 859.6px 에 그렸다. + +#![cfg(not(target_arch = "wasm32"))] + +use std::path::Path; + +use rhwp::document_core::DocumentCore; +use rhwp::renderer::render_tree::{RenderNode, RenderNodeType}; + +const SAMPLE: &str = "samples/issue1937/top_caption_rowbreak_p20.hwp"; +const PAGE: u32 = 2; +const HOST_PARA: usize = 18; +const CAPTION: &str = "개별법령상"; + +fn text(node: &RenderNode) -> String { + let mut out = match &node.node_type { + RenderNodeType::TextRun(run) => run.display_or_text().to_string(), + _ => String::new(), + }; + for child in &node.children { + out.push_str(&text(child)); + } + out +} + +fn host_table(node: &RenderNode) -> Option<&RenderNode> { + if matches!(&node.node_type, RenderNodeType::Table(t) if t.para_index == Some(HOST_PARA)) { + return Some(node); + } + node.children.iter().find_map(host_table) +} + +/// 표 노드 밖에서 `needle` 을 담은 글줄의 (위, 아래). +fn line_outside_tables(node: &RenderNode, needle: &str) -> Option<(f64, f64)> { + if matches!(node.node_type, RenderNodeType::Table(_)) { + return None; + } + if matches!(node.node_type, RenderNodeType::TextLine(_)) && text(node).contains(needle) { + return Some((node.bbox.y, node.bbox.y + node.bbox.height)); + } + node.children + .iter() + .find_map(|child| line_outside_tables(child, needle)) +} + +#[test] +fn stored_first_frame_top_caption_table_is_drawn_once_below_its_caption() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(SAMPLE); + let core = DocumentCore::from_bytes(&std::fs::read(&path).expect("read")).expect("parse"); + assert_eq!(core.page_count(), 5, "한/글 2024 Print 정본 쪽수"); + let pages = core.dump_page_items_json(None); + let body = &pages.as_array().unwrap()[PAGE as usize]["bodyArea"]; + let body_bottom = body["y"].as_f64().unwrap() + body["height"].as_f64().unwrap(); + + let tree = core.build_page_render_tree(PAGE).expect("page 3"); + let table = host_table(&tree.root).expect("3쪽 표 pi=18 첫 조각"); + let (table_top, table_bottom) = (table.bbox.y, table.bbox.y + table.bbox.height); + let (caption_top, caption_bottom) = + line_outside_tables(&tree.root, CAPTION).expect("3쪽 위 캡션 «… 개별법령상 …»"); + + // 캡션은 표 바로 위 한 번: 캡션 줄 아래 ≤ 표 위, 그 사이는 캡션 간격 한 칸 안이다. + assert!( + caption_bottom <= table_top + 0.5 && table_top - caption_bottom <= 12.0, + "캡션 줄({caption_top:.1}–{caption_bottom:.1}) 바로 아래에 표({table_top:.1})" + ); + // 첫 조각은 본문 안에 든다. + assert!( + table_bottom <= body_bottom + 0.5, + "표 첫 조각 아래({table_bottom:.1})가 본문 아래({body_bottom:.1}) 안" + ); + // 3쪽은 Visual Sweep 판정값(raw 실루엣) Native 99.90% · fresh WASM 99.87%로 정본과 맞고 직접 + // 대조했다 — 정본 표 위 괘선 834.1px · 첫 조각 아래 괘선 1005.0px(96dpi)을 ±2px 로 고정한다. + assert!( + (table_top - 834.1).abs() <= 2.0, + "표 위 괘선 {table_top:.1} — 정본 834.1 ±2" + ); + assert!( + (table_bottom - 1005.0).abs() <= 2.0, + "표 첫 조각 아래 괘선 {table_bottom:.1} — 정본 1005.0 ±2" + ); +} diff --git a/tests/cases/native_fractional_glyph_origin.rs b/tests/cases/native_fractional_glyph_origin.rs index 6f57063272..f0aee318d5 100644 --- a/tests/cases/native_fractional_glyph_origin.rs +++ b/tests/cases/native_fractional_glyph_origin.rs @@ -1,6 +1,7 @@ #![cfg(feature = "native-skia")] //! 생성 번호의 소수 원점과 일반 본문의 기존 raster 격자를 함께 보호한다. +//! 장치 한 칸보다 가는 raster 획의 최소 굵기(#8125)도 같은 Native raster 격자 계약이라 여기서 검사한다. use rhwp::paint::{LayerNode, PageLayerTree, PaintOp}; use rhwp::renderer::layer_renderer::RasterRenderOptions; @@ -97,3 +98,83 @@ fn ordinary_ttf_keeps_existing_raster_baseline_grid() { assert!((whole.1 - original.1 - 1.0).abs() <= 0.02); assert!((quarter.0 - original.0).abs() <= 0.05); } + +/// Native raster 의 장치 한 칸보다 가는 획은 한 칸 굵기로 그린다. +/// +/// 한컴 Print PDF 를 raster 로 읽으면 0.1 mm(약 0.38 px) 표 괘선도 장치 한 칸을 채운다. +/// Native Skia 가 선언 굵기 그대로 anti-alias 하면 괘선의 덮임이 38% 안팎에 그쳐 거의 보이지 +/// 않는다. `samples/basic/calendar_year.hwp`(한 쪽, 기준 `pdf/basic/calendar_year-2022.pdf`)는 +/// 이 차이로 Native 실루엣이 88.25 였고, 한 칸 굵기로 그리면 98.87(fresh WASM 93.80)이다. +/// +/// 조판 좌표·선언 굵기는 그대로이고 raster 획 굵기만 바뀌므로, render tree 의 실제 괘선 위치에서 +/// 그 괘선을 가로지르는 화소 중 가장 진한 화소가 선 색 대비의 절반 이상인지 본다. +mod subpixel_stroke_hairline { + use rhwp::document_core::DocumentCore; + use rhwp::paint::RenderProfile; + use rhwp::renderer::render_tree::{LineNode, RenderNode, RenderNodeType}; + + fn thin_horizontal_lines(node: &RenderNode, out: &mut Vec) { + if let RenderNodeType::Line(line) = &node.node_type { + let horizontal = (line.y1 - line.y2).abs() < 0.01 && (line.x2 - line.x1).abs() > 20.0; + if horizontal && line.style.width > 0.0 && line.style.width < 1.0 { + out.push(line.clone()); + } + } + for child in &node.children { + thin_horizontal_lines(child, out); + } + } + + #[test] + fn subpixel_table_rules_cover_one_device_pixel() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("samples/basic/calendar_year.hwp"); + let core = DocumentCore::from_bytes(&std::fs::read(path).expect("read")).expect("parse"); + let tree = core.build_page_render_tree(0).expect("page"); + let mut lines = Vec::new(); + thin_horizontal_lines(&tree.root, &mut lines); + assert!(lines.len() >= 10, "가는 가로 괘선 — {}개", lines.len()); + + let width = tree.root.bbox.width.round() as u32; + let pixels = core + .render_page_pixels_native_at_width(0, width, RenderProfile::Print, &[]) + .expect("raster"); + let scale = f64::from(pixels.width) / tree.root.bbox.width; + let gray = |x: i32, y: i32| -> f64 { + let at = y as usize * pixels.stride + x as usize * 4; + let p = &pixels.bytes[at..at + 3]; + (f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])) / 3.0 + }; + + let mut faint = Vec::new(); + for line in &lines { + let color = line.style.color; + let ink = (f64::from((color & 0xff) as u8) + + f64::from(((color >> 8) & 0xff) as u8) + + f64::from(((color >> 16) & 0xff) as u8)) + / 3.0; + let contrast = 255.0 - ink; + if contrast < 64.0 { + continue; + } + // 괘선 양 끝(교차 괘선)을 피해 가운데 세 지점을 본다. + for t in [0.3, 0.5, 0.7] { + let x = ((line.x1 + (line.x2 - line.x1) * t) * scale) as i32; + let cy = line.y1 * scale; + let darkest = ((cy - 2.0) as i32..=(cy + 2.0) as i32) + .filter(|y| *y >= 0 && *y < pixels.height) + .map(|y| 255.0 - gray(x, y)) + .fold(0.0, f64::max); + if darkest < contrast * 0.5 { + faint.push((line.y1, line.style.width, darkest / contrast)); + } + } + } + assert!( + faint.is_empty(), + "장치 한 칸을 덮지 못한 괘선 {}곳 (y, 굵기, 최대 덮임) — {:?}", + faint.len(), + &faint[..faint.len().min(6)] + ); + } +}