Skip to content

[renderer/layout] 빈 host 문단의 다중 자리차지 표: host 줄·빈 문단이 표 사이 틈을 채우고 띠에 걸리는 줄만 띠 아래로 가야 한다 (한글문서파일형식 19쪽 −17.4px) #7687

Closed

Description

@planet6897

사용 경로

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 아래로 가는 사례다.

재현 순서·문제 페이지

  1. rhwp dump samples/한글문서파일형식_5.0_revision1.3.hwp -s 3 -p 130 (131, 132 도 같은 방법). 저장 vpos 와 표 offset 을 확인한다.
  2. 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)과 비교한다.
  3. 합성: python3 gen_empty_host.py samples/issue1549_multipositive_float_tables.hwpx empty-host.hwpx 로 만든 뒤 export-render-tree 를 한/글 PDF 와 비교한다.

관련 코드 (devel 6f66932a7)

  • src/renderer/typeset.rs:4635 is_visible_para_float / src/renderer/layout.rs:13350 is_current_visible_para_float
    • 글이 있는 host 만 배제 띠(exclusion) 모델을 탄다. 빈·공백 host 는 표를 흐름에 쌓는 경로로 간다.
  • src/renderer/typeset.rs:4852 · src/renderer/layout.rs:12103 issue2439_visible_host_stack
  • src/renderer/float_placement.rs:2336 from_empty_reflow_host, :3429 reflow_empty_table_host
  • 저장 vpos 스냅: src/renderer/height_cursor.rs:212 HeightCursor::vpos_adjust, 호출부 src/renderer/layout.rs:8250

원본 문서·재현 자료

  • 공개 샘플 samples/한글문서파일형식_5.0_revision1.3.hwp 와 정본 PDF 는 저장소에 있다.
  • 합성 생성기는 아래에 붙였다. 쓰는 템플릿은 저장소 공개 샘플 samples/issue1549_multipositive_float_tables.hwpx 뿐이다.
  • 생성물 empty-host.hwpx SHA-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, job c9fcdd5a-…
  • 한/글 재저장 HWP empty-host-hancom.hwp
    • SHA-256 4f1c6e5ada17…
    • 같은 MCP target: hwp, engine: 2020
  • 두 바이너리는 CLI 로 이슈에 올릴 수 없다. 필요하면 메인터너나 작성자가 첨부한다. 위 생성기와 같은 MCP 로 다시 만들 수 있다.

관련 증상

이전에 정상 동작한 버전

모름 (확인한 devel 에서는 모두 재현)

Activity

  1. planet6897 commented on Oct 17, 2026

    @planet6897
    ContributorAuthor

    착수합니다. 한글문서파일형식 19쪽(pi=130~133)의 빈 host 다중 자리차지 표 틈 채움(#7687)과 양수 오프셋 둘째 표 뒤 host 글(#7680)을 한 규칙으로 봅니다 — 양수 오프셋 표는 흐름을 전진시키지 않는 배제 띠이고, 띠에 걸리는 줄만 띠 아래로 간다. 정본 pdf/한글문서파일형식_5.0_revision1.3-hancom-official.pdf 20쪽을 기준으로 측정·배치가 같은 결과를 쓰게 고칩니다.

  2. planet6897 commented on Oct 17, 2026

    @planet6897
    ContributorAuthor

    PR #8005 로 제출했다 (head 839af8c92, base f9d2ab17a). #7680 과 함께 닫는다.

    • 규칙: 빈·공백 host 의 자리차지 표 둘(첫 오프셋 0, 둘째 양수) → 첫 표는 띠 끝까지 흐름 전진, host 줄 상자는 첫 띠 아래, 둘째 표는 흐름을 안 미는 배제 띠, 띠에 걸린 줄만 띠 아래(앞 간격 흡수). 근거는 hwpspec 구역 3 pi=130 저장 사다리(10810 → 12910 → 28197)와 합성 H1~H6 한컴 2020 Print PDF(pdf/issue7687/empty-host-2020.pdf).
    • hwpspec 19쪽: pi=130 host 줄 없음 → 247.7, pi=131 426.3(둘째 표 안) → 275.7, pi=132 454.3 → 479.5, 「참고문헌」 659.2 → 850.2(정본 849.5). 2px 실루엣 62.0 → 81.2(Native) / 81.8(WASM). 남은 차이는 글꼴·머리글 띠(이 문서는 수정 전부터 66쪽이 90% 미만, 쪽수 72/71 기존 차이).
    • 합성 H1~H6: Native·fresh WASM 전쪽 100.0, 정본 글자 윗변 ±0.3px.
    • 회귀 tests/cases/issue_7687_empty_host_multi_float_gap.rs 2건(수정 전 FAIL → 수정 후 PASS). 코퍼스 A/B 원장 546문서 변화 0, samples 1,252문서 anomaly 변화 0, 쪽수 변화 1건(시장구조조사 316 → 315 = 정본).
    • 범위 밖: 첫 표부터 양수 오프셋이거나 표 셋 이상인 빈 host 는 독립 근거가 없어 종전 lane 적층 유지.
  3. planet6897 commented on Oct 26, 2026

    @planet6897
    ContributorAuthor

    devel 반영 확인 — 닫습니다

    PR #8005 와 #8157 의 수정이 각각 #8148 과 #8228(3e02fe274)로 devel 에 들어갔습니다. devel 4e690c930 새 빌드로 다시 쟀습니다(Native native-skia·fresh WASM, --compat 2022, 96dpi, raw 실루엣, 글꼴 공급).

    • 회귀 검사 issue_7687_empty_host_multi_float_gap 2건 PASS:
      • synthetic_empty_host_cases_follow_hancom_gap_rule
      • titled_host_after_empty_host_band_keeps_its_float_offsets
    • 합성 fixture:
      • samples/issue7687/empty-host.hwpx ↔ empty-host-2020.pdf: 전 6쪽 Native·WASM 100
      • titled-host.hwpx ↔ titled-host-2020.pdf: 전 4쪽 최저 Native 94.70 · WASM 94.23
    • 공개 샘플 samples/한글문서파일형식_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).

    이 문서에는 이 이슈 범위 밖의 차이가 남아 있습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions