Skip to content

[renderer/layout] 쪽을 넘긴 자리차지 표의 마지막 조각 뒤 host 글줄을 typeset 이 예약하지 않고 layout 만 그리며 문단 앞 간격을 다시 더한다 — 정본은 바깥 아래 여백만 #7677

Closed

Description

@planet6897

사용 경로

CLI (export-svg / dump-pages), 렌더 트리

rhwp 버전 또는 접속 URL

devel f0e7228f6 (stream/devel, 2026-10-08)

운영체제

Linux

문서 형식

HWP (HWP5) — 한/글 2020 이 저장한 공개 합성 입력

표시 문제와 기대 결과

문단(PARA) 기준 자리차지(TOP_AND_BOTTOM) RowBreak 표가 쪽을 넘어 이어질 때, 마지막 표 조각 뒤의 host 글줄에 문단 앞 간격이 다시 더해지고, 조판(typeset)은 이 글줄을 아예 예약하지 않는다.

  • 기대(한/글 2020 Print PDF): host 첫 글줄은 마지막 표 조각 하단 + 표 바깥 아래 여백(852HU = 11.4px)에 선다. 앞 간격(1400HU = 18.7px)은 문단 상단에서 재는 거리라 앞 쪽에서 이미 소비됐다.
  • 실제(devel): 표 조각 하단 + 앞 간격(18.4px)에 선다(바깥 아래 여백 대신 앞 간격).
6쪽 (96dpi px, 쪽 원점) 한/글 정본 devel
마지막 표 조각 하단 362.5 361.4
host 첫 글줄 「S. 넘어간 표 조각 뒤 host 의 첫 글줄」 상단 373.8 (+11.3) 379.8 (+18.4)
다음 문단 「S 뒤 문단이다.」 395.2 401.1

측정·배치도 같은 결과를 쓰지 않는다. dump-pages 6쪽 항목은 PartialTable rows=10..20 cont=true 와 다음 문단뿐이고(used 288.0px = 표 조각 266.7 + 다음 문단 21.3), host 글줄은 typeset 항목에 없다. layout 만 표 조각 경로에서 이 글줄을 그린다.

재현 순서·문제 페이지

  1. 아래 생성기로 python3 make_float_push_spacing_before_boundaries.py --continuation out.hwpx (저장소 루트, 뼈대는 공개 샘플 samples/issue2527_empty_linesegs.hwpx).
  2. 한/글 2020(hwp2024Convert MCP engine 2020)으로 HWP 저장(sha256 56f74734327cd664…)하고 같은 엔진으로 Print PDF 출력(sha256 1839db526f608552…). 저장 LineSeg 는 한/글이 쓴 값이다.
  3. rhwp dump-pages out.hwp · rhwp export-render-tree out.hwp 6쪽: 위 표의 좌표.

S 형상: 앞 간격 1400HU host 문단(채움 문단 30개 뒤)이 20행(행 2000HU) PARA 기준 자리차지 표(바깥 여백 위 140/아래 852HU)를 품고 표 뒤에 글 한 줄이 있다. 표가 5쪽 아래에서 시작해 6쪽으로 넘어간다.

자료

공개 합성 입력 생성기 (make_float_push_spacing_before_boundaries.py, 저장소 루트에서 실행 — 뼈대는 공개 샘플 samples/issue2527_empty_linesegs.hwpx)
#!/usr/bin/env python3
"""공개 합성 입력 생성기 (#7330 float-sb 경계 재현용).

같은 문단의 문단(PARA) 기준 자리차지(TOP_AND_BOTTOM) 비-글자취급 표가 host 첫 글줄을 표 아래로
미는 형상의 **경계**를 쪽마다 하나씩 둔다. 기대 위치는 이 생성물을 한/글(hwp2024Convert MCP
engine 2020)로 HWP 저장한 저장 LineSeg 와 같은 엔진의 Print PDF 가 정한다.

- 1쪽 M: 앞 간격(4000HU)이 표 점유(한 행 1000HU, 바깥 여백 0)보다 큰 host — 글줄 원점이
  `문단 상단 + 앞 간격` 쪽으로 정해지는 경계. 대조: 같은 앞 간격의 표 없는 문단, 앞 간격 없는 host.
- 2쪽 T: 같은 형상의 host 가 쪽 나눔 뒤 쪽 맨 위에 있다(단 맨 위). T 뒤를 채움 문단으로
  채워, 측정이 첫 글줄 위치를 과소 계상하면 채움 문단의 쪽 소속이 달라지게 한다.
- 4쪽 L: 앞 간격(1400HU) host 의 표 뒤 글이 여러 줄이다 — 첫 글줄만 앞 간격 경계이고
  둘째 줄부터는 문단 안의 줄이다.

`--continuation` 을 주면 이어받기 경계 두 쪽을 더 붙인다(회귀 시험 입력이 아니라 기존 결함의
공개 재현용이다).
- S: 앞 간격(1400HU) host 의 여러 행 표가 쪽 아래에서 시작해 다음 쪽으로 넘어간다 —
  이어받은 표 조각 뒤의 host 글줄.
- C: L 과 같은 host 가 쪽 아래에 있어 표 뒤 글줄이 다음 쪽으로 이어진다(이어받은 줄).

뼈대(secPr·colPr·header·패키지 파일)는 공개 샘플 samples/issue2527_empty_linesegs.hwpx 에서
가져오고 rhwp 출처 표식(META-INF/rhwp-hwp5-origin)은 넣지 않는다. 생성물에는 linesegarray 가 없다.

사용: python3 samples/float_push_spacing_before/make_float_push_spacing_before_boundaries.py [--continuation] <출력.hwpx>
(저장소 루트에서)
"""
import re
import sys
import zipfile

src = zipfile.ZipFile('samples/issue2527_empty_linesegs.hwpx')
continuation = '--continuation' in sys.argv[1:]
out = [a for a in sys.argv[1:] if not a.startswith('--')][0]
hdr = src.read('Contents/header.xml').decode('utf8')
pp0 = re.search(r'<hh:paraPr id="0".*?</hh:paraPr>', hdr, re.S).group(0)
pp1 = pp0.replace('id="0"', 'id="1"').replace('<hc:prev value="0"', '<hc:prev value="1400"')
pp2 = pp0.replace('id="0"', 'id="2"').replace('<hc:prev value="0"', '<hc:prev value="4000"')
hdr = hdr.replace('<hh:paraProperties itemCnt="1">' + pp0,
                  '<hh:paraProperties itemCnt="3">' + pp0 + pp1 + pp2)
assert 'itemCnt="3">' in hdr
sec = src.read('Contents/section0.xml').decode('utf8')
head = sec[:sec.find('<hp:bookmark')]  # 첫 문단의 secPr·colPr 까지


def para(pid, runs, pp=0, page_break=False):
    return (f'<hp:p id="{pid}" paraPrIDRef="{pp}" styleIDRef="0" pageBreak="{1 if page_break else 0}" '
            f'columnBreak="0" merged="0">{runs}</hp:p>\n')


def t(text):
    return f'<hp:run charPrIDRef="0"><hp:t>{text}</hp:t></hp:run>'


def cell(c, r, w, h, text):
    return (f'<hp:tc name="" header="0" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="2">'
            f'<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" '
            f'linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">'
            + para(100000 + r * 10 + c, t(text)) +
            f'</hp:subList><hp:cellAddr colAddr="{c}" rowAddr="{r}"/><hp:cellSpan colSpan="1" rowSpan="1"/>'
            f'<hp:cellSz width="{w}" height="{h}"/><hp:cellMargin left="510" right="510" top="0" bottom="0"/></hp:tc>')


def table(tid, rows, row_h, margin):
    w = 48000 // 3
    top, bottom = margin
    trs = ''.join('<hp:tr>' + ''.join(cell(c, r, w, row_h, rows[r][c]) for c in range(3)) + '</hp:tr>'
                  for r in range(len(rows)))
    return (f'<hp:tbl id="{tid}" zOrder="{tid}" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" '
            f'lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="0" rowCnt="{len(rows)}" colCnt="3" '
            f'cellSpacing="0" borderFillIDRef="2" noAdjust="0">'
            f'<hp:sz width="{w * 3}" widthRelTo="ABSOLUTE" height="{row_h * len(rows)}" heightRelTo="ABSOLUTE" protect="0"/>'
            '<hp:pos treatAsChar="0" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" '
            'vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>'
            f'<hp:outMargin left="140" right="140" top="{top}" bottom="{bottom}"/>'
            '<hp:inMargin left="510" right="510" top="0" bottom="0"/>'
            + trs + '</hp:tbl>')


def host(pid, tid, rows, row_h, margin, text, pp, page_break=False):
    return para(pid, '<hp:run charPrIDRef="0">' + table(tid, rows, row_h, margin) + '</hp:run>' + t(text),
                pp=pp, page_break=page_break)


small = [['가', '1,000', '-']]
body = [head + '</hp:run>' + t('문단 앞 간격·자리차지 표 경계 합성 입력 (#7330)') + '</hp:p>\n',
        para(10, t('1. 표 앞 문단이다.')),
        host(20, 301, small, 1000, (0, 0), 'M. 앞 간격이 표 점유보다 큰 host 의 첫 글줄', pp=2),
        para(21, t('M 뒤 문단이다.')),
        para(22, t('대조: 같은 앞 간격의 표 없는 문단'), pp=2),
        para(23, t('대조 뒤 문단이다.')),
        host(24, 302, small, 1000, (0, 0), '대조: 앞 간격 없는 host 의 첫 글줄', pp=0),
        para(25, t('대조 host 뒤 문단이다.')),
        host(30, 303, small, 1000, (0, 0), 'T. 쪽 맨 위 host 의 첫 글줄 — 앞 간격이 표 점유보다 크다', pp=2,
             page_break=True),
        para(31, t('T 뒤 문단이다.'))]
# 2쪽: T 뒤를 채움 문단으로 채운다 — 첫 글줄 위치를 측정이 과소 계상하면 이 쪽에 문단이 더 남는다.
for i in range(42):
    body.append(para(200 + i, t(f'T 채움 {i + 1}')))
# L 쪽: 표 뒤 글이 여러 줄이다.
long_text = ('L. 표 뒤 글이 여러 줄인 host 문단이다. ' * 6).strip()
body.append(para(90, t('L 쪽 첫 문단이다.'), page_break=True))
body.append(host(91, 305, [['구분', '금액', '비고'], ['가', '1,000', '-']], 2000, (140, 852), long_text, pp=1))
body.append(para(92, t('L 뒤 문단이다.')))
body.append(para(93, t('끝.')))
if continuation:
    # S 쪽: 쪽 아래에서 시작해 넘어가는 표 — 이어받은 표 조각 뒤의 host 글줄.
    body.append(para(40, t('S 쪽 첫 문단이다.'), page_break=True))
    for i in range(30):
        body.append(para(41 + i, t(f'채움 문단 {i + 1}')))
    rows = [[f'{r + 1}행', f'{(r + 1) * 1000:,}', '-'] for r in range(20)]
    body.append(host(80, 304, rows, 2000, (140, 852), 'S. 넘어간 표 조각 뒤 host 의 첫 글줄', pp=1))
    body.append(para(81, t('S 뒤 문단이다.')))
    # C 쪽: 표 뒤 여러 줄 글이 쪽 끝을 넘어 다음 쪽으로 이어진다(이어받은 줄).
    body.append(para(300, t('C 쪽 첫 문단이다.'), page_break=True))
    for i in range(37):
        body.append(para(301 + i, t(f'C 채움 {i + 1}')))
    c_text = ('C. 쪽 끝을 넘는 host 문단이다. ' * 12).strip()
    body.append(host(340, 306, [['구분', '금액', '비고'], ['가', '1,000', '-']], 2000, (140, 852), c_text, pp=1))
    body.append(para(341, t('C 뒤 문단이다.')))
    body.append(para(342, t('끝.')))
sec_new = ''.join(body) + '</hs:sec>'


def put(z, name, data, stored=False):
    info = zipfile.ZipInfo(name, date_time=(2026, 10, 8, 0, 0, 0))
    info.compress_type = zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED
    z.writestr(info, data)


with zipfile.ZipFile(out, 'w') as z:
    put(z, 'mimetype', src.read('mimetype'), stored=True)
    for n in ('version.xml', 'Contents/content.hpf', 'META-INF/container.xml', 'META-INF/manifest.xml'):
        put(z, n, src.read(n))
    put(z, 'Contents/header.xml', hdr.encode('utf8'))
    put(z, 'Contents/section0.xml', sec_new.encode('utf8'))

Activity

  1. planet6897 commented on Oct 17, 2026

    @planet6897
    ContributorAuthor

    devel 6ed1682c3 재현 확인 — 착수한다

    저장소 생성기 make_float_push_spacing_before_boundaries.py --continuation 으로 HWPX 를 만들고(저장 줄 없음), 로컬 한/글 2024(13.0.0.564)로 HWP 저장(저장 줄 있음) + 같은 한/글 Print PDF 를 만들었다. 본문의 2020 MCP 수치와 정본 좌표가 같다.

    6쪽 (96dpi) 한/글 2024 Print devel 6ed1682c3 PR #7965 head
    마지막 표 조각 하단 362.5 363.2 363.2
    「S. 넘어간 표 조각 뒤 host 의 첫 글줄」 윗변 373.8 (+11.3) 381.7 (+18.5) 381.7
    「S 뒤 문단이다.」 395.2 403.0 403.0

    dump-pages 6쪽 항목은 여전히 PartialTable rows=10..20 cont=true 다음 바로 FullParagraph(다음 문단) 이라 host 줄은 layout 만 그린다. 저장 줄 없는 HWPX 경로도 같은 증상(+18.5)이다.

    수정 방향: typeset 이 마지막 조각을 커밋한 직후(흐름이 조각 하단 + 바깥 아래 여백에 선 시점)에 host 글줄을 PartialParagraph 로 예약하고 앞 간격 소비량 0 을 기록, layout 은 그 항목이 있으면 지연 host 렌더(앞 간격 재가산)를 하지 않는다. 쪽에 안 들어가는 경우(C 형, #7678)는 범위 밖으로 둔다.

  2. planet6897 commented on Oct 18, 2026

    @planet6897
    ContributorAuthor

    PR #8007 로 제출했다 (head 5413d4f7d, base 6ed1682c3). 이 이슈를 닫는다.

  3. planet6897 commented on Oct 22, 2026

    @planet6897
    ContributorAuthor

    devel 605ccfd92 재측정 — 이슈 규칙은 해소, 문서 잔여는 #8100 에서 추적하며 닫습니다

    • 수정 커밋: fc32ca502 (쪽을 넘긴 자리차지 표의 마지막 조각 뒤 host 글줄을 조판이 예약하고 배치는 바깥 아래 여백 자리에 둔다)
    • 회귀 검사 issue_7677_rowbreak_host_line_after_last_fragment (regression_suite_006, release-test): 3/3 통과
      • host_line_after_the_last_fragment_sits_at_outer_bottom_not_spacing_before ok
      • hwpx_without_stored_lines_follows_the_same_outer_bottom_rule ok
      • host_text_is_not_drawn_twice_across_the_split ok

    남은 범위: 원본 입력은 #7445 이관 자산(deferred-20261020)으로 옮겨졌고, 연속 표 7677·7678 의 전체 피델리티 잔여는 #8100 에서 추적합니다.

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