Skip to content

Commit d7a602c

Browse files
chrisryugjclaude
andcommitted
fix(render): 칸 안 떠 있는 표가 쪽을 넘으면 칸과 표를 그 표가 끝나는 쪽까지 잇는다
한컴이 갈라 저장하지 않은 칸(칸 줄 vertpos 되돌아감 없음)에 앵커한 떠 있는 표가 쪽을 넘으면 — 칸 하나짜리 별표 서식 안의 큰 표 — 렌더는 칸 안 표를 첫 쪽에 다 그려 쪽 밖 행이 잘렸다. 칸 안 표를 쪽에서 가르는 것은 갈라 저장한 칸에만 켜져 있었다. - 그런 칸의 행은 칸 줄을 다 첫 조각에 두고 칸 틀을 표가 끝나는 쪽까지 잇는다(사이 쪽은 줄 없는 조각, 끝 쪽은 표 바닥 + 칸 아래 여백). 표가 이어지는 조각은 칸 세로 정렬을 하지 않는다. - 칸 높이(cellContentExtent)는 떠 있는 표도 행으로 잰 높이까지 센다 — 쪽을 넘는 표의 저장 sz 는 첫 쪽 조각뿐(sz 67,874· 행 합 142,876). sz 로만 재면 표 전체가 아래 여백 안에서 끝나 보여 표를 가르지 않았다. - 쪽 넘김 판정의 칸 안 표 위는, 앵커 줄이 그 표에 밀려 저장됐으면 문단 위(앞 문단 끝) — 그릴 때와 같다(34라운드). 줄 위로 보면 사업 제안요청서 칸 안 표가 233pt 아래에서 시작한다고 보고 쪽이 하나 늘었다. 근거 — 같은 판(2020) 저장·인쇄 한컴 PDF: 원자력안전법 시행규칙 별표(칸 안 26행 표, 칸 줄 되돌아감 없음, 안쪽 표의 두 행이 갈라 저장됨) 3쪽, 대기환경보전법 시행규칙 별표(71행 표 마지막 행 칸 안 3행·5행 표) 6쪽. PDF 짝 글(공백 무시 유일 문자열) 일치 8/22 → 22/22, 76/98 → 88/98. 실측(한컴 PDF 짝 렌더): - 쪽 수 1,419 → 1,421/1,431(위 2건), 새로 어긋난 문서 0 - 글 조각 86,051/90,433·그림 3,872/3,976 그대로(두 문서는 글 위치 짝 모수 밖) - 조판(12개 폴더 저장본 대조): 본문 76,729·본문+개체 16,336·칸 205,015·칸+개체 6,188·두 줄+ 24,940/25,187·줄 수 306,681 그대로, 문서별 오르내림 0 - 테스트 4,300(통과 4,292·건너뜀 8; 새 검사: 칸 줄에 앵커한 20행 떠 있는 표(sz 30,000·행 합 60,000)가 둘째 쪽으로 이어진다 — 옛 코드는 1쪽으로 실패), 쪽 경계 10/10, reflow 게이트 59/59, 왕복 PASS Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7EF2EFJZMeH4dk7Rm2f15
1 parent c6c83dd commit d7a602c

6 files changed

Lines changed: 62 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- 칸 줄이 쪽에서 갈라 저장된(칸 안 줄 vertpos 가 되돌아간) 표는 아래 여백으로 넘쳐 한 쪽에 남기지 않는다(렌더). 종전엔 표 전체가 아래 여백 안에서 끝나면 가르지 않아, 한컴이 두 쪽에 걸쳐 저장한 서식 표를 한 쪽에 그리고 갈린 칸 줄을 쪽 밖으로 밀어냈다.
1313
- 쪽에서 갈라 저장된 행(칸 줄이 되돌아감)의 첫 조각은 첫 줄도 이 쪽 남은 자리에 안 들면 다음 쪽 맨 위에서 시작한다(렌더 — 칸 줄 나눔과 같은 판정). 종전엔 저장된 갈림 자리를 그대로 따라 첫 조각을 쪽 바닥(남은 858) 아래에 그렸다.
1414
- 칸 안 빈 문단의 위아래 배치 표도 줄이 그 표에 밀려 저장됐으면(줄 = 칸 안 앞 문단 끝 + 바깥 여백 + 표 높이) 줄 위(앞 문단 끝)에 그린다(렌더 — 본문 문단과 같은 판정). 종전엔 칸 안에서는 늘 줄 자리에 그려, 칸 안 표가 제 높이만큼(196~234pt) 아래로 밀렸다.
15+
- 갈라 저장되지 않은 칸의 줄에 앵커한 떠 있는 표가 쪽을 넘으면 칸 틀을 그 표가 끝나는 쪽까지 잇고 칸 안 표를 다음 쪽에 이어 그린다(렌더). 칸 높이는 떠 있는 표도 행으로 잰 높이까지 센다(쪽을 넘는 표의 저장 `sz` 는 첫 쪽 조각뿐). 종전엔 칸 안 표를 첫 쪽에 다 그려 쪽 밖 행이 잘렸다 — 26행 안쪽 표가 든 세 쪽 서식이 한 쪽.
1516
- 꼬리말 영역이 0 인 구역의 아래쪽 쪽 번호는 아래 여백 가운데 위(기준선 = 쪽 바닥 − 아래 여백/2 − 2.43pt)에 놓는다(렌더). 종전엔 꼬리말 영역이 있을 때처럼 아래 여백 바로 위(쪽 바닥 − 아래 여백 − 2.25pt)에 놓아, 아래 여백 2,834 서식의 쪽 번호가 한컴 PDF 보다 14pt 위에 찍혔다.
1617

1718
## [4.22.12] - 2026-10-18

‎src/render/para-model.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ export function cellContentExtent(cell: CellModel, memo?: ExtentMemo): number {
359359
const anchor = pushed >= -100 ? pushed : baseV - (memo?.paraGeom?.get(p.getAttribute("paraPrIDRef") ?? "")?.spaceBefore ?? 0)
360360
// 문단 기준 음수 vertOffset 은 문단 위에서 멈춘다(렌더와 같다) — 한컴 PDF: vertOffset −167 그림을 품은 칸의 행이 그림 높이만큼 자란다
361361
const vo = num(pos, "vertOffset")
362-
ext = Math.max(ext, anchor + num(om, "top") + Math.max(0, vo) + o.height)
362+
// 떠 있는 표도 행으로 잰 높이까지 — 쪽을 넘는 표의 저장 sz 는 첫 쪽 조각뿐이다(한컴 PDF: sz 67,874 인 26행 안쪽 표가 행 합 142,876 으로 세 쪽)
363+
const oh = o.tag === "tbl" ? Math.max(o.height, measureTableHeight(o.el, memo)) : o.height
364+
ext = Math.max(ext, anchor + num(om, "top") + Math.max(0, vo) + oh)
363365
}
364366
}
365367
memo?.cell.set(cell.el, ext)

‎src/render/svg-content.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ export function drawPara(p: Element, ox: number, oy: number, areaW: number, ctx:
510510
/** hp:pos 기준계 해석 → 개체 좌상단 절대좌표 (tac=0) */
511511
/** 칸 안 문단의 영역 위 — 앞 문단 끝 줄(vertpos + 높이 + 간격) + 앞 문단 아래 간격, 첫 문단은 0. 앞 문단 줄이 없으면 모른다.
512512
* 글을 밀지 않는 개체가 첫 줄을 덮어 줄이 개체 아래로 저장된 칸 문단도 개체는 문단 위 기준이다(한컴 PDF: 칸 안 글 뒤 앵커 그림 −80 이 칸 위에, 글 줄 2551 은 그 아래) */
513-
function cellParaTop(p: Element, geom: RenderStyles["paraGeom"]): number | undefined {
513+
export function cellParaTop(p: Element, geom: RenderStyles["paraGeom"]): number | undefined {
514514
let prev = p.previousSibling as Element | null
515515
while (prev && ln(prev) !== "p") prev = prev.previousSibling as Element | null
516516
if (!prev) return 0

‎src/render/svg-render.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { subFloatBottoms } from "./reflow-tables.js"
3939
import { mulAffine, objectGeometry, renderingMatrix, shapeGeometry, rotatedBounds, SHAPE_TAGS, type Affine, type RegionRotation } from "./shape-geometry.js"
4040
import { anchorObject, linePushedBy } from "./object-anchor.js"
4141
import { readZipEntry } from "../hwpx/parser-shared.js"
42-
import { escapeXml, pt, ptNum, pageNo, bboxOf, regionOpenTag, parentId, emit, warnOnce, drawPara, type PageGeom, type Ctx, type PaintJob } from "./svg-content.js"
42+
import { escapeXml, pt, ptNum, pageNo, bboxOf, regionOpenTag, parentId, emit, warnOnce, drawPara, cellParaTop, type PageGeom, type Ctx, type PaintJob } from "./svg-content.js"
4343
export { escapeXml } from "./svg-content.js"
4444
export { buildPara, measureTableHeight, tabAdvance, type Seg, type ParaChar, type ParaModel, type ExtentMemo } from "./para-model.js"
4545

@@ -108,7 +108,10 @@ function cellFloatSpan(MT: number, BODY_H: number, memo: ExtentMemo, slack: numb
108108
if ((pos?.getAttribute("vertRelTo") ?? "PARA") !== "PARA") continue
109109
const segK = Math.max(0, m.segs.findIndex((sg, i) => sg.textpos <= o.index && (i === m.segs.length - 1 || m.segs[i + 1].textpos > o.index)))
110110
if (segK !== k) continue
111-
const ty = top + num(findChildByLocalName(o.el, "outMargin"), "top") + Math.max(0, toInt32(pos?.getAttribute("vertOffset") ?? undefined, 0))
111+
// 앵커 줄이 이 표에 밀려 저장됐으면(줄 = 앞 문단 끝 + 표) 표는 줄 위가 아니라 문단 위에서 시작한다 — 그릴 때와 같다
112+
const segV = num(seg, "vertpos"), pTop = cellParaTop(p, memo.paraGeom ?? new Map())
113+
const start = linePushedBy(o, segV, pTop) ? top - (segV - (pTop ?? segV)) : top
114+
const ty = start + num(findChildByLocalName(o.el, "outMargin"), "top") + Math.max(0, toInt32(pos?.getAttribute("vertOffset") ?? undefined, 0))
112115
const span = tableContPages(o.el, ty, MT, BODY_H, memo, slack, cellFloatSpan(MT, BODY_H, memo, slack), true, contMT)
113116
if (span.pages > 0 && (!best || span.pages > best.pages)) best = { ...span, el: o.el }
114117
}
@@ -197,7 +200,7 @@ function drawTable(tbl: Element, tx: number, ty: number, ctx: Ctx, depth: number
197200
// 마지막 조각은 칸 저장 몫으로 내용보다 클 수 있다(두 쪽 나눔) — 그 남는 높이 안에서 맞춘다(같은 판 PDF: 마지막 조각 글이 남는 몫의 절반 아래)
198201
const lastK = g.pieces.length - 1
199202
const pieceOff = g.pieces.map((pc, k) => {
200-
if (va === "TOP" || (k > 0 && k < lastK)) return 0
203+
if (va === "TOP" || (k > 0 && k < lastK) || pc.fill) return 0
201204
const from = pc.range?.get(c.el)?.[0] ?? 0
202205
const bottoms = boxes.filter(b => pieceOf(b.top) === k).map(b => b.bottom - from)
203206
if (k > 0) for (const f of subFloatBottoms(c.sub!)) { const b = boxes.find(x => x.seg === f.seg); if (b && pieceOf(b.top) === k) bottoms.push(b.top + f.bottom - f.v0 - from) }

‎src/render/table-pages.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { subFloatBottoms } from "./reflow-tables.js"
1313
* 행 하나가 한 쪽에 놓인 조각 — page 는 표 시작 쪽에서 센 쪽 차이, y 는 절대 위치. range 는 칸마다 이 조각에 그릴 내용 vertpos [from, to).
1414
* place 는 이 조각에 옮겨 그릴 칸 안 떠 있는 개체의 위(절대 y)
1515
*/
16-
export interface RowPiece { page: number; y: number; h: number; range?: Map<Element, [number, number]>; place?: Map<Element, number> }
16+
/** fill — 칸 줄에 앵커한 떠 있는 표가 이 조각 바닥 너머로 이어진다(칸 세로 정렬 없이 저장 자리에 그린다) */
17+
export interface RowPiece { page: number; y: number; h: number; range?: Map<Element, [number, number]>; place?: Map<Element, number>; fill?: boolean }
1718

1819
/** 칸 문단의 겹치지 않는(allowOverlap=0) 떠 있는 문단 기준 개체 — 위 오프셋(음수는 0)과 바깥 여백 포함 높이 */
1920
function stackedFloats(c: CellModel): Array<{ el: Element; vo: number; h: number; omT: number }> {
@@ -220,6 +221,26 @@ export function planTableRows(tbl: Element, cells: CellModel[], rowH: number[],
220221
y += h; first = false
221222
continue
222223
}
224+
// 갈라 저장되지 않은 칸의 줄에 앵커한 떠 있는 표가 쪽을 넘으면 칸 틀은 그 표가 끝나는 쪽까지 잇는다 — 칸 줄은 다 첫 조각,
225+
// 사이 쪽은 줄 없는 조각, 끝 쪽 조각은 표 바닥 + 칸 아래 여백까지(같은 판이 저장·인쇄한 한컴 PDF: 칸 하나짜리 별표 서식 안의 26행 표가 세 쪽)
226+
if (boxes && floatSpan) {
227+
let span = 0, spanEnd = 0, mB = 0
228+
for (const c of rowCells) for (const b of boxes.get(c.el) ?? []) {
229+
const f = floatSpan(b.seg, y + c.marginT + b.top, contTop + c.marginT)
230+
if (f && f.pages > span) { span = f.pages; spanEnd = f.endY; mB = c.marginB }
231+
}
232+
if (span > 0) {
233+
const none = new Map(rowCells.map(c => [c.el, [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] as [number, number]]))
234+
pieces.push({ page, y, h: pageBottom - y, range: new Map(rowCells.map(c => [c.el, [0, Number.POSITIVE_INFINITY] as [number, number]])), fill: true })
235+
for (let e = 1; e < span; e++) pieces.push({ page: page + e, y: contTop, h: pageBottom - contTop, range: none, fill: true })
236+
page += span
237+
const endH = Math.max(0, spanEnd - contTop) + mB
238+
pieces.push({ page, y: contTop, h: endH, range: none })
239+
out.push(pieces)
240+
y = contTop + endH; first = false
241+
continue
242+
}
243+
}
223244
if (splittable && y + h > pageBottom) {
224245
const from = new Map<Element, number>()
225246
const lines = new Map(rowCells.map(c => [c.el, cellLines(c)] as const))
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it } from "node:test"
2+
import assert from "node:assert/strict"
3+
import JSZip from "jszip"
4+
import { renderHwpxPages } from "../src/render/svg-render.js"
5+
6+
const ns = 'xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"'
7+
const head = `<hh:head ${ns}><hh:fontfaces><hh:fontface lang="HANGUL"><hh:font id="0" face="함초롬바탕" type="TTF"/></hh:fontface></hh:fontfaces>` +
8+
`<hh:charProperties><hh:charPr id="0" height="1000"><hh:fontRef hangul="0"/></hh:charPr></hh:charProperties>` +
9+
`<hh:paraProperties><hh:paraPr id="0"><hh:align horizontal="LEFT"/></hh:paraPr></hh:paraProperties></hh:head>`
10+
const seg = (v: number) => `<hp:linesegarray><hp:lineseg textpos="0" vertpos="${v}" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="9000"/></hp:linesegarray>`
11+
const tc = (r: number, sub: string, h: number) => `<hp:tc><hp:subList vertAlign="TOP">${sub}</hp:subList><hp:cellAddr colAddr="0" rowAddr="${r}"/><hp:cellSpan colSpan="1" rowSpan="1"/><hp:cellSz width="8000" height="${h}"/><hp:cellMargin left="0" right="0" top="0" bottom="0"/></hp:tc>`
12+
13+
describe("render: 갈라 저장되지 않은 칸 안의 쪽을 넘는 표", () => {
14+
it("칸 줄에 앵커한 떠 있는 표(행 20개 × 3000, 저장 sz 는 첫 쪽 조각 30000)가 쪽을 넘으면 칸과 표가 다음 쪽으로 이어진다", async () => {
15+
const rows = [...Array(20).keys()].map(r => `<hp:tr>${tc(r, `<hp:p paraPrIDRef="0"><hp:run charPrIDRef="0"><hp:t>행${r}번</hp:t></hp:run>${seg(0)}</hp:p>`, 3000)}</hp:tr>`).join("")
16+
const inner = `<hp:tbl rowCnt="20" colCnt="1" pageBreak="CELL" textWrap="TOP_AND_BOTTOM"><hp:sz width="8000" height="30000"/><hp:pos treatAsChar="0" vertRelTo="PARA" horzRelTo="PARA" vertOffset="0" horzOffset="0"/><hp:outMargin left="0" right="0" top="0" bottom="0"/>${rows}</hp:tbl>`
17+
// 바깥 칸: 제목 줄(0) + 빈 문단 줄(1600, 표에 밀리지 않음) — 한컴은 이 칸을 갈라 저장하지 않았다
18+
const sub = `<hp:p paraPrIDRef="0"><hp:run charPrIDRef="0"><hp:t>제목</hp:t></hp:run>${seg(0)}</hp:p><hp:p paraPrIDRef="0"><hp:run charPrIDRef="0">${inner}<hp:t/></hp:run>${seg(1600)}</hp:p>`
19+
const outer = `<hp:tbl rowCnt="1" colCnt="1" pageBreak="CELL" textWrap="TOP_AND_BOTTOM"><hp:sz width="10000" height="31600"/><hp:pos treatAsChar="0" vertRelTo="PARA" horzRelTo="PARA" vertOffset="0" horzOffset="0"/><hp:outMargin left="0" right="0" top="0" bottom="0"/>` +
20+
`<hp:tr><hp:tc><hp:subList vertAlign="TOP">${sub}</hp:subList><hp:cellAddr colAddr="0" rowAddr="0"/><hp:cellSpan colSpan="1" rowSpan="1"/><hp:cellSz width="10000" height="31600"/><hp:cellMargin left="0" right="0" top="0" bottom="0"/></hp:tc></hp:tr></hp:tbl>`
21+
const sec = `<hs:sec ${ns}><hp:p paraPrIDRef="0"><hp:run charPrIDRef="0"><hp:secPr><hp:pagePr width="20000" height="40000"><hp:margin header="0" footer="0" left="1000" right="1000" top="1000" bottom="1000"/></hp:pagePr></hp:secPr>${outer}<hp:t/></hp:run>${seg(0)}</hp:p></hs:sec>`
22+
const z = new JSZip()
23+
z.file("Contents/header.xml", head); z.file("Contents/section0.xml", sec)
24+
const r = await renderHwpxPages(await z.generateAsync({ type: "uint8array" }))
25+
assert.equal(r.pageSvgs.size, 2)
26+
const m = (r.pageSvgs.get(2) ?? "").match(/<text x="[-\d.]+" y="([-\d.]+)"[^>]*>행19번<\/text>/)
27+
assert.ok(m, "둘째 쪽의 안쪽 표 마지막 행")
28+
})
29+
})

0 commit comments

Comments
 (0)