Repository navigation
[renderer/layout] 빈 host 문단의 다중 자리차지 표: host 줄·빈 문단이 표 사이 틈을 채우고 띠에 걸리는 줄만 띠 아래로 가야 한다 (한글문서파일형식 19쪽 −17.4px) #7687
Copy link
Copy link
Closed
Description
사용 경로
CLI (export-svg / export-render-tree / dump-pages), 렌더 트리
rhwp 버전 또는 접속 URL
devel f0e7228f6 로 실측. 코드 위치는 현재 devel 6f66932a7 기준.
운영체제
Linux
문서 형식
HWP (HWP5, 한/글이 저장한 LINE_SEG 있음) · HWPX (linesegarray 없음)
표시 문제와 기대 결과
빈(또는 공백뿐인) host 문단에 문단 기준 자리차지 표(비-TAC, TopAndBottom)가 둘 있다고 하자. 첫 표는 세로 offset 0, 둘째 표는 양수 offset 이라 그 사이에 틈이 있다. 이때 rhwp 는 둘째 표 높이를 흐름에 이어 쌓는다. 그래서 host 줄과 뒤 빈 문단·글 줄이 틈을 채우지 못하고, 둘째 표 띠 안이나 아래로 밀린다.
한/글 규칙(아래 실측으로 확인):
- 빈 host 문단도 자기 줄 상자를 가진다. 그 줄이 offset 0 표의 띠와 겹치면 띠 아래에 놓인다.
- 뒤의 빈 문단·공백 문단·글 문단은 평범한 줄로 표 사이 틈을 차례로 채운다.
- 줄 상자가 다음 표 띠(바깥 여백 포함)에 걸리면 그 줄만 띠 아래로 간다. 이때 앞 간격은 띠 안에서 흡수된다.
- 양수 offset 표는 흐름을 전진시키지 않는다(배제 띠일 뿐이다).
공개 샘플: samples/한글문서파일형식_5.0_revision1.3.hwp (rhwp 19쪽 ↔ 정본 pdf/한글문서파일형식_5.0_revision1.3-hancom-official.pdf 20쪽)
19쪽 위쪽은 정본과 0.2px 안에서 일치한다. 그런데 구역 3 pi=133(「3.2.12. 참고문헌」 문단)부터 아래 내용이 모두 −17.4px 위로 올라간다.
- 원인 문단은 pi=130 · pi=131 · pi=132다.
- 쪽수 차이(rhwp 70 / 정본 71)와는 별개다. 그 한 쪽 밀림은 정본 9쪽에서 이미 시작된다.
| 문단 | 내용 | 저장 LINE_SEG vpos (HU) |
|---|---|---|
| pi=130 | 빈 host(쪽나누기). 표 ci=2: offset 0, 3×1 + 캡션. 표 ci=3: offset 15023, 5×1 + 캡션. 글앞 직선 2 | 10810 |
| pi=131 | 빈 문단 (앞 간격 500HU, 줄 1000+600) | 12910 |
| pi=132 | 빈 문단 (같은 모양) | 28197 |
| pi=133 | #7680 의 host (저장 LINE_SEG 없음) | — |
저장 사다리는 위 규칙과 정확히 맞는다(쪽 위 = 0).
- pi=130 host 줄 10810 = 표 ci=2 띠 끝(바깥 위 141 + 8726 + 캡션 + 바깥 아래 141).
- pi=131 12910 = +2100(앞 간격 500 + 줄 1600). 틈 안이다.
- pi=132 28197 = 표 ci=3 띠 끝(15023 + 141 + 11090 + 캡션 + 141). 띠에 걸려 그 아래로 갔고, 앞 간격은 흡수됐다.
- 따라서 pi=133 = 28197 + 1600 → 103.6 + 397.3 = 500.9px. 정본 PDF 의 pi=133 문단 상단은 501.0px다(첫 표 글자 504.7 − 칸 안 1.8 − 바깥 여백 1.9).
rhwp 는 이 저장값을 따르지 않는다.
| (96dpi, 쪽 원점) | 정본 / 저장 사다리 | rhwp layout | rhwp typeset |
|---|---|---|---|
| pi=130 뒤 흐름 | host 줄 끝 269.1 | 419.6 (표 ci=2 끝 245.8 + 표 ci=3 전체 높이 173.8) | 475.8 (표 ci=3 띠 끝 근처까지 소비) |
| pi=131 | 275.7 (틈) | 426.3 (표 ci=3 띠 안) | 475.8 |
| pi=132 | 479.6 (띠 아래) | 454.3 (띠 안) | 503.8 |
| pi=133 문단 상단 | 501.0 | 483.6 (−17.4, 저장 위치로 +8 당김) | 531.8 (+30.8) |
- 측정(typeset)과 배치(layout)가 서로 다르고, 둘 다 정본과 다르다.
- layout 은 pi=131 의 저장 위치(275.7)가 흐름보다 뒤라서 뒤로 당기지 않는다(뒤로는 8px 까지만 당김).
합성 실험 H1–H6 (공개 템플릿으로 생성, 아래 <details>)
- 쪽마다 빈 host + 표 T1(offset 0, 높이 8000, 바깥 여백 283) + 표 T2(offset 15000, 가로 20000, 높이 8000)를 둔다.
- 그 뒤 문단을 사례마다 달리한다. 모든 문단에 저장 줄이 없다.
- 정본은 한/글 2020 Print PDF 다.
- "한/글 재저장 HWP" 는 같은 HWPX 를 한/글이 HWP 로 저장한 파일이다. 저장 LINE_SEG 를 한/글이 썼다.
| 사례 | 뒤 문단 | 정본 「표 뒤 글」 상단 (글자 상단 px) | 규칙 예측 | rhwp devel: HWPX | rhwp devel: 한/글 재저장 HWP |
|---|---|---|---|---|---|
| H1 | 글 줄 8개 | 글 1·2·3 은 틈 310.9/332.2/353.4, 글 4 부터 T2 아래 489.4 | 일치 | +178.2 / +63.7 | +195.8 / +81.3 |
| H2 | 빈 문단 2 + 글 | 332.2 (틈) | 331.8 | +178.3 | +77.4 |
| H3 | 빈 문단 1 + 글 | 310.9 (틈) | 310.5 | +178.2 | +77.4 |
| H4 | 빈 문단 4 + 글 | 489.4 (T2 아래) | 일치 | +63.7 | −0.3 |
| H5 | 공백 host + 빈 문단 2 + 글 | 332.2 (틈) | 빈 host 와 같음 | +178.7 | +200.1 |
| H6 | 앞 간격 있는 문단들 | 468.2 (T2 아래, 앞 간격 흡수) | 일치 | +74.3 | −0.4 |
- 한/글 재저장 HWP 의 H2 저장 vpos 도 같은 규칙이다: host 10166(=T1 띠 끝), 빈 문단 11766·13366, 글 14966(=331.8px).
- H4·H6 의 HWP 결과는 우연히 맞은 것이다. 어차피 T2 아래로 가는 사례다.
재현 순서·문제 페이지
rhwp dump samples/한글문서파일형식_5.0_revision1.3.hwp -s 3 -p 130(131, 132 도 같은 방법). 저장 vpos 와 표 offset 을 확인한다.rhwp export-render-tree samples/한글문서파일형식_5.0_revision1.3.hwp -p 18. pi=131 TextLine 426.3, pi=132 454.3, 표 pi=133 ci=2 상단 485.5 를 정본 20쪽(표 글자 504.7)과 비교한다.- 합성:
python3 gen_empty_host.py samples/issue1549_multipositive_float_tables.hwpx empty-host.hwpx로 만든 뒤export-render-tree를 한/글 PDF 와 비교한다.
관련 코드 (devel 6f66932a7)
src/renderer/typeset.rs:4635is_visible_para_float/src/renderer/layout.rs:13350is_current_visible_para_float- 글이 있는 host 만 배제 띠(exclusion) 모델을 탄다. 빈·공백 host 는 표를 흐름에 쌓는 경로로 간다.
src/renderer/typeset.rs:4852·src/renderer/layout.rs:12103issue2439_visible_host_stacksrc/renderer/float_placement.rs:2336from_empty_reflow_host,:3429reflow_empty_table_host- 저장 vpos 스냅:
src/renderer/height_cursor.rs:212HeightCursor::vpos_adjust, 호출부src/renderer/layout.rs:8250
원본 문서·재현 자료
- 공개 샘플
samples/한글문서파일형식_5.0_revision1.3.hwp와 정본 PDF 는 저장소에 있다. - 합성 생성기는 아래에 붙였다. 쓰는 템플릿은 저장소 공개 샘플
samples/issue1549_multipositive_float_tables.hwpx뿐이다. - 생성물
empty-host.hwpxSHA-256:3c9a25d26e79…(같은 명령은 같은 바이트를 낸다).
gen_empty_host.py
#!/usr/bin/env python3
"""빈 host 문단의 자리차지 표 두 개(offset 0 / offset 15000HU) 사이 틈을 host 줄·빈 문단·글 줄이
어떻게 채우는지 보는 HWPX 생성기. 모든 문단에 linesegarray 가 없다(한/글이 직접 조판).
python3 gen_empty_host.py samples/issue1549_multipositive_float_tables.hwpx empty-host.hwpx
템플릿은 저장소 공개 샘플이며 header.xml·settings 만 빌려 쓰고 section0.xml 을 새로 만든다.
쪽마다 사례 하나(H1~H6).
"""
import re
import sys
import zipfile
_id = [1000]
def nid():
_id[0] += 1
return _id[0]
def cell_para(text):
return (f'<hp:p id="{nid()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'<hp:run charPrIDRef="0"><hp:t>{text}</hp:t></hp:run></hp:p>')
def table(vert, horz, height, label):
width, rows = 18000, 2
trs = []
for r in range(rows):
tc = (f'<hp:tc name="" header="0" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="3">'
f'<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" '
f'linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">'
f'{cell_para(f"{label} 행{r + 1}")}</hp:subList><hp:cellAddr colAddr="0" rowAddr="{r}"/>'
f'<hp:cellSpan colSpan="1" rowSpan="1"/><hp:cellSz width="{width}" height="{height // rows}"/>'
f'<hp:cellMargin left="510" right="510" top="141" bottom="141"/></hp:tc>')
trs.append(f'<hp:tr>{tc}</hp:tr>')
return (f'<hp:tbl id="{nid()}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" '
f'lock="0" dropcapstyle="None" pageBreak="NONE" repeatHeader="0" rowCnt="{rows}" colCnt="1" '
f'cellSpacing="0" borderFillIDRef="3" noAdjust="0"><hp:sz width="{width}" widthRelTo="ABSOLUTE" '
f'height="{height}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="0" affectLSpacing="0" '
f'flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" '
f'vertAlign="TOP" horzAlign="LEFT" vertOffset="{vert}" horzOffset="{horz}"/>'
f'<hp:outMargin left="283" right="283" top="283" bottom="283"/>'
f'<hp:inMargin left="510" right="510" top="141" bottom="141"/>{"".join(trs)}</hp:tbl>')
def para(text, page_break=False, para_pr=0):
return (f'<hp:p id="{nid()}" paraPrIDRef="{para_pr}" styleIDRef="0" pageBreak="{1 if page_break else 0}" '
f'columnBreak="0" merged="0"><hp:run charPrIDRef="0"><hp:t>{text}</hp:t></hp:run></hp:p>')
T1 = (0, 0, 8000, "T1")
T2 = (15000, 20000, 8000, "T2")
def host(text, para_pr=0):
ctrls = table(*T1) + table(*T2)
body = f'<hp:t>{text}</hp:t>' if text is not None else ''
return (f'<hp:p id="{nid()}" paraPrIDRef="{para_pr}" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'<hp:run charPrIDRef="0">{ctrls}{body}</hp:run></hp:p>')
def empty(para_pr=0):
return (f'<hp:p id="{nid()}" paraPrIDRef="{para_pr}" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'<hp:run charPrIDRef="0"/></hp:p>')
CASES = [
("H1 빈 host 뒤 글 줄", lambda: [host(None)] + [para(f"H1 글 {i}") for i in range(1, 9)]),
("H2 빈 host 뒤 빈 문단 둘", lambda: [host(None), empty(), empty(), para("H2 표 뒤 글")]),
("H3 빈 host 뒤 빈 문단 하나", lambda: [host(None), empty(), para("H3 표 뒤 글")]),
("H4 빈 host 뒤 빈 문단 넷", lambda: [host(None)] + [empty() for _ in range(4)] + [para("H4 표 뒤 글")]),
("H5 공백 host 뒤 빈 문단 둘", lambda: [host(" "), empty(), empty(), para("H5 표 뒤 글")]),
("H6 앞 간격 있는 빈 문단", lambda: [host(None, 12), empty(12), empty(12), para("H6 표 뒤 글", para_pr=12)]),
]
def main():
template, out = sys.argv[1], sys.argv[2]
zin = zipfile.ZipFile(template)
sec = zin.read("Contents/section0.xml").decode("utf-8")
sec_head = sec[:sec.index("<hp:p ")]
sec_run = re.search(r'<hp:p [^>]*>(<hp:run charPrIDRef="0"><hp:secPr.*?</hp:run>)', sec, re.S).group(1)
body = [f'<hp:p id="{nid()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'{sec_run}<hp:run charPrIDRef="0"><hp:t>#7680 빈 host 실험</hp:t></hp:run></hp:p>']
for i, (name, build) in enumerate(CASES):
body.append(para(f"사례 {name}", page_break=i > 0))
body.extend(build())
new_sec = sec_head + "".join(body) + "</hs:sec>"
with zipfile.ZipFile(out, "w") as zout:
for item in zin.infolist():
if item.filename.startswith("Preview/"):
continue
data = new_sec.encode("utf-8") if item.filename == "Contents/section0.xml" else zin.read(item.filename)
ct = zipfile.ZIP_STORED if item.filename == "mimetype" else zipfile.ZIP_DEFLATED
zout.writestr(item, data, compress_type=ct)
if __name__ == "__main__":
main()비교 화면·PDF
- 한/글 2020 Print PDF
empty-host-2020.pdf- SHA-256
2c02499fa575… - hwp2024Convert MCP
engine: 2020, 한컴 11.0.0.9136,pdf_print_method: 0,pdf_output_mode: hancom2020_pdf_driver_one_up, jobc9fcdd5a-…
- SHA-256
- 한/글 재저장 HWP
empty-host-hancom.hwp- SHA-256
4f1c6e5ada17… - 같은 MCP
target: hwp,engine: 2020
- SHA-256
- 두 바이너리는 CLI 로 이슈에 올릴 수 없다. 필요하면 메인터너나 작성자가 첨부한다. 위 생성기와 같은 MCP 로 다시 만들 수 있다.
관련 증상
- [renderer/layout] 같은 문단의 둘째 자리차지 표가 양수 세로 오프셋이면 host 글이 그 표 아래로 넘어가지 않고 표 위에 겹친다 (한글문서파일형식 19쪽) #7680: 같은 문단에 자리차지 표가 여럿인 경우의 글 있는 host 쪽 결함이다. 그 작업 중 발견했다.
- 수정은 로컬 브랜치
fix/7680-on7617(fix(조판): 자리차지 표가 민 첫 글줄에 문단 앞 간격을 다시 더하지 않는다 (Refs #7330) #7617 위)에만 있고 PR 은 아직 없다. - 이 결함 때문에 [renderer/layout] 같은 문단의 둘째 자리차지 표가 양수 세로 오프셋이면 host 글이 그 표 아래로 넘어가지 않고 표 위에 겹친다 (한글문서파일형식 19쪽) #7680 의 원래 쪽(한글문서파일형식 19쪽)은 그 수정 뒤에도 실루엣 60.4% 에 머문다(90% 미달). 이 −17.4px 를 지우면 정본과 0.1px 안으로 맞을 것으로 예상하지만 미검증이다.
- 수정은 로컬 브랜치
- 연관 축: [layout] 자리차지 표만 든 host 문단이 본문 글줄을 못 내고 흐름 전진이 저장 줄높이에 못 미친다 — #4599 잔여 SINGLE 81건 중 53건 (정답지 확인 22건) #7344(자리차지 표만 든 host 가 글줄을 못 내고 전진이 모자람, 단일 표), 빈 호스트 자리차지 표 뒤 간격: rhwp 는 호스트 줄간격+문단 뒤 간격, 한/글은 표 바깥 여백만 (코퍼스 350쌍) #6841(빈 host 자리차지 표 뒤 간격), [renderer] 빈 host 문단의 자리차지 표가 vertical_offset·바깥여백만큼 흐름을 전진시키지 않아 다음 표가 위로 올라와 겹친다 — 40쪽 결재란 19.7px 침범 (10645) #5870, 빈-host co-anchored float 표 문서에서 섹션 제목이 표 아래로 밀림 — v0.8.0 회귀 #3645(빈-host co-anchored), [renderer/layout] 자리차지 표의 윗변이 경로마다 다른 원점을 쓴다 — 13건은 앞 문단 글자를 뚫고 6건은 칸 안 줄을 잃는다 (hwpctl_API_v2.4, 40개 중 29개) #7203.
- 범위 경고: 고치려면 빈·공백 host 의 다중 float 모델을 측정·배치 양쪽에서 바꿔야 한다. 다음 이슈들의 기존 핀·회귀 시험과 맞닿는다:
- [HWP] 같은 문단 co-anchored float 표를 vertical_offset 으로 배치 — 페이지 순서 역전·세로 공백 페이지 증가·인라인 제목 좌측 쏠림 #1510 / [HWP] PR #1518 이후: 같은 문단 co-anchored float 표를 1페이지로 배치할 때 인접 셀 텍스트 겹침 (#1510 후속) #1535 / [HWP/렌더링] visible host 문단 텍스트(제목)가 양수 offset co-anchored float 표 아래로 밀려 렌더됨 (#1510 계열, #1535와 별개) #1549 / [HWP] PR #1518 이후: 빈 문단 co-anchored float 표의 음수 vertical_offset 으로 페이지 순서 역전 (실문서, #1510 계열) #1639 / 빈-host co-anchored 자리차지 RowBreak 표: 후속 표가 페이지 경계에서 행 분할돼 헤더-본문 분리 + 표 뒤 빈 페이지 #1663 / HWP 반복 표가 6페이지에서 겹쳐 렌더링됨 (LAYOUT_OVERFLOW) #2439 / PR #2512(#2439 반복 표 exclusion) 이후 표 문서 7건 +1 과분할 회귀 (10k r19) #2808 / [renderer] 같은 문단의 자리차지 표 2장이 세로로 안 쌓이고 632x342px 겹친다 (3070000-202200004 86쪽, #2439 의 렌더 짝) #6795 / [renderer] 다음 문단이 앞 문단 자리차지 표 밴드를 통과해 633x113px 겹친다 — 사다리는 표 바닥을 가리킨다 (156160455 7쪽) #6797 / [layout] 문단 끝 자리차지 표의 앵커 줄 보정 누락으로 본문과 겹침 #6950
- 빈 host 자리차지 표는 공문서에 흔해 코퍼스 영향이 클 수 있다.
- 저장 vpos 스냅(
HeightCursor::vpos_adjust)의 쪽 기준(page base) 처리와도 닿을 수 있다.
이전에 정상 동작한 버전
모름 (확인한 devel 에서는 모두 재현)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
planet6897 commentedon Oct 17, 2026
착수합니다. 한글문서파일형식 19쪽(pi=130~133)의 빈 host 다중 자리차지 표 틈 채움(#7687)과 양수 오프셋 둘째 표 뒤 host 글(#7680)을 한 규칙으로 봅니다 — 양수 오프셋 표는 흐름을 전진시키지 않는 배제 띠이고, 띠에 걸리는 줄만 띠 아래로 간다. 정본
pdf/한글문서파일형식_5.0_revision1.3-hancom-official.pdf20쪽을 기준으로 측정·배치가 같은 결과를 쓰게 고칩니다.planet6897 commentedon Oct 17, 2026
PR #8005 로 제출했다 (head
839af8c92, basef9d2ab17a). #7680 과 함께 닫는다.pdf/issue7687/empty-host-2020.pdf).tests/cases/issue_7687_empty_host_multi_float_gap.rs2건(수정 전 FAIL → 수정 후 PASS). 코퍼스 A/B 원장 546문서 변화 0, samples 1,252문서 anomaly 변화 0, 쪽수 변화 1건(시장구조조사 316 → 315 = 정본).fix(renderer): 빈 host 다중 자리차지 표의 틈을 host 줄·빈 문단이 채우고 둘째 표 띠에 걸린 글줄은 띠…
fix(typeset): 빈 공동 앵커 host 자리차지 표의 띠·흐름에 바깥 여백을 거듭 더하지 않는다 (edwardkim…
docs(report): edwardkim#7687 띠 바깥 여백 이중 계상 수정 전후 review·overlay 와 HWP…
fix(typeset): 빈 공동 앵커 host 자리차지 표의 띠·흐름에 바깥 여백을 거듭 더하지 않는다 (edwardkim…
feat: 기여 PR 10건 통합과 조판·Studio 회귀 보정 (#8228)
planet6897 commentedon Oct 26, 2026
devel 반영 확인 — 닫습니다
PR #8005 와 #8157 의 수정이 각각 #8148 과 #8228(
3e02fe274)로 devel 에 들어갔습니다. devel4e690c930새 빌드로 다시 쟀습니다(Native native-skia·fresh WASM,--compat 2022, 96dpi, raw 실루엣, 글꼴 공급).issue_7687_empty_host_multi_float_gap2건 PASS:synthetic_empty_host_cases_follow_hancom_gap_ruletitled_host_after_empty_host_band_keeps_its_float_offsetssamples/issue7687/empty-host.hwpx↔empty-host-2020.pdf: 전 6쪽 Native·WASM 100titled-host.hwpx↔titled-host-2020.pdf: 전 4쪽 최저 Native 94.70 · WASM 94.23samples/한글문서파일형식_5.0_revision1.3.hwp: 「3.2.12. 참고문헌」이 든 쪽이 지금은 rhwp 20쪽 = 정본 20쪽으로 맞습니다. 그 20쪽은 Native 99.998 · WASM 99.92 입니다(이슈 당시 −17.4px, 수정(renderer): 빈 host 다중 자리차지 표의 틈 채우기와 둘째 표 아래 host 글줄 (#7687, #7680) #8005 직후 81.2).이 문서에는 이 이슈 범위 밖의 차이가 남아 있습니다.
7f813a048도 83쪽이고, [layout/table] 빈 host 자리차지 표가 바깥 위 여백만큼 위에 그려지고 표 뒤 흐름은 7px 아래로 간다 — 한글문서파일형식 39·40쪽 표 마지막 행이 넘어가 +1쪽 (#8148 뒤 새로 생김, 74쪽 vs 정본 71쪽) #8149 수정 빌드는 71쪽입니다. 그래서 [layout/table] 빈 host 자리차지 표가 바깥 위 여백만큼 위에 그려지고 표 뒤 흐름은 7px 아래로 간다 — 한글문서파일형식 39·40쪽 표 마지막 행이 넘어가 +1쪽 (#8148 뒤 새로 생김, 74쪽 vs 정본 71쪽) #8149 / PR fix: #8149 빈 host 자리차지 표의 저장 상자·쪽 바닥 첫 조각 계약 (사양서 74→71쪽) #8184 범위로 둡니다.planet6897 commentedon Oct 26, 2026
앞 코멘트의 사양서 19쪽 잔여를 나눠 추적합니다.