[버그·퇴행] 4.2.3 — 같은 문단에 inline 표가 있으면 float 표가 문단 텍스트보다 앞으로 방출됨 (4.2.0은 정상)
# [버그·퇴행] 4.2.3 — 같은 문단에 inline 표가 있으면 float 표가 문단 텍스트보다 앞으로 방출됨 (4.2.0은 정상)
#50 수정으로 inline 표 순서는 좋아졌는데, **float 표(`treatAsChar="0"`)가 얽힌 새 역전이 4.2.3에서 생겼습니다.** 4.2.0에서는 바르게 나오던 문서가 4.2.3에서 깨집니다.
- kordoc 버전: **4.2.3** (4.2.0과 대조 실측)
- Node.js: v24
- 입력: 외부 파일 불필요 — 최소 HWPX 직접 구성
## 증상
한 문단(`hp:p`)의 run 순서가 `[텍스트] [float 표] [inline 표]` 일 때:
| | 최상위 blocks 순서 |
|---|---|
| **4.2.0** | `paragraph(텍스트)` → `table(float)` → `table(inline)` ✅ |
| **4.2.3** | `table(float)` → `paragraph(텍스트)` → `table(inline)` ❌ |
**트리거는 같은 문단의 inline 표 존재입니다.** 같은 문단이 `[텍스트] [float 표]` 뿐이면(inline 표 없음) 4.2.3도 정상입니다(아래 변형 A). inline 표가 하나라도 있으면 float 표가 텍스트를 건너뛰고 앞으로 나옵니다(변형 B).
#50 답변에서 밝혀 주신 설계와도 어긋나는 동작입니다:
> 분할 방출은 `treatAsChar="1"` 인라인 표에만 적용하고, float·페이지 앵커 표는 … 흐름에 불참, **종전대로 텍스트 뒤에 방출**
관측된 동작은 "텍스트 뒤에 방출"이 아니라 **텍스트보다 앞으로 방출**입니다. 짐작으로는, 분할 방출 경로에서 float 표를 만나는 즉시 push하는 반면 문단 텍스트는 inline 표 직전에만 flush 되어, float 표가 그 사이를 추월하는 것으로 보입니다.
## 재현 (외부 파일 없이)
```js
import { zipSync, strToU8 } from 'fflate';
import { parseHwpx } from 'kordoc';
const tbl = (id, txt, treatAsChar) => `<hp:tbl id="${id}" zOrder="${id}" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="1" rowCnt="1" colCnt="1" cellSpacing="0" borderFillIDRef="3" noAdjust="0">
<hp:sz width="20000" widthRelTo="ABSOLUTE" height="2000" heightRelTo="ABSOLUTE" protect="0"/>
<hp:pos treatAsChar="${treatAsChar}" affectLSpacing="0" flowWithText="1" allowOverlap="${treatAsChar === '0' ? '1' : '0'}" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" vertAlign="TOP" horzAlign="LEFT" vertOffset="1000" horzOffset="0"/>
<hp:tr><hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="3">
<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">
<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0"><hp:t>${txt}</hp:t></hp:run></hp:p>
</hp:subList>
<hp:cellAddr colAddr="0" rowAddr="0"/><hp:cellSpan colSpan="1" rowSpan="1"/>
<hp:cellSz width="20000" height="2000"/><hp:cellMargin left="510" right="510" top="141" bottom="141"/>
</hp:tc></hp:tr>
</hp:tbl>`;
const mkSection = (runs) => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section">
<hp:p id="1" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">
${runs}
</hp:p>
</hs:sec>`;
const header = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" version="1.4" secCnt="1">
<hh:refList><hh:borderFills/><hh:charProperties/><hh:paraProperties/><hh:styles/></hh:refList>
</hh:head>`;
const container = `<?xml version="1.0" encoding="UTF-8"?>
<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container">
<ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/></ocf:rootfiles>
</ocf:container>`;
const hpf = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf/" version="" unique-identifier="" id="">
<opf:manifest>
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
</opf:manifest>
<opf:spine><opf:itemref idref="header"/><opf:itemref idref="section0"/></opf:spine>
</opf:package>`;
const version = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="1" buildNumber="0" os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="11, 0, 0, 7290"/>`;
async function run(name, runsXml) {
const zip = zipSync({
'mimetype': strToU8('application/hwp+zip'),
'version.xml': strToU8(version),
'META-INF/container.xml': strToU8(container),
'Contents/content.hpf': strToU8(hpf),
'Contents/header.xml': strToU8(header),
'Contents/section0.xml': strToU8(mkSection(runsXml)),
});
const r = await parseHwpx(Buffer.from(zip));
console.log(name, '→', JSON.stringify(r.blocks.map(b => b.type === 'table'
? 'table(' + (b.table.cells || []).flat().map(c => c && c.text || '').join('').replace(/\s+/g, '').slice(0, 12) + ')'
: b.type + '(' + String(b.text || '').replace(/\s+/g, '').slice(0, 12) + ')')));
}
await run('A: 텍스트 + float표',
`<hp:run charPrIDRef="0"><hp:t>신 청 서 제 목</hp:t></hp:run>
<hp:run charPrIDRef="0">${tbl(200, '일반현황', '0')}</hp:run>`);
await run('B: 텍스트 + float표 + inline표',
`<hp:run charPrIDRef="0"><hp:t>신 청 서 제 목</hp:t></hp:run>
<hp:run charPrIDRef="0">${tbl(300, '일반현황', '0')}</hp:run>
<hp:run charPrIDRef="0">${tbl(301, '접수번호', '1')}</hp:run>`);
```
**결과:**
```
[4.2.3]
A: 텍스트 + float표 → ["paragraph(신청서제목)","table(일반현황)"] ✅
B: 텍스트 + float표 + inline표 → ["table(일반현황)","paragraph(신청서제목)","table(접수번호)"] ❌ float 표가 텍스트 앞으로
[4.2.0]
A: 텍스트 + float표 → ["paragraph(신청서제목)","table(일반현황)"] ✅
B: 텍스트 + float표 + inline표 → ["paragraph(신청서제목)","table(일반현황)","table(접수번호)"] ✅
```
## 실제 문서에서의 영향
서식 문서의 별지 신청서 페이지가 정확히 이 구조입니다. 한 문단에 `[서식 제목 텍스트] [내용 표(float)] [접수번호 표(inline)]` 가 놓여 있습니다.
원문 렌더 좌표(같은 페이지): 제목 y=161, 표 첫 행 y=194 — **제목이 표 위**입니다. 4.2.0은 이 순서 그대로 내는데, 4.2.3은 서식 제목이 표를 건너뛰고 표 뒤로 밀립니다. 서식 문서에서 제목이 본문 표 아래로 가면 문서 구조가 어긋나 읽기 순서가 깨집니다.
저희 실문서 코퍼스 전수 재실측 기준, 4.2.0 → 4.2.3에서 이 유형의 순서 역전이 새로 생긴 문서가 확인됐고, 그 밖의 문서는 출력 동일 또는 개선이었습니다.
issue