hwpkit-dev 0.0.2 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/ .npmignore +4 -1
  2. package/README.md +44 -7
  3. package/dist/index.d.mts +46 -16
  4. package/dist/index.d.ts +46 -16
  5. package/dist/index.js +3964 -1227
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +3964 -1227
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -1
  10. package/playground/index.html +346 -0
  11. package/playground/main.ts +302 -0
  12. package/playground/vite.config.ts +16 -0
  13. package/src/contract/decoder.ts +1 -0
  14. package/src/contract/encoder.ts +6 -1
  15. package/src/core/BaseDecoder.ts +118 -0
  16. package/src/core/BaseEncoder.ts +146 -0
  17. package/src/decoders/docx/DocxDecoder.ts +743 -151
  18. package/src/decoders/html/HtmlDecoder.ts +366 -0
  19. package/src/decoders/hwp/HwpScanner.ts +478 -193
  20. package/src/decoders/hwpx/HwpxDecoder.ts +796 -297
  21. package/src/decoders/md/MdDecoder.ts +4 -4
  22. package/src/encoders/docx/DocxEncoder.ts +549 -240
  23. package/src/encoders/html/HtmlEncoder.ts +17 -19
  24. package/src/encoders/hwp/HwpEncoder.ts +1643 -890
  25. package/src/encoders/hwpx/HwpxEncoder.ts +1626 -472
  26. package/src/encoders/hwpx/constants.ts +148 -0
  27. package/src/encoders/hwpx/utils.ts +198 -0
  28. package/src/encoders/md/MdEncoder.ts +20 -15
  29. package/src/model/builders.ts +4 -4
  30. package/src/model/doc-props.ts +24 -10
  31. package/src/model/doc-tree.ts +13 -5
  32. package/src/pipeline/Pipeline.ts +7 -3
  33. package/src/pipeline/registry.ts +13 -2
  34. package/src/safety/StyleBridge.ts +51 -6
  35. package/src/toolkit/ArchiveKit.ts +56 -0
  36. package/src/toolkit/StyleMapper.ts +221 -0
  37. package/src/toolkit/UnitConverter.ts +138 -0
  38. package/src/toolkit/XmlKit.ts +0 -5
  39. package/hwp-analyze.ts +0 -90
  40. package/inspect-doc.ts +0 -57
  41. package/output_test.hwp +0 -0
  42. package/test-docx-to-hwp.ts +0 -45
@@ -1,4 +1,13 @@
1
- import type { Encoder } from "../../contract/encoder";
1
+ /**
2
+ * HwpxEncoder — DocRoot → HWPX (ZIP + XML)
3
+ *
4
+ * ANYTOHWP에서 영감받은 개선 사항:
5
+ * 1. LangFontBank — 7개 언어 그룹 독립 폰트 레지스트리 (HANGUL/LATIN/HANJA/…)
6
+ * 2. BorderFillBank — 정확한 ID 관리 (하드코딩 "1" 제거)
7
+ * 3. readPixelDims — PNG/JPEG 바이너리 헤더에서 실제 픽셀 치수 추출
8
+ * 4. 두 패스 구조 — Pre-scan(등록) → Encode(생성)
9
+ */
10
+
2
11
  import type {
3
12
  DocRoot,
4
13
  ParaNode,
@@ -8,9 +17,12 @@ import type {
8
17
  ImgNode,
9
18
  SheetNode,
10
19
  CellNode,
20
+ LinkNode,
11
21
  } from "../../model/doc-tree";
12
22
  import type { Outcome } from "../../contract/result";
23
+ import { BaseEncoder } from "../../core/BaseEncoder";
13
24
  import type {
25
+ DocMeta,
14
26
  PageDims,
15
27
  TextProps,
16
28
  ParaProps,
@@ -23,8 +35,9 @@ import { Metric, safeFontToKr } from "../../safety/StyleBridge";
23
35
  import { ArchiveKit } from "../../toolkit/ArchiveKit";
24
36
  import { TextKit } from "../../toolkit/TextKit";
25
37
  import { registry } from "../../pipeline/registry";
38
+ import { HWPX_MIME_TYPE } from "./constants";
26
39
 
27
- // ─── All HWPX namespaces ────────────────────────────────────
40
+ // ─── HWPX 네임스페이스 ──────────────────────────────────────
28
41
  const NS = [
29
42
  'xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app"',
30
43
  'xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"',
@@ -36,77 +49,386 @@ const NS = [
36
49
  'xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page"',
37
50
  'xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"',
38
51
  'xmlns:dc="http://purl.org/dc/elements/1.1/"',
39
- 'xmlns:opf="http://www.idpf.org/2007/opf/"',
52
+ 'xmlns:opf="http://www.idpf.org/2007/opf"',
40
53
  'xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart"',
41
- 'xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar"',
42
54
  'xmlns:epub="http://www.idpf.org/2007/ops"',
43
55
  'xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"',
44
56
  ].join(" ");
45
57
 
46
- // ─── Registries for IDRef system ────────────────────────────
58
+ // ─── LinesegArray Flags 상수 (HWPX 스펙) ─────────────────
59
+ // 첫 번째 lineseg: 0x160000 = 1441792 (시작 줄, 고정 위치)
60
+ // 이후 lineseg: 0x60000 = 393216 (일반 줄)
61
+ const LINESEG_FLAGS_FIRST = 0x160000; // 1441792 - 첫 줄 (시작, 고정)
62
+ const LINESEG_FLAGS_OTHER = 0x60000; // 393216 - 이후 줄
63
+
64
+ // ─── ANYTOHWP 영감: 언어별 폰트 레지스트리 ─────────────────
65
+ // 7개 언어 그룹을 독립적으로 관리 — charPr fontRef의 정확한 ID 생성
66
+ const LANG_GROUPS = [
67
+ "HANGUL",
68
+ "LATIN",
69
+ "HANJA",
70
+ "JAPANESE",
71
+ "OTHER",
72
+ "SYMBOL",
73
+ "USER",
74
+ ] as const;
75
+ type LangGroup = (typeof LANG_GROUPS)[number];
76
+
77
+ class LangFontBank {
78
+ // 언어 그룹별 독립 폰트 맵: face → localId (0-based)
79
+ private maps = new Map<LangGroup, Map<string, number>>(
80
+ LANG_GROUPS.map((g) => [g, new Map<string, number>()]),
81
+ );
82
+
83
+ constructor() {
84
+ // ANYTOHWP 기본값: 모든 그룹에 한컴 기본 폰트 등록 (id=0)
85
+ this.registerAll("함초롬바탕");
86
+ }
87
+
88
+ /** 모든 언어 그룹에 동일 폰트 등록 */
89
+ registerAll(face: string): void {
90
+ for (const g of LANG_GROUPS) {
91
+ const m = this.maps.get(g)!;
92
+ if (!m.has(face)) m.set(face, m.size);
93
+ }
94
+ }
95
+
96
+ /** 특정 언어 그룹에 폰트 등록, 이미 있으면 기존 ID 반환 */
97
+ register(lang: LangGroup, face: string): number {
98
+ const m = this.maps.get(lang)!;
99
+ if (m.has(face)) return m.get(face)!;
100
+ const id = m.size;
101
+ m.set(face, id);
102
+ return id;
103
+ }
104
+
105
+ /** 폰트 이름 → 한글 폰트 여부 판별 (ANYTOHWP 방식) */
106
+ private isKorean(face: string): boolean {
107
+ return (
108
+ /[\uAC00-\uD7A3\u3131-\u318E]/.test(face) ||
109
+ ["맑은", "나눔", "굴림", "돋움", "바탕", "함초롬", "한컴", "HY"].some(
110
+ (k) => face.includes(k),
111
+ )
112
+ );
113
+ }
114
+
115
+ /** TextProps.font 문자열에서 적절한 HANGUL/LATIN 그룹에 등록 */
116
+ registerFont(rawFace: string): { hangulId: number; latinId: number } {
117
+ const face = safeFontToKr(rawFace) || "함초롬바탕";
118
+ const isKor = this.isKorean(face);
119
+ // 한글 폰트: HANGUL/HANJA/JAPANESE/OTHER/SYMBOL/USER에 등록
120
+ // 라틴 폰트: LATIN에 등록, 나머지는 기본값(0) 유지
121
+ const hangulId = this.register("HANGUL", isKor ? face : "함초롬바탕");
122
+ const latinId = this.register("LATIN", isKor ? "함초롬바탕" : face);
123
+ for (const g of [
124
+ "HANJA",
125
+ "JAPANESE",
126
+ "OTHER",
127
+ "SYMBOL",
128
+ "USER",
129
+ ] as LangGroup[]) {
130
+ this.register(g, isKor ? face : "함초롬바탕");
131
+ }
132
+ return { hangulId, latinId };
133
+ }
134
+
135
+ /** 언어 그룹별 폰트 목록 반환 */
136
+ getFaces(lang: LangGroup): string[] {
137
+ return [...this.maps.get(lang)!.keys()];
138
+ }
139
+
140
+ getId(lang: LangGroup, face: string): number {
141
+ return this.maps.get(lang)!.get(face) ?? 0;
142
+ }
143
+
144
+ /** hh:fontfaces XML 생성 */
145
+ toXml(): string {
146
+ let xml = `<hh:fontfaces itemCnt="${LANG_GROUPS.length}">`;
147
+ for (const lang of LANG_GROUPS) {
148
+ const faces = this.getFaces(lang);
149
+ xml += `<hh:fontface lang="${lang}" fontCnt="${faces.length}">`;
150
+ faces.forEach((face, i) => {
151
+ xml +=
152
+ `<hh:font id="${i}" face="${esc(face)}" type="TTF" isEmbedded="0">` +
153
+ `<hh:typeInfo familyType="FCAT_UNKNOWN" weight="0" proportion="0" contrast="0" strokeVariation="0" armStyle="0" letterform="0" midline="252" xHeight="255"/>` +
154
+ `</hh:font>`;
155
+ });
156
+ xml += `</hh:fontface>`;
157
+ }
158
+ return xml + `</hh:fontfaces>`;
159
+ }
160
+ }
161
+
162
+ // ─── ANYTOHWP 영감: BorderFill 레지스트리 ───────────────────
163
+ // 하드코딩 "1" 제거 — 모든 셀/표의 실제 테두리를 추적
164
+
165
+ const KIND_MAP: Record<string, string> = {
166
+ solid: "SOLID",
167
+ dash: "DASH",
168
+ dot: "DOT",
169
+ double: "DOUBLE",
170
+ none: "NONE",
171
+ dash_dot: "DASH_DOT",
172
+ dash_dot_dot: "DASH_DOT_DOT",
173
+ };
174
+
175
+ /**
176
+ * 테두리 선 굵기 mm 값을 한글 표준 규격 리스트 중 가장 가까운 값으로 매핑(양자화)합니다.
177
+ */
178
+ function quantizeBorderWidth(pt: number): string {
179
+ const mm = pt * 0.3528;
180
+ const standardWidths = [0.1, 0.12, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0];
181
+ let closest = standardWidths[0];
182
+ let minDiff = Math.abs(mm - closest);
183
+ for (let i = 1; i < standardWidths.length; i++) {
184
+ const diff = Math.abs(mm - standardWidths[i]);
185
+ if (diff < minDiff) {
186
+ minDiff = diff;
187
+ closest = standardWidths[i];
188
+ }
189
+ }
190
+ let str = closest.toFixed(2);
191
+ if (str.endsWith("0")) {
192
+ str = str.slice(0, -1);
193
+ }
194
+ if (str.endsWith(".0")) {
195
+ str = str.slice(0, -2);
196
+ }
197
+ return `${str} mm`;
198
+ }
199
+
200
+ class BorderFillBank {
201
+ private fills: { id: number; xml: string }[] = [];
202
+ private keyMap = new Map<string, number>();
203
+
204
+ constructor() {
205
+ // id=1: 기본 (테두리 없음) — ANYTOHWP의 기본 초기화 방식
206
+ this._addXml(
207
+ this._buildXml(undefined, undefined, undefined, undefined, undefined),
208
+ );
209
+ // id=2: 표 기본 테두리 (solid 0.5pt black)
210
+ const defS: Stroke = { kind: "solid", pt: 0.5, color: "000000" };
211
+ this._addXml(this._buildXml(defS, defS, defS, defS, undefined));
212
+ }
213
+
214
+ private _strokeXml(tag: string, s?: Stroke): string {
215
+ const type =
216
+ s && s.kind !== "none" ? (KIND_MAP[s.kind] ?? "SOLID") : "NONE";
217
+ const w =
218
+ s && s.kind !== "none" ? quantizeBorderWidth(s.pt) : "0.12 mm";
219
+ const c = s
220
+ ? s.color.startsWith("#")
221
+ ? s.color
222
+ : `#${s.color}`
223
+ : "#000000";
224
+ return `<hh:${tag} type="${type}" width="${w}" color="${c}"/>`;
225
+ }
226
+
227
+ private _buildXml(
228
+ top?: Stroke,
229
+ right?: Stroke,
230
+ bottom?: Stroke,
231
+ left?: Stroke,
232
+ bg?: string,
233
+ ): string {
234
+ const fill = bg
235
+ ? `<hc:fillBrush><hc:winBrush faceColor="${bg.startsWith("#") ? bg : "#" + bg}" hatchColor="none" alpha="0"/></hc:fillBrush>`
236
+ : "";
237
+ return (
238
+ `<hh:borderFill id="__ID__" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">` +
239
+ `<hh:slash type="NONE" Crooked="0" isCounter="0"/>` +
240
+ `<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>` +
241
+ this._strokeXml("leftBorder", left) +
242
+ this._strokeXml("rightBorder", right) +
243
+ this._strokeXml("topBorder", top) +
244
+ this._strokeXml("bottomBorder", bottom) +
245
+ `<hh:diagonal type="NONE" width="0.12 mm" color="#000000"/>` +
246
+ fill +
247
+ `</hh:borderFill>`
248
+ );
249
+ }
250
+
251
+ private _addXml(xml: string): number {
252
+ const id = this.fills.length + 1;
253
+ this.fills.push({ id, xml: xml.replace("__ID__", String(id)) });
254
+ return id;
255
+ }
256
+
257
+ private _key(
258
+ top?: Stroke,
259
+ right?: Stroke,
260
+ bottom?: Stroke,
261
+ left?: Stroke,
262
+ bg?: string,
263
+ ): string {
264
+ const sk = (s?: Stroke) =>
265
+ s ? `${s.kind}:${s.pt.toFixed(2)}:${s.color}` : "none";
266
+ return `${sk(top)}|${sk(right)}|${sk(bottom)}|${sk(left)}|${bg ?? ""}`;
267
+ }
268
+
269
+ /** 균일 테두리 등록 */
270
+ addUniform(s?: Stroke, bg?: string): number {
271
+ const key = this._key(s, s, s, s, bg);
272
+ if (this.keyMap.has(key)) return this.keyMap.get(key)!;
273
+ const id = this._addXml(this._buildXml(s, s, s, s, bg));
274
+ this.keyMap.set(key, id);
275
+ return id;
276
+ }
277
+
278
+ /** 방향별 테두리 등록 */
279
+ addPerSide(
280
+ top?: Stroke,
281
+ right?: Stroke,
282
+ bottom?: Stroke,
283
+ left?: Stroke,
284
+ bg?: string,
285
+ ): number {
286
+ const key = this._key(top, right, bottom, left, bg);
287
+ if (this.keyMap.has(key)) return this.keyMap.get(key)!;
288
+ const id = this._addXml(this._buildXml(top, right, bottom, left, bg));
289
+ this.keyMap.set(key, id);
290
+ return id;
291
+ }
292
+
293
+ /** CellProps에서 적절한 borderFill ID 계산 (하드코딩 "1" 완전 제거) */
294
+ addFromCellProps(cp: CellProps, defStroke?: Stroke): number {
295
+ const d = defStroke ?? DEFAULT_STROKE;
296
+ const top = cp.top ?? d;
297
+ const right = cp.right ?? d;
298
+ const bottom = cp.bot ?? d;
299
+ const left = cp.left ?? d;
300
+ const bg = cp.bg;
301
+ const uniform =
302
+ top.kind === right.kind &&
303
+ top.kind === bottom.kind &&
304
+ top.kind === left.kind &&
305
+ top.pt === right.pt &&
306
+ top.pt === bottom.pt &&
307
+ top.pt === left.pt &&
308
+ top.color === right.color &&
309
+ top.color === bottom.color &&
310
+ top.color === left.color;
311
+ return uniform
312
+ ? this.addUniform(top, bg)
313
+ : this.addPerSide(top, right, bottom, left, bg);
314
+ }
315
+
316
+ toXml(): string {
317
+ return `<hh:borderFills itemCnt="${this.fills.length}">${this.fills.map((f) => f.xml).join("")}</hh:borderFills>`;
318
+ }
319
+ }
320
+
321
+ // ─── ANYTOHWP 영감: PNG/JPEG 바이너리 헤더에서 픽셀 치수 추출
322
+ function readPixelDims(
323
+ b64: string,
324
+ mime: string,
325
+ ): { w: number; h: number } | null {
326
+ try {
327
+ const raw = TextKit.base64Decode(b64);
328
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
329
+
330
+ if (mime.includes("png")) {
331
+ // PNG: 시그니처 8바이트 + IHDR 청크 길이(4) + 타입(4) + 너비(4) + 높이(4)
332
+ if (
333
+ raw.length >= 24 &&
334
+ view.getUint32(0) === 0x89504e47 &&
335
+ view.getUint32(4) === 0x0d0a1a0a
336
+ ) {
337
+ return { w: view.getUint32(16), h: view.getUint32(20) };
338
+ }
339
+ } else if (mime.includes("jpeg") || mime.includes("jpg")) {
340
+ // JPEG: SOI(FF D8) 후 SOF0(FF C0) 또는 SOF2(FF C2) 마커 탐색
341
+ let off = 2;
342
+ while (off < raw.length - 4) {
343
+ const marker = view.getUint16(off);
344
+ off += 2;
345
+ if (marker === 0xffc0 || marker === 0xffc2) {
346
+ // SOF: length(2) + precision(1) + height(2) + width(2)
347
+ return { w: view.getUint16(off + 5), h: view.getUint16(off + 3) };
348
+ }
349
+ if ((marker & 0xff00) !== 0xff00) break;
350
+ const segLen = view.getUint16(off);
351
+ off += segLen;
352
+ }
353
+ }
354
+ } catch {
355
+ /* 무시 */
356
+ }
357
+ return null;
358
+ }
359
+
360
+ // ─── charPr / paraPr 레지스트리 ─────────────────────────────
47
361
 
48
362
  interface CharPrDef {
49
363
  id: number;
50
- height: number;
364
+ height: number; // HWPX height 단위 (1000 = 10pt)
51
365
  bold: boolean;
52
366
  italic: boolean;
53
- underline: string;
54
- strikeout: string;
55
- textColor: string;
56
- fontName: string;
57
- fontId: number;
367
+ underline: string; // "NONE" | "BOTTOM"
368
+ strikeout: string; // "NONE" | "SOLID"
369
+ textColor: string; // "#RRGGBB"
370
+ hangulId: number; // HANGUL 그룹 폰트 ID
371
+ latinId: number; // LATIN 그룹 폰트 ID
58
372
  bg?: string;
59
373
  }
60
-
61
374
  interface ParaPrDef {
62
375
  id: number;
63
376
  align: string;
377
+ leftHwp: number;
378
+ rightHwp: number;
379
+ intentHwp: number;
380
+ prevHwp: number;
381
+ nextHwp: number;
382
+ lineSpacing: number;
383
+ lineSpacingFixed?: number; // HWPUNIT, type=FIXED
64
384
  listType?: string;
65
385
  listLevel?: number;
66
- intentHwp: number; // first-line indent in HWPUNIT
67
- prevHwp: number; // space before paragraph in HWPUNIT
68
- nextHwp: number; // space after paragraph in HWPUNIT
69
- lineSpacing: number; // line spacing percentage (e.g., 160 = 160%)
386
+ verAlign?: string;
387
+ lineWrap?: string;
388
+ }
389
+ interface StyleEntry {
390
+ id: number;
391
+ name: string;
392
+ engName: string;
393
+ paraPrIDRef: number;
394
+ charPrIDRef: number;
70
395
  }
71
396
 
72
397
  interface BinEntry {
73
- id: string;
74
- name: string;
398
+ id: string; // "BIN0001"
399
+ name: string; // "BIN0001.png"
75
400
  data: Uint8Array;
76
401
  }
77
402
 
403
+ function charPrKey(p: TextProps): string {
404
+ return `${p.b ? 1 : 0}|${p.i ? 1 : 0}|${p.u ? 1 : 0}|${p.s ? 1 : 0}|${p.pt ?? 10}|${p.color ?? "000000"}|${p.font ?? ""}|${p.bg ?? ""}`;
405
+ }
406
+
407
+ /**
408
+ * ParaProps 를 해시 키로 변환 (동일 포맷팅 감지용)
409
+ * null/undefined는 0 으로 처리하여 일관성 유지
410
+ */
411
+ function paraPrKey(p: ParaProps): string {
412
+ return `${p.align ?? "left"}|${p.verAlign ?? "baseline"}|${p.lineWrap ?? "break"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.leftMargin ?? 0}|${p.indentRightPt ?? 0}|${p.firstLineIndentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}|${p.lineHeightFixed ?? 0}|${p.styleId ?? ""}`;
413
+ }
414
+
415
+ // ─── 인코딩 컨텍스트 ─────────────────────────────────────────
416
+
78
417
  interface HwpxCtx {
418
+ fontBank: LangFontBank;
419
+ borderFillBank: BorderFillBank;
79
420
  charPrs: CharPrDef[];
80
421
  charPrMap: Map<string, number>;
81
422
  paraPrs: ParaPrDef[];
82
423
  paraPrMap: Map<string, number>;
83
- borderFills: { id: number; xml: string }[];
84
424
  bins: BinEntry[];
85
425
  nextBinNum: number;
86
426
  nextElementId: number;
87
- availableWidth: number; // HWPUNIT — page width minus margins
88
- fonts: string[];
89
- fontMap: Map<string, number>;
90
- imgMap: WeakMap<ImgNode, string>; // ImgNode → binId (no mutation)
91
- nextZOrder: number; // monotonically increasing z-order for images/objects
92
- }
93
-
94
- function charPrKey(p: TextProps): string {
95
- return `${p.b ? 1 : 0}|${p.i ? 1 : 0}|${p.u ? 1 : 0}|${p.s ? 1 : 0}|${p.pt ?? 10}|${p.color ?? "000000"}|${p.font ?? ""}|${p.bg ?? ""}`;
96
- }
97
-
98
- function paraPrKey(p: ParaProps): string {
99
- return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}`;
100
- }
101
-
102
- function registerFont(name: string, ctx: HwpxCtx): number {
103
- const n = name || "굴림체";
104
- const existing = ctx.fontMap.get(n);
105
- if (existing !== undefined) return existing;
106
- const id = ctx.fonts.length;
107
- ctx.fonts.push(n);
108
- ctx.fontMap.set(n, id);
109
- return id;
427
+ availableWidth: number; // HWPUNIT
428
+ imgMap: WeakMap<ImgNode, string>;
429
+ nextZOrder: number;
430
+ styleIdToHwpxId: Map<string, number>;
431
+ hwpxStyles: StyleEntry[];
110
432
  }
111
433
 
112
434
  function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
@@ -114,11 +436,11 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
114
436
  const existing = ctx.charPrMap.get(key);
115
437
  if (existing !== undefined) return existing;
116
438
 
117
- const fontName = safeFontToKr(props.font) || "굴림체";
118
- const fontId = registerFont(fontName, ctx);
119
-
439
+ const rawFont = props.font ?? "함초롬바탕";
440
+ const { hangulId, latinId } = ctx.fontBank.registerFont(rawFont);
120
441
  const id = ctx.charPrs.length;
121
- const def: CharPrDef = {
442
+
443
+ ctx.charPrs.push({
122
444
  id,
123
445
  height: Metric.ptToHHeight(props.pt ?? 10),
124
446
  bold: !!props.b,
@@ -126,28 +448,59 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
126
448
  underline: props.u ? "BOTTOM" : "NONE",
127
449
  strikeout: props.s ? "SOLID" : "NONE",
128
450
  textColor: props.color ? `#${props.color}` : "#000000",
129
- fontName,
130
- fontId,
451
+ hangulId,
452
+ latinId,
131
453
  bg: props.bg,
132
- };
133
- ctx.charPrs.push(def);
454
+ });
134
455
  ctx.charPrMap.set(key, id);
135
456
  return id;
136
457
  }
137
458
 
459
+ const ALIGN_MAP: Record<string, string> = {
460
+ left: "LEFT",
461
+ center: "CENTER",
462
+ right: "RIGHT",
463
+ justify: "JUSTIFY",
464
+ distribute: "DISTRIBUTE",
465
+ distribute_space: "DISTRIBUTE_SPACE",
466
+ };
467
+
468
+ const V_ALIGN_MAP: Record<string, string> = {
469
+ baseline: "BASELINE",
470
+ top: "TOP",
471
+ center: "CENTER",
472
+ bottom: "BOTTOM",
473
+ };
474
+
475
+ const LINE_WRAP_MAP: Record<string, string> = {
476
+ break: "BREAK",
477
+ squeeze: "SQUEEZE",
478
+ keep: "KEEP",
479
+ };
480
+
138
481
  function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
139
482
  const key = paraPrKey(props);
140
483
  const existing = ctx.paraPrMap.get(key);
141
484
  if (existing !== undefined) return existing;
142
485
 
143
486
  const id = ctx.paraPrs.length;
487
+
488
+ const alignStr = props.align ? (ALIGN_MAP[props.align] ?? "LEFT") : "LEFT";
489
+ const verAlignStr = props.verAlign ? (V_ALIGN_MAP[props.verAlign] ?? "BASELINE") : "BASELINE";
490
+ const lineWrapStr = props.lineWrap ? (LINE_WRAP_MAP[props.lineWrap] ?? "BREAK") : "BREAK";
491
+
144
492
  const def: ParaPrDef = {
145
493
  id,
146
- align: (props.align ?? "left").toUpperCase(),
147
- intentHwp: props.indentPt ? Metric.ptToHwp(props.indentPt) : 0,
148
- prevHwp: props.spaceBefore ? Metric.ptToHwp(props.spaceBefore) : 0,
149
- nextHwp: props.spaceAfter ? Metric.ptToHwp(props.spaceAfter) : 0,
150
- lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
494
+ align: alignStr,
495
+ verAlign: verAlignStr,
496
+ lineWrap: lineWrapStr,
497
+ leftHwp: Metric.ptToHwp(props.leftMargin ?? 0),
498
+ rightHwp: Metric.ptToHwp(props.indentRightPt ?? 0),
499
+ intentHwp: Metric.ptToHwp(props.firstLineIndentPt ?? 0),
500
+ prevHwp: Metric.ptToHwp(props.spaceBefore ?? 0),
501
+ nextHwp: Metric.ptToHwp(props.spaceAfter ?? 0),
502
+ lineSpacing: props.lineHeightFixed ? 0 : (props.lineHeight ? Math.round(props.lineHeight * 100) : 160),
503
+ lineSpacingFixed: props.lineHeightFixed ? Metric.ptToHwp(props.lineHeightFixed) : undefined,
151
504
  };
152
505
  if (props.listOrd !== undefined) {
153
506
  def.listType = props.listOrd ? "DIGIT" : "BULLET";
@@ -158,33 +511,7 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
158
511
  return id;
159
512
  }
160
513
 
161
- // ─── Pre-scan: collect all charPr/paraPr used ───────────────
162
-
163
- function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
164
- for (const kid of kids) {
165
- if (kid.tag === "para") scanPara(kid, ctx);
166
- else if (kid.tag === "grid") scanGrid(kid, ctx);
167
- }
168
- }
169
-
170
- function scanPara(para: ParaNode, ctx: HwpxCtx): void {
171
- registerParaPr(para.props, ctx);
172
- for (const kid of para.kids) {
173
- if (kid.tag === "span") registerCharPr(kid.props, ctx);
174
- else if (kid.tag === "img") registerImage(kid, ctx);
175
- }
176
- }
177
-
178
- function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
179
- for (const row of grid.kids)
180
- for (const cell of row.kids) for (const p of cell.kids) scanPara(p, ctx);
181
- }
182
-
183
- function scanParas(paras: ParaNode[], ctx: HwpxCtx): void {
184
- for (const p of paras) scanPara(p, ctx);
185
- }
186
-
187
- // ─── Image handling ─────────────────────────────────────────
514
+ // ─── 이미지 등록 ─────────────────────────────────────────────
188
515
 
189
516
  function mimeToExt(mime: string): string {
190
517
  if (mime.includes("jpeg")) return "jpg";
@@ -204,170 +531,261 @@ function registerImage(img: ImgNode, ctx: HwpxCtx): void {
204
531
  ctx.imgMap.set(img, id);
205
532
  }
206
533
 
207
- // ─── BorderFill ─────────────────────────────────────────────
534
+ // ─── 스타일 등록 ─────────────────────────────────────────────
535
+
536
+ const STYLE_NAME_MAP: Record<string, string> = {
537
+ Normal: "바탕글",
538
+ "Heading 1": "개요 1",
539
+ "Heading 2": "개요 2",
540
+ "Heading 3": "개요 3",
541
+ "Heading 4": "개요 4",
542
+ "Heading 5": "개요 5",
543
+ "Heading 6": "개요 6",
544
+ "Body Text": "본문",
545
+ };
208
546
 
209
- function addBorderFill(
547
+ function registerStyle(
548
+ styleId: string,
549
+ paraPrId: number,
550
+ charPrId: number,
210
551
  ctx: HwpxCtx,
211
- stroke?: Stroke,
212
- bgColor?: string,
213
- ): number {
214
- const id = ctx.borderFills.length + 1;
215
- const s = stroke ?? DEFAULT_STROKE;
216
- const kindMap: Record<string, string> = {
217
- solid: "SOLID",
218
- dash: "DASH",
219
- dot: "DOT",
220
- double: "DOUBLE",
221
- none: "NONE",
222
- };
223
- const type = kindMap[s.kind] ?? "SOLID";
224
- const w = `${(s.pt * 0.3528).toFixed(2)} mm`;
225
- const c = s.color.startsWith("#") ? s.color : `#${s.color}`;
226
-
227
- let fill = "";
228
- if (bgColor) {
229
- const bc = bgColor.startsWith("#") ? bgColor : `#${bgColor}`;
230
- fill = `<hc:fillBrush><hc:winBrush faceColor="${bc}" hatchColor="none" alpha="0"/></hc:fillBrush>`;
552
+ ): void {
553
+ if (!styleId || ctx.styleIdToHwpxId.has(styleId)) return;
554
+ if (styleId === "Normal") {
555
+ ctx.styleIdToHwpxId.set(styleId, 0);
556
+ return;
231
557
  }
232
-
233
- const xml = `<hh:borderFill id="${id}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/><hh:leftBorder type="${type}" width="${w}" color="${c}"/><hh:rightBorder type="${type}" width="${w}" color="${c}"/><hh:topBorder type="${type}" width="${w}" color="${c}"/><hh:bottomBorder type="${type}" width="${w}" color="${c}"/><hh:diagonal type="NONE" width="0.12 mm" color="#000000"/>${fill}</hh:borderFill>`;
234
- ctx.borderFills.push({ id, xml });
235
- return id;
558
+ const hwpxId = ctx.hwpxStyles.length;
559
+ ctx.styleIdToHwpxId.set(styleId, hwpxId);
560
+ ctx.hwpxStyles.push({
561
+ id: hwpxId,
562
+ name: STYLE_NAME_MAP[styleId] ?? styleId,
563
+ engName: "",
564
+ paraPrIDRef: paraPrId,
565
+ charPrIDRef: charPrId,
566
+ });
236
567
  }
237
568
 
238
- function addBorderFillPerSide(
239
- ctx: HwpxCtx,
240
- top?: Stroke,
241
- right?: Stroke,
242
- bottom?: Stroke,
243
- left?: Stroke,
244
- bgColor?: string,
245
- ): number {
246
- const id = ctx.borderFills.length + 1;
247
- const kindMap: Record<string, string> = {
248
- solid: "SOLID", dash: "DASH", dot: "DOT", double: "DOUBLE", none: "NONE",
249
- };
250
- function sideXml(tag: string, s?: Stroke): string {
251
- const type = s ? (kindMap[s.kind] ?? "SOLID") : "NONE";
252
- const w = s ? `${(s.pt * 0.3528).toFixed(2)} mm` : "0.12 mm";
253
- const c = s ? (s.color.startsWith("#") ? s.color : `#${s.color}`) : "#000000";
254
- return `<hh:${tag} type="${type}" width="${w}" color="${c}"/>`;
569
+ // ─── Pre-scan: 콘텐츠 순회하며 모든 ID 사전 등록 ─────────────
570
+
571
+ function scanPara(para: ParaNode, ctx: HwpxCtx): void {
572
+ const paraPrId = registerParaPr(para.props, ctx);
573
+ let firstCharPrId = 0;
574
+ let hasFirstSpan = false;
575
+
576
+ function scanKids(kids: ParaNode["kids"]): void {
577
+ for (const kid of kids) {
578
+ if (kid.tag === "span") {
579
+ const cId = registerCharPr(kid.props, ctx);
580
+ if (!hasFirstSpan) {
581
+ firstCharPrId = cId;
582
+ hasFirstSpan = true;
583
+ }
584
+ } else if (kid.tag === "img") {
585
+ registerImage(kid, ctx);
586
+ } else if (kid.tag === "link") {
587
+ scanKids((kid as LinkNode).kids as ParaNode["kids"]);
588
+ }
589
+ }
255
590
  }
591
+ scanKids(para.kids);
592
+ if (para.props.styleId)
593
+ registerStyle(para.props.styleId, paraPrId, firstCharPrId, ctx);
594
+ }
256
595
 
257
- let fill = "";
258
- if (bgColor) {
259
- const bc = bgColor.startsWith("#") ? bgColor : `#${bgColor}`;
260
- fill = `<hc:fillBrush><hc:winBrush faceColor="${bc}" hatchColor="none" alpha="0"/></hc:fillBrush>`;
596
+ function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
597
+ const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
598
+ // 기본 테두리 사전 등록
599
+ ctx.borderFillBank.addUniform(defStroke);
600
+ for (const row of grid.kids) {
601
+ for (const cell of row.kids) {
602
+ ctx.borderFillBank.addFromCellProps(cell.props, defStroke);
603
+ for (const p of cell.kids) {
604
+ if (p.tag === 'grid') scanGrid(p, ctx);
605
+ else scanPara(p, ctx);
606
+ }
607
+ }
261
608
  }
609
+ }
262
610
 
263
- const xml = `<hh:borderFill id="${id}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/>${sideXml("leftBorder", left)}${sideXml("rightBorder", right)}${sideXml("topBorder", top)}${sideXml("bottomBorder", bottom)}<hh:diagonal type="NONE" width="0.12 mm" color="#000000"/>${fill}</hh:borderFill>`;
264
- ctx.borderFills.push({ id, xml });
265
- return id;
611
+ function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
612
+ for (const kid of kids) {
613
+ if (kid.tag === "para") scanPara(kid, ctx);
614
+ else if (kid.tag === "grid") scanGrid(kid, ctx);
615
+ }
266
616
  }
267
617
 
268
- // ─── Encoder class ──────────────────────────────────────────
618
+ // ─── Encoder 클래스 ──────────────────────────────────────────
269
619
 
270
- export class HwpxEncoder implements Encoder {
271
- readonly format = "hwpx";
620
+ export class HwpxEncoder extends BaseEncoder {
621
+ protected getFormat(): string { return "hwpx"; }
622
+ protected getAliases(): string[] { return [HWPX_MIME_TYPE, "application/hwp+zip"]; }
272
623
 
273
624
  async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
274
625
  try {
275
626
  const sheet = doc.kids[0];
276
627
  const dims = normalizeDims(sheet?.dims ?? A4);
277
628
 
278
- // Available width = page width - left margin - right margin (in HWPUNIT)
279
- const availableWidth =
629
+ const safeML = (dims.ml !== undefined && dims.ml >= 0) ? dims.ml : 70.87;
630
+ const safeMR = (dims.mr !== undefined && dims.mr >= 0) ? dims.mr : 70.87;
631
+ const availableWidth = Math.round(
280
632
  Metric.ptToHwp(dims.wPt) -
281
- Metric.ptToHwp(dims.ml) -
282
- Metric.ptToHwp(dims.mr);
633
+ Metric.ptToHwp(safeML) -
634
+ Metric.ptToHwp(safeMR),
635
+ );
283
636
 
637
+ // 컨텍스트 초기화
284
638
  const ctx: HwpxCtx = {
639
+ fontBank: new LangFontBank(), // ANYTOHWP 방식 언어별 폰트
640
+ borderFillBank: new BorderFillBank(), // 하드코딩 없는 테두리 관리
285
641
  charPrs: [],
286
642
  charPrMap: new Map(),
287
643
  paraPrs: [],
288
644
  paraPrMap: new Map(),
289
- borderFills: [],
290
645
  bins: [],
291
646
  nextBinNum: 1,
292
647
  nextElementId: 10000,
293
648
  availableWidth,
294
- fonts: [],
295
- fontMap: new Map(),
296
649
  imgMap: new WeakMap(),
297
650
  nextZOrder: 0,
651
+ styleIdToHwpxId: new Map(),
652
+ hwpxStyles: [],
298
653
  };
299
654
 
300
- // Default borderFill (id=1, no border)
301
- addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
302
- // Table border borderFill (id=2)
303
- addBorderFill(ctx, DEFAULT_STROKE);
304
- // Default no-border for text areas (id=3)
305
- addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
306
-
307
- // Register default charPr (id=0) and paraPr (id=0)
655
+ // id=0 기본 charPr/paraPr 등록
308
656
  registerCharPr({}, ctx);
309
657
  registerParaPr({}, ctx);
310
658
 
311
- // Pre-scan all content to collect charPr/paraPr/images
659
+ // 바탕글(Normal) 스타일 id=0으로 고정
660
+ ctx.hwpxStyles.push({
661
+ id: 0,
662
+ name: "바탕글",
663
+ engName: "Normal",
664
+ paraPrIDRef: 0,
665
+ charPrIDRef: 0,
666
+ });
667
+ ctx.styleIdToHwpxId.set("Normal", 0);
668
+
669
+ // 패스 1: Pre-scan — 모든 charPr/paraPr/이미지/테두리 사전 등록
312
670
  scanContent(sheet?.kids ?? [], ctx);
313
- if (sheet?.header) scanParas(sheet.header, ctx);
314
- if (sheet?.footer) scanParas(sheet.footer, ctx);
671
+ if (sheet?.headers?.default) for (const p of sheet.headers.default) scanPara(p, ctx);
672
+ if (sheet?.footers?.default) for (const p of sheet.footers.default) scanPara(p, ctx);
315
673
 
316
- // Extract plain text preview from document
674
+ // 패스 2: Encode section 먼저 (borderFill 동적 등록 완료 후 header 생성)
675
+ const sectionData = this.stringToBytes(buildSectionXml(sheet, dims, ctx));
676
+ const headerData = this.stringToBytes(buildHeaderXml(dims, doc.meta, ctx));
317
677
  const previewText = extractPreviewText(sheet);
318
678
 
319
- // IMPORTANT: Generate section XML FIRST so that borderFills created
320
- // during table encoding are registered in ctx before headerXml runs.
321
- const sectionData = TextKit.encode(sectionXml(sheet, dims, ctx));
322
- const headerData = TextKit.encode(headerXml(dims, doc.meta, ctx));
323
-
324
- const entries: { name: string; data: Uint8Array }[] = [
325
- { name: "mimetype", data: TextKit.encode("application/hwp+zip") },
326
- { name: "version.xml", data: TextKit.encode(VERSION_XML) },
327
- { name: "Contents/header.xml", data: headerData },
328
- { name: "Contents/section0.xml", data: sectionData },
329
- { name: "Preview/PrvText.txt", data: TextKit.encode(previewText) },
330
- { name: "settings.xml", data: TextKit.encode(SETTINGS_XML) },
331
- { name: "META-INF/container.rdf", data: TextKit.encode(CONTAINER_RDF) },
679
+ const entries: { name: string; data: Uint8Array; mime: string; compression?: 'STORE' | 'DEFLATE' }[] = [
680
+ {
681
+ name: "mimetype",
682
+ data: new TextEncoder().encode(HWPX_MIME_TYPE),
683
+ compression: "STORE",
684
+ mime: "",
685
+ },
686
+ {
687
+ name: "version.xml",
688
+ data: this.stringToBytes(VERSION_XML),
689
+ mime: "application/xml",
690
+ },
691
+ {
692
+ name: "META-INF/container.xml",
693
+ data: this.stringToBytes(CONTAINER_XML),
694
+ mime: "application/xml",
695
+ },
696
+ {
697
+ name: "META-INF/manifest.xml",
698
+ data: this.stringToBytes(MANIFEST_XML),
699
+ mime: "application/xml",
700
+ },
332
701
  {
333
702
  name: "Contents/content.hpf",
334
- data: TextKit.encode(contentHpf(ctx, doc.meta)),
703
+ data: this.stringToBytes(buildContentHpf(ctx, doc.meta)),
704
+ mime: "application/hwpml-package+xml",
705
+ },
706
+ {
707
+ name: "Contents/header.xml",
708
+ data: headerData,
709
+ mime: "application/xml",
710
+ },
711
+ {
712
+ name: "Contents/section0.xml",
713
+ data: sectionData,
714
+ mime: "application/xml",
715
+ },
716
+ {
717
+ name: "Preview/PrvText.txt",
718
+ data: this.stringToBytes(previewText),
719
+ mime: "text/plain",
720
+ },
721
+ {
722
+ name: "settings.xml",
723
+ data: this.stringToBytes(buildSettingsXml()),
724
+ mime: "application/xml",
335
725
  },
336
- { name: "META-INF/container.xml", data: TextKit.encode(CONTAINER_XML) },
337
- { name: "META-INF/manifest.xml", data: TextKit.encode(MANIFEST_XML) },
338
726
  ];
339
727
 
340
728
  for (const bin of ctx.bins) {
341
- entries.push({ name: `BinData/${bin.name}`, data: bin.data });
729
+ const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
730
+ const ct =
731
+ ext === "png"
732
+ ? "image/png"
733
+ : ext === "jpg" || ext === "jpeg"
734
+ ? "image/jpeg"
735
+ : ext === "gif"
736
+ ? "image/gif"
737
+ : "image/bmp";
738
+ entries.push({ name: `BinData/${bin.name}`, data: bin.data, mime: ct });
342
739
  }
343
740
 
344
- return succeed(await ArchiveKit.zip(entries));
741
+ return succeed(await this.zip(entries));
345
742
  } catch (e: any) {
346
- return fail(`HWPX encode error: ${e?.message ?? String(e)}`);
743
+ return fail(`HWPX 인코딩 오류: ${e?.message ?? String(e)}`);
347
744
  }
348
745
  }
349
746
  }
350
747
 
351
- // ─── Constants ──────────────────────────────────────────────
352
-
353
- const VERSION_XML = `<?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="0" buildNumber="1" os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="11, 0, 0, 0"/>`;
354
-
355
- const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"><ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/><ocf:rootfile full-path="Preview/PrvText.txt" media-type="text/plain"/><ocf:rootfile full-path="META-INF/container.rdf" media-type="application/rdf+xml"/></ocf:rootfiles></ocf:container>`;
356
-
357
- const CONTAINER_RDF = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/header.xml"/></rdf:Description><rdf:Description rdf:about="Contents/header.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#HeaderFile"/></rdf:Description><rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/section0.xml"/></rdf:Description><rdf:Description rdf:about="Contents/section0.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#SectionFile"/></rdf:Description><rdf:Description rdf:about=""><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#Document"/></rdf:Description></rdf:RDF>`;
358
-
359
- const MANIFEST_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>`;
360
-
361
- const SETTINGS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/><config:config-item-set name="PrintInfo"><config:config-item name="PrintMethod" type="short">0</config:config-item><config:config-item name="ZoomX" type="short">100</config:config-item><config:config-item name="ZoomY" type="short">100</config:config-item></config:config-item-set></ha:HWPApplicationSetting>`;
362
-
363
- function contentHpf(ctx: HwpxCtx, meta?: any): string {
748
+ // ─── 상수 XML ────────────────────────────────────────────────
749
+
750
+ // namespace: 실제 HWP가 기대하는 2011 버전 네임스페이스 사용 (owpml.org/2024는 열리지 않음)
751
+ const VERSION_XML =
752
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
753
+ `<hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" ` +
754
+ `tagetApplication="WORDPROCESSOR" major="5" minor="0" micro="5" buildNumber="0" ` +
755
+ `os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="9, 6, 1, 10097"/>`;
756
+
757
+ // container.rdf rootfile 항목 제거 — 실제 HWPX 파일 구조와 일치
758
+ const CONTAINER_XML =
759
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
760
+ `<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" ` +
761
+ `xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf">` +
762
+ `<ocf:rootfiles>` +
763
+ `<ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/>` +
764
+ `<ocf:rootfile full-path="Preview/PrvText.txt" media-type="text/plain"/>` +
765
+ `</ocf:rootfiles></ocf:container>`;
766
+
767
+ // HWPX 파일은 container.rdf 대신 manifest.xml(빈 ODF 매니페스트) 사용
768
+ const MANIFEST_XML =
769
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
770
+ `<odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>`;
771
+
772
+ // ─── content.hpf ─────────────────────────────────────────────
773
+
774
+ function buildContentHpf(ctx: HwpxCtx, meta?: DocMeta): string {
364
775
  const title = esc(meta?.title ?? "");
365
- const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
776
+ const creator = esc(meta?.author ?? "text");
777
+ const subject = esc(meta?.subject ?? "text");
778
+ const desc = esc(meta?.desc ?? "text");
779
+ const keyword = esc(meta?.keywords ?? "text");
780
+ const created =
781
+ meta?.created ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
782
+ const modified = meta?.modified ?? created;
366
783
 
367
784
  let items =
368
- `<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>` +
785
+ `<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>` +
369
786
  `<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>` +
370
- `<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
787
+ `<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
788
+
371
789
  for (const bin of ctx.bins) {
372
790
  const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
373
791
  const ct =
@@ -380,352 +798,1021 @@ function contentHpf(ctx: HwpxCtx, meta?: any): string {
380
798
  : "image/bmp";
381
799
  items += `<opf:item id="${bin.id}" href="BinData/${bin.name}" media-type="${ct}" isEmbeded="1"/>`;
382
800
  }
383
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><opf:package ${NS} version="" unique-identifier="" id=""><opf:metadata><opf:title>${title}</opf:title><opf:language>ko</opf:language><opf:meta name="creator" content="text"/><opf:meta name="subject" content="text"/><opf:meta name="description" content="text"/><opf:meta name="CreatedDate" content="text">${now}</opf:meta><opf:meta name="ModifiedDate" content="text">${now}</opf:meta><opf:meta name="keyword" content="text"/></opf:metadata><opf:manifest>${items}</opf:manifest><opf:spine><opf:itemref idref="header" linear="yes"/><opf:itemref idref="section0" linear="yes"/></opf:spine></opf:package>`;
384
- }
385
-
386
- // ─── header.xml ─────────────────────────────────────────────
387
-
388
- function headerXml(dims: PageDims, meta: any, ctx: HwpxCtx): string {
389
- // Font face definitions — register all unique fonts per language group
390
- const fontCount = ctx.fonts.length || 1;
391
- const langs = [
392
- "HANGUL",
393
- "LATIN",
394
- "HANJA",
395
- "JAPANESE",
396
- "OTHER",
397
- "SYMBOL",
398
- "USER",
399
- ];
400
- let fontFaces = `<hh:fontfaces itemCnt="${langs.length}">`;
401
- for (const lang of langs) {
402
- fontFaces += `<hh:fontface lang="${lang}" fontCnt="${fontCount}">`;
403
- for (let fi = 0; fi < ctx.fonts.length; fi++) {
404
- fontFaces += `<hh:font id="${fi}" face="${esc(ctx.fonts[fi])}" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
405
- }
406
- if (ctx.fonts.length === 0) {
407
- fontFaces += `<hh:font id="0" face="굴림체" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
408
- }
409
- fontFaces += `</hh:fontface>`;
410
- }
411
- fontFaces += `</hh:fontfaces>`;
412
801
 
413
- // CharPr definitions
802
+ return (
803
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
804
+ `<opf:package ${NS} version="" unique-identifier="" id="">` +
805
+ `<opf:metadata>` +
806
+ `<opf:title>${title}</opf:title><opf:language>ko</opf:language>` +
807
+ `<opf:meta name="creator" content="text">${creator}</opf:meta>` +
808
+ `<opf:meta name="subject" content="text">${subject}</opf:meta>` +
809
+ `<opf:meta name="description" content="text">${desc}</opf:meta>` +
810
+ `<opf:meta name="CreatedDate" content="text">${created}</opf:meta>` +
811
+ `<opf:meta name="ModifiedDate" content="text">${modified}</opf:meta>` +
812
+ `<opf:meta name="keyword" content="text">${keyword}</opf:meta>` +
813
+ `<opf:meta name="trackchageConfig" content="text">0</opf:meta>` +
814
+ `</opf:metadata>` +
815
+ `<opf:manifest>${items}</opf:manifest>` +
816
+ `<opf:spine><opf:itemref idref="header"/><opf:itemref idref="section0"/></opf:spine>` +
817
+ `</opf:package>`
818
+ );
819
+ }
820
+
821
+ // ─── settings.xml ────────────────────────────────────────────
822
+
823
+ function buildSettingsXml(): string {
824
+ return (
825
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
826
+ `<ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" ` +
827
+ `xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0">` +
828
+ `<ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/>` +
829
+ `<config:config-item-set name="PrintInfo">` +
830
+ `<config:config-item name="PrintAutoFootNote" type="boolean">false</config:config-item>` +
831
+ `<config:config-item name="PrintAutoHeadNote" type="boolean">false</config:config-item>` +
832
+ `<config:config-item name="PrintMethod" type="short">4</config:config-item>` +
833
+ `<config:config-item name="OverlapSize" type="short">0</config:config-item>` +
834
+ `<config:config-item name="PrintCropMark" type="short">0</config:config-item>` +
835
+ `<config:config-item name="BinderHoleType" type="short">0</config:config-item>` +
836
+ `<config:config-item name="ZoomX" type="short">100</config:config-item>` +
837
+ `<config:config-item name="ZoomY" type="short">100</config:config-item>` +
838
+ `</config:config-item-set>` +
839
+ `</ha:HWPApplicationSetting>`
840
+ );
841
+ }
842
+
843
+ function buildNumberingsXml(): string {
844
+ return (
845
+ `<hh:numberings itemCnt="1">` +
846
+ `<hh:numbering id="1" start="1">` +
847
+ `<hh:paraHead start="1" level="1" align="LEFT" ` +
848
+ `useInstWidth="1" autoIndent="0" widthAdjust="0" ` +
849
+ `textOffsetType="PERCENT" textOffset="50" ` +
850
+ `numFormat="BULLET" charPrIDRef="0" checkable="0"/>` +
851
+ `</hh:numbering></hh:numberings>`
852
+ );
853
+ }
854
+
855
+ function buildBulletsXml(): string {
856
+ return (
857
+ `<hh:bullets itemCnt="1">` +
858
+ `<hh:bullet id="1" charPrIDRef="0" start="1" numFormat="BULLET">` +
859
+ `<hh:paraHead level="1" numChar="&#x2022;"/>` +
860
+ `<hh:paraHead level="2" numChar="&#x2022;"/>` +
861
+ `<hh:paraHead level="3" numChar="&#x2022;"/>` +
862
+ `</hh:bullet></hh:bullets>`
863
+ );
864
+ }
865
+
866
+ // ─── header.xml ──────────────────────────────────────────────
867
+
868
+ /**
869
+ * Contents/header.xml 용 전역 구역 설정 리스트(secPrList)를 생성합니다.
870
+ */
871
+ /**
872
+ * 페이지 여백 (margin) 과 헤더/푸터 영역 (zone) 을 계산합니다.
873
+ * HWPX spec 에서는 pagePr > margin 의 header/footer 가 헤더/푸터 영역의 높이 (zone height) 입니다.
874
+ * - headerZone: 용지 상단에서 헤더 영역 상단까지의 거리 (headerPt 가 없으면 0)
875
+ * - footerZone: 용지 하단에서 푸터 영역 하단까지의 거리 (footerPt 가 없으면 0)
876
+ * - mt/mb: 용지 상단/하단에서 본문 영역 상단/하단까지의 거리
877
+ */
878
+ function buildHeaderSecPrListXml(dims: PageDims): string {
879
+ const wHwp = Metric.ptToHwp(dims.wPt);
880
+ const hHwp = Metric.ptToHwp(dims.hPt);
881
+ const ml = Metric.ptToHwp(dims.ml);
882
+ const mr = Metric.ptToHwp(dims.mr);
883
+ const mt = Metric.ptToHwp(dims.mt);
884
+ const mb = Metric.ptToHwp(dims.mb);
885
+
886
+ // 헤더/푸터 영역 높이 계산 (HWPX 는 zone height 를 직접 지정)
887
+ // headerPt 가 설정되어 있으면 그 값을 zone height 로 사용, 없으면 0 으로 설정
888
+ const headerZone = dims.headerPt !== undefined && dims.headerPt > 0
889
+ ? Metric.ptToHwp(dims.headerPt)
890
+ : 0;
891
+ const footerZone = dims.footerPt !== undefined && dims.footerPt > 0
892
+ ? Metric.ptToHwp(dims.footerPt)
893
+ : 0;
894
+
895
+ const pageBorderFill =
896
+ `<hh:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
897
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
898
+ `</hh:pageBorderFill>` +
899
+ `<hh:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
900
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
901
+ `</hh:pageBorderFill>` +
902
+ `<hh:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
903
+ `<hh:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
904
+ `</hh:pageBorderFill>`;
905
+
906
+ return (
907
+ `<hh:secPrList itemCnt="1">` +
908
+ `<hh:secPr id="0" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">` +
909
+ `<hh:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/>` +
910
+ `<hh:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
911
+ `<hh:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
912
+ `<hh:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>` +
913
+ `<hh:pagePr landscape="WIDELY" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
914
+ `<hh:margin header="${headerZone}" footer="${footerZone}" gutter="0" left="${ml}" right="${mr}" top="${mt}" bottom="${mb}"/>` +
915
+ `</hh:pagePr>` +
916
+ `<hh:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>` +
917
+ `<hh:footNotePr><hh:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
918
+ `<hh:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
919
+ `<hh:noteSpacing betweenNotes="283" belowLine="0" aboveLine="1000"/>` +
920
+ `<hh:numbering type="CONTINUOUS" newNum="1"/>` +
921
+ `<hh:placement place="EACH_COLUMN" beneathText="0"/>` +
922
+ `</hh:footNotePr>` +
923
+ `<hh:endNotePr><hh:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
924
+ `<hh:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
925
+ `<hh:noteSpacing betweenNotes="0" belowLine="0" aboveLine="1000"/>` +
926
+ `<hh:numbering type="CONTINUOUS" newNum="1"/>` +
927
+ `<hh:placement place="END_OF_DOCUMENT" beneathText="0"/>` +
928
+ `</hh:endNotePr>` +
929
+ pageBorderFill +
930
+ `</hh:secPr>` +
931
+ `</hh:secPrList>`
932
+ );
933
+ }
934
+
935
+ function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
936
+ // 언어별 폰트 (LangFontBank → XML)
937
+ const fontFacesXml = ctx.fontBank.toXml();
938
+
939
+ // charPr 목록 — 언어별 폰트 ID를 fontRef에 반영 (ANYTOHWP 핵심 개선)
414
940
  let charPrXml = "";
415
941
  for (const cp of ctx.charPrs) {
416
942
  const bold = cp.bold ? "<hh:bold/>" : "";
417
943
  const italic = cp.italic ? "<hh:italic/>" : "";
418
- const fid = cp.fontId ?? 0;
419
- charPrXml += `<hh:charPr id="${cp.id}" height="${cp.height}" textColor="${cp.textColor}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="3"><hh:fontRef hangul="${fid}" latin="${fid}" hanja="${fid}" japanese="${fid}" other="${fid}" symbol="${fid}" user="${fid}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>${bold}${italic}<hh:underline type="${cp.underline}" shape="SOLID" color="#000000"/><hh:strikeout shape="${cp.strikeout}" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/></hh:charPr>`;
944
+ const hid = cp.hangulId;
945
+ const lid = cp.latinId;
946
+ const shadeColor = cp.bg ? (cp.bg.startsWith("#") ? cp.bg : `#${cp.bg}`) : "none";
947
+ charPrXml +=
948
+ `<hh:charPr id="${cp.id}" height="${cp.height}" textColor="${cp.textColor}" ` +
949
+ `shadeColor="${shadeColor}" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">` +
950
+ `<hh:fontRef hangul="${hid}" latin="${lid}" hanja="${hid}" japanese="${hid}" other="${lid}" symbol="${lid}" user="${lid}"/>` +
951
+ `<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
952
+ `<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
953
+ `<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
954
+ `<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
955
+ bold +
956
+ italic +
957
+ `<hh:underline type="${cp.underline}" shape="SOLID" color="#000000"/>` +
958
+ `<hh:strikeout shape="${cp.strikeout}" color="#000000"/>` +
959
+ `<hh:outline type="NONE"/>` +
960
+ `<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>` +
961
+ `</hh:charPr>`;
420
962
  }
421
963
 
422
- // ParaPr definitions
964
+ // paraPr 목록 (동적 정렬 및 줄바꿈 적용)
423
965
  let paraPrXml = "";
424
966
  for (const pp of ctx.paraPrs) {
425
- const marginSwitch = `<hp:switch><hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar"><hh:margin><hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/><hc:next value="${pp.nextHwp}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/></hp:case><hp:default><hh:margin><hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/><hc:next value="${pp.nextHwp}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/></hp:default></hp:switch>`;
426
- paraPrXml += `<hh:paraPr id="${pp.id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="${pp.align}" vertical="BASELINE"/><hh:heading type="NONE" idRef="0" level="0"/><hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/>${marginSwitch}<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>`;
967
+ const ver = pp.verAlign ?? "BASELINE";
968
+ const wrap = pp.lineWrap ?? "BREAK";
969
+ const lsType = pp.lineSpacingFixed !== undefined ? "FIXED" : "PERCENT";
970
+ const lsValue = pp.lineSpacingFixed !== undefined ? pp.lineSpacingFixed : pp.lineSpacing;
971
+ paraPrXml +=
972
+ `<hh:paraPr id="${pp.id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">` +
973
+ `<hh:align horizontal="${pp.align}" vertical="${ver}"/>` +
974
+ `<hh:heading type="NONE" idRef="0" level="0"/>` +
975
+ `<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="${wrap}"/>` +
976
+ `<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>` +
977
+ `<hh:margin>` +
978
+ `<hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/>` +
979
+ `<hc:left value="${pp.leftHwp}" unit="HWPUNIT"/>` +
980
+ `<hc:right value="${pp.rightHwp}" unit="HWPUNIT"/>` +
981
+ `<hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/>` +
982
+ `<hc:next value="${pp.nextHwp}" unit="HWPUNIT"/>` +
983
+ `</hh:margin>` +
984
+ `<hh:lineSpacing type="${lsType}" value="${lsValue}" unit="HWPUNIT"/>` +
985
+ `<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>` +
986
+ `</hh:paraPr>`;
427
987
  }
428
988
 
429
- // BorderFill definitions
430
- const borderFillXml = ctx.borderFills.map((bf) => bf.xml).join("");
431
-
432
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hh:head ${NS} version="1.4" secCnt="1"><hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/><hh:refList>${fontFaces}<hh:borderFills itemCnt="${ctx.borderFills.length}">${borderFillXml}</hh:borderFills><hh:charProperties itemCnt="${ctx.charPrs.length}">${charPrXml}</hh:charProperties><hh:paraProperties itemCnt="${ctx.paraPrs.length}">${paraPrXml}</hh:paraProperties><hh:tabProperties itemCnt="1"><hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/></hh:tabProperties><hh:styles itemCnt="1"><hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/></hh:styles></hh:refList><hh:compatibleDocument targetProgram="HWP201X"><hh:layoutCompatibility/></hh:compatibleDocument><hh:docOption><hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/></hh:docOption><hh:trackchageConfig flags="56"/></hh:head>`;
989
+ // borderFill 목록 (BorderFillBank → XML)
990
+ const borderFillXml = ctx.borderFillBank.toXml();
991
+
992
+ // 스타일 목록
993
+ const stylesXml =
994
+ `<hh:styles itemCnt="${ctx.hwpxStyles.length}">` +
995
+ ctx.hwpxStyles
996
+ .map(
997
+ (s) =>
998
+ `<hh:style id="${s.id}" type="PARA" name="${esc(s.name)}" engName="${esc(s.engName)}" ` +
999
+ `paraPrIDRef="${s.paraPrIDRef}" charPrIDRef="${s.charPrIDRef}" nextStyleIDRef="0" langID="1042" lockForm="0"/>`,
1000
+ )
1001
+ .join("") +
1002
+ `</hh:styles>`;
1003
+
1004
+ return (
1005
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
1006
+ `<hh:head ${NS} version="1.2" secCnt="1">` +
1007
+ `<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>` +
1008
+ `<hh:refList>` +
1009
+ fontFacesXml +
1010
+ borderFillXml +
1011
+ `<hh:charProperties itemCnt="${ctx.charPrs.length}">${charPrXml}</hh:charProperties>` +
1012
+ `<hh:tabProperties itemCnt="1"><hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/></hh:tabProperties>` +
1013
+ buildNumberingsXml() +
1014
+ buildBulletsXml() +
1015
+ `<hh:paraProperties itemCnt="${ctx.paraPrs.length}">${paraPrXml}</hh:paraProperties>` +
1016
+ stylesXml +
1017
+ `</hh:refList>` +
1018
+ buildHeaderSecPrListXml(dims) +
1019
+ `<hh:compatibleDocument targetProgram="HWP201X"><hh:layoutCompatibility/></hh:compatibleDocument>` +
1020
+ `<hh:docOption><hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/></hh:docOption>` +
1021
+ `<hh:trackchageConfig flags="56"/>` +
1022
+ `</hh:head>`
1023
+ );
433
1024
  }
434
1025
 
435
- // ─── section0.xml ───────────────────────────────────────────
1026
+ // ─── section0.xml ────────────────────────────────────────────
436
1027
 
437
- function sectionXml(
1028
+ function buildHeaderFooterRunXml(
1029
+ sheet: SheetNode,
1030
+ dims: PageDims,
1031
+ ctx: HwpxCtx,
1032
+ ): string {
1033
+ const headers = sheet.headers || {};
1034
+ const footers = sheet.footers || {};
1035
+ const hasAny = Object.keys(headers).length > 0 || Object.keys(footers).length > 0;
1036
+ if (!hasAny) return "";
1037
+
1038
+ const availW = ctx.availableWidth;
1039
+ const mtHwp = Metric.ptToHwp(dims.mt);
1040
+ const mbHwp = Metric.ptToHwp(dims.mb);
1041
+ const headerZoneH = dims.headerPt ? Metric.ptToHwp(dims.headerPt) : 4252; // 기본값 15mm
1042
+ const footerZoneH = dims.footerPt ? Metric.ptToHwp(dims.footerPt) : 4252; // 기본값 15mm
1043
+
1044
+ let inner = "";
1045
+
1046
+ // 1. 첫 페이지 숨김 설정 (first 헤더/푸터가 있으면 활성화)
1047
+ const hideFirst = !!(headers.first || footers.first);
1048
+ inner += `<hp:ctrl><hp:pageHiding hideHeader="${hideFirst ? 1 : 0}" hideFooter="${hideFirst ? 1 : 0}" hideMasterPage="0" hideBorder="0" hideFill="0" hidePageNum="0"/></hp:ctrl>`;
1049
+
1050
+ // 2. 헤더들 생성
1051
+ for (const [type, paras] of Object.entries(headers)) {
1052
+ if (!Array.isArray(paras) || paras.length === 0) continue;
1053
+ const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
1054
+ const savedId = ctx.nextElementId;
1055
+ ctx.nextElementId = 0;
1056
+ const parasXml = paras.map((p) => encodeParaPositioned(p, ctx, 0, "", availW).xml).join("");
1057
+ ctx.nextElementId = savedId;
1058
+ inner +=
1059
+ `<hp:ctrl>` +
1060
+ `<hp:header id="1" applyPageType="${applyPageType}">` +
1061
+ `<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="TOP" ` +
1062
+ `linkListIDRef="0" linkListNextIDRef="0" textWidth="${availW}" textHeight="${headerZoneH}" ` +
1063
+ `hasTextRef="0" hasNumRef="0">` +
1064
+ parasXml +
1065
+ `</hp:subList>` +
1066
+ `</hp:header>` +
1067
+ `</hp:ctrl>`;
1068
+ }
1069
+
1070
+ // 3. 푸터들 생성
1071
+ for (const [type, paras] of Object.entries(footers)) {
1072
+ if (!Array.isArray(paras) || paras.length === 0) continue;
1073
+ const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
1074
+ const savedId = ctx.nextElementId;
1075
+ ctx.nextElementId = 0;
1076
+ const parasXml = paras.map((p) => encodeParaPositioned(p, ctx, 0, "", availW).xml).join("");
1077
+ ctx.nextElementId = savedId;
1078
+ inner +=
1079
+ `<hp:ctrl>` +
1080
+ `<hp:footer id="2" applyPageType="${applyPageType}">` +
1081
+ `<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="BOTTOM" ` +
1082
+ `linkListIDRef="0" linkListNextIDRef="0" textWidth="${availW}" textHeight="${footerZoneH}" ` +
1083
+ `hasTextRef="0" hasNumRef="0">` +
1084
+ parasXml +
1085
+ `</hp:subList>` +
1086
+ `</hp:footer>` +
1087
+ `</hp:ctrl>`;
1088
+ }
1089
+
1090
+ return `<hp:run charPrIDRef="0" charTcId="0">${inner}</hp:run>`;
1091
+ }
1092
+
1093
+ function buildSectionXml(
438
1094
  sheet: SheetNode | undefined,
439
1095
  dims: PageDims,
440
1096
  ctx: HwpxCtx,
441
1097
  ): string {
1098
+ const secPrXml = buildSecPrXml(dims);
442
1099
  const kids = sheet?.kids ?? [];
1100
+ const hfRunXml = sheet ? buildHeaderFooterRunXml(sheet, dims, ctx) : "";
443
1101
 
444
- // First paragraph includes secPr
445
- // WIDELY = portrait (standard), NARROWLY = landscape
446
- const secPr = `<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0"><hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/><hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/><hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/><hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/><hp:pagePr landscape="${dims.orient === "landscape" ? "NARROWLY" : "WIDELY"}" width="${Metric.ptToHwp(dims.wPt)}" height="${Metric.ptToHwp(dims.hPt)}" gutterType="LEFT_ONLY"><hp:margin header="2834" footer="2834" gutter="0" left="${Metric.ptToHwp(dims.ml)}" right="${Metric.ptToHwp(dims.mr)}" top="${Metric.ptToHwp(dims.mt)}" bottom="${Metric.ptToHwp(dims.mb)}"/></hp:pagePr><hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="-1" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="284" belowLine="568" aboveLine="852"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="EACH_COLUMN" beneathText="0"/></hp:footNotePr><hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="0" type="NONE" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="0" belowLine="576" aboveLine="864"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="END_OF_DOCUMENT" beneathText="0"/></hp:endNotePr><hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill><hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill><hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill></hp:secPr><hp:ctrl><hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/></hp:ctrl>`;
1102
+ // 가용 너비 계산 (HWPUNIT)
1103
+ const availWidth = Math.max(
1104
+ 1000,
1105
+ Metric.ptToHwp(dims.wPt) - Metric.ptToHwp(dims.ml) - Metric.ptToHwp(dims.mr),
1106
+ );
1107
+ ctx.availableWidth = availWidth;
447
1108
 
448
1109
  let contentXml = "";
449
- let isFirst = true;
1110
+ let vertPos = 0;
450
1111
 
451
- const defaultLineseg = `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="${ctx.availableWidth}" flags="393216"/></hp:linesegarray>`;
1112
+ for (let i = 0; i < kids.length; i++) {
1113
+ const kid = kids[i];
1114
+ const isFirst = i === 0;
1115
+ // secPr은 hs:sec 하위에 직접 기입하므로 문단 내에는 기입하지 않습니다.
1116
+ const curSecPr = "";
1117
+ const curHfRun = isFirst ? hfRunXml : "";
452
1118
 
453
- for (const kid of kids) {
454
1119
  if (kid.tag === "para") {
455
- contentXml += encodePara(kid, ctx, isFirst ? secPr : "");
456
- isFirst = false;
1120
+ const { xml, nextVertPos, hasPageBreak } = encodeParaPositioned(
1121
+ kid,
1122
+ ctx,
1123
+ vertPos,
1124
+ curSecPr,
1125
+ availWidth,
1126
+ curHfRun,
1127
+ );
1128
+ contentXml += xml;
1129
+ vertPos = nextVertPos; // 페이지 브레이크 발생 시에도 누적 절대 좌표 유지
457
1130
  } else if (kid.tag === "grid") {
458
- // Grid is embedded inside a paragraph's run.
459
- // When secPr is present, put it in a separate run (matching real HWPX structure).
460
- const gridXml = encodeGrid(kid, ctx);
461
- const prefix = isFirst ? secPr : "";
462
- const runsXml = prefix
463
- ? `<hp:run charPrIDRef="0">${prefix}</hp:run><hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`
464
- : `<hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`;
465
- contentXml += `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">${runsXml}${defaultLineseg}</hp:p>`;
466
- isFirst = false;
1131
+ const { xml, nextVertPos, hasPageBreak } = encodeGridPositioned(
1132
+ kid,
1133
+ ctx,
1134
+ vertPos,
1135
+ curSecPr,
1136
+ curHfRun,
1137
+ );
1138
+ contentXml += xml;
1139
+ vertPos = nextVertPos; // 페이지 브레이크 발생 시에도 누적 절대 좌표 유지
467
1140
  }
468
1141
  }
469
1142
 
470
- // If empty, add one empty paragraph with secPr
471
- if (contentXml === "") {
472
- contentXml = `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0">${secPr}<hp:t></hp:t></hp:run>${defaultLineseg}</hp:p>`;
1143
+ if (kids.length === 0) {
1144
+ // 문서 최소 단락 1개 필수
1145
+ const fs = 1000;
1146
+ const vs = 1600;
1147
+ const { xml: linesegXml } = buildLinesegarray(" ", 0, fs, vs / (fs / 100), availWidth);
1148
+ contentXml =
1149
+ `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
1150
+ hfRunXml +
1151
+ `<hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run>` +
1152
+ linesegXml +
1153
+ `</hp:p>`;
473
1154
  }
474
1155
 
475
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS}>${contentXml}</hs:sec>`;
1156
+ // hs:sec 바로 아래 직계 자식으로 secPrXml 기입!
1157
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS} xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">${secPrXml}${contentXml}</hs:sec>`;
476
1158
  }
477
1159
 
478
- function estimateCellHeight(cell: CellNode, ctx: HwpxCtx): number {
479
- const topPad = 141;
480
- const botPad = 141;
481
- let contentHeight = 0;
482
- for (const para of cell.kids) {
483
- const fontSize = getFontSizeForPara(para, ctx);
484
- const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props));
485
- const paraPr = paraPrId !== undefined ? ctx.paraPrs[paraPrId] : null;
486
- const lineSpacing = paraPr ? paraPr.lineSpacing : 160;
487
- const spaceBefore = paraPr ? paraPr.prevHwp : 0;
488
- const spaceAfter = paraPr ? paraPr.nextHwp : 0;
489
- const lineHeight = Math.round((fontSize * lineSpacing) / 100);
490
- contentHeight += lineHeight + spaceBefore + spaceAfter;
491
- }
492
- if (contentHeight === 0) contentHeight = Math.round(1000 * 1.6); // 10pt @ 160%
493
- return contentHeight + topPad + botPad;
494
- }
495
-
496
- function encodePara(
497
- para: ParaNode,
498
- ctx: HwpxCtx,
499
- secPr: string = "",
500
- availWidth?: number,
501
- ): string {
502
- const paraPrId = registerParaPr(para.props, ctx);
503
-
504
- // Detect page break: paragraph starts on new page if any span contains a pb node
505
- const hasPageBreak = para.kids.some(
506
- (k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
1160
+ function buildSecPrXml(dims: PageDims): string {
1161
+ const wHwp = Metric.ptToHwp(dims.wPt);
1162
+ const hHwp = Metric.ptToHwp(dims.hPt);
1163
+ const ml = Metric.ptToHwp(dims.ml);
1164
+ const mr = Metric.ptToHwp(dims.mr);
1165
+ const mt = Metric.ptToHwp(dims.mt);
1166
+ const mb = Metric.ptToHwp(dims.mb);
1167
+ // HWPX margin header/footer = header/footer ZONE HEIGHT (not distance from paper edge)
1168
+ // = top_hwp - header_from_top_hwp (and bottom_hwp - footer_from_bottom_hwp)
1169
+ const headerZone = dims.headerPt ? Metric.ptToHwp(dims.headerPt) : 0;
1170
+ const footerZone = dims.footerPt ? Metric.ptToHwp(dims.footerPt) : 0;
1171
+
1172
+ const pageBorderFill =
1173
+ `<hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
1174
+ `<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
1175
+ `</hp:pageBorderFill>` +
1176
+ `<hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
1177
+ `<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
1178
+ `</hp:pageBorderFill>` +
1179
+ `<hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
1180
+ `<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
1181
+ `</hp:pageBorderFill>`;
1182
+
1183
+ return (
1184
+ `<hp:secPr id="0" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">` +
1185
+ `<hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/>` +
1186
+ `<hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
1187
+ `<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
1188
+ `<hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>` +
1189
+ `<hp:pagePr landscape="WIDELY" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
1190
+ `<hp:margin header="${headerZone}" footer="${footerZone}" gutter="0" left="${ml}" right="${mr}" top="${mt}" bottom="${mb}"/>` +
1191
+ `</hp:pagePr>` +
1192
+ `<hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>` +
1193
+ `<hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
1194
+ `<hp:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
1195
+ `<hp:noteSpacing betweenNotes="283" belowLine="0" aboveLine="1000"/>` +
1196
+ `<hp:numbering type="CONTINUOUS" newNum="1"/>` +
1197
+ `<hp:placement place="EACH_COLUMN" beneathText="0"/>` +
1198
+ `</hp:footNotePr>` +
1199
+ `<hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
1200
+ `<hp:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
1201
+ `<hp:noteSpacing betweenNotes="0" belowLine="0" aboveLine="1000"/>` +
1202
+ `<hp:numbering type="CONTINUOUS" newNum="1"/>` +
1203
+ `<hp:placement place="END_OF_DOCUMENT" beneathText="0"/>` +
1204
+ `</hp:endNotePr>` +
1205
+ pageBorderFill +
1206
+ `</hp:secPr>`
507
1207
  );
1208
+ }
508
1209
 
509
- let runs = "";
1210
+ // ─── 정보 XML (linesegarray) ──────────────────────────────
1211
+ // 가이드 준수: 실제 시각적 줄 단위로 lineseg 생성
1212
+
1213
+ function buildLinesegarray(
1214
+ text: string,
1215
+ vertPosStart: number,
1216
+ fontSize: number,
1217
+ lineSpacingPct: number,
1218
+ horzSize: number,
1219
+ ): { xml: string; totalHeight: number } {
1220
+ const vertsizeLine = Math.round((fontSize * lineSpacingPct) / 100);
1221
+ const spacing = vertsizeLine - fontSize;
1222
+ const baseline = Math.round(fontSize * 0.83);
1223
+
1224
+ if (text.length === 0) {
1225
+ const xml = `<hp:linesegarray>` +
1226
+ `<hp:lineseg textpos="0" vertpos="${vertPosStart}" vertsize="${vertsizeLine}" ` +
1227
+ `textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
1228
+ `horzpos="0" horzsize="${horzSize}" flags="${LINESEG_FLAGS_FIRST}"/>` +
1229
+ `</hp:linesegarray>`;
1230
+ return { xml, totalHeight: vertsizeLine };
1231
+ }
1232
+
1233
+ // 문자 단위 정밀 가로 폭 계산 및 자동 줄바꿈 알고리즘 (개행 문자 지원)
1234
+ const lines: { startPos: number; width: number }[] = [];
1235
+ let currentLineWidth = 0;
1236
+ let lineStartIdx = 0;
1237
+
1238
+ for (let i = 0; i < text.length; i++) {
1239
+ const charCode = text.charCodeAt(i);
1240
+
1241
+ // 개행 문자 (\n 또는 \r) 감지 시, 현재 줄 세그먼트를 무조건 마감하고 새로운 줄 시작
1242
+ if (charCode === 10 || charCode === 13) {
1243
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1244
+ lineStartIdx = i + 1;
1245
+ currentLineWidth = 0;
1246
+ continue;
1247
+ }
1248
+
1249
+ let charW = fontSize * 0.55; // 기본값 (영문 소문자, 숫자 등)
1250
+
1251
+ if (charCode >= 0xac00 && charCode <= 0xd7a3) {
1252
+ charW = fontSize; // 한글
1253
+ } else if (charCode >= 0x3130 && charCode <= 0x318f) {
1254
+ charW = fontSize; // 한글 자모
1255
+ } else if (charCode >= 0x4e00 && charCode <= 0x9fff) {
1256
+ charW = fontSize; // 한자
1257
+ } else if (charCode >= 65 && charCode <= 90) {
1258
+ charW = fontSize * 0.65; // 영문 대문자
1259
+ } else if (charCode === 32) {
1260
+ charW = fontSize * 0.32; // 공백
1261
+ } else if (charCode > 255) {
1262
+ charW = fontSize; // 기타 전각 문자
1263
+ } else {
1264
+ charW = fontSize * 0.42; // 기타 특수기호
1265
+ }
1266
+
1267
+ if (currentLineWidth + charW > horzSize && i > lineStartIdx) {
1268
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1269
+ lineStartIdx = i;
1270
+ currentLineWidth = charW;
1271
+ } else {
1272
+ currentLineWidth += charW;
1273
+ }
1274
+ }
1275
+ lines.push({ startPos: lineStartIdx, width: currentLineWidth });
1276
+
1277
+ const lineCount = lines.length;
1278
+ const linesegParts: string[] = [];
1279
+
1280
+ for (let i = 0; i < lineCount; i++) {
1281
+ const flags = i === 0 ? LINESEG_FLAGS_FIRST : LINESEG_FLAGS_OTHER;
1282
+ const textpos = lines[i].startPos;
1283
+ linesegParts.push(
1284
+ `<hp:lineseg textpos="${textpos}" ` +
1285
+ `vertpos="${vertPosStart + i * vertsizeLine}" ` +
1286
+ `vertsize="${vertsizeLine}" textheight="${fontSize}" ` +
1287
+ `baseline="${baseline}" spacing="${spacing}" ` +
1288
+ `horzpos="0" horzsize="${horzSize}" ` +
1289
+ `flags="${flags}"/>`,
1290
+ );
1291
+ }
1292
+
1293
+ return {
1294
+ xml: `<hp:linesegarray>${linesegParts.join("")}</hp:linesegarray>`,
1295
+ totalHeight: lineCount * vertsizeLine,
1296
+ };
1297
+ }
1298
+
1299
+ /** 단락에서 순수 텍스트 추출 (줄바꿈 계산용) */
1300
+ function extractParaText(para: ParaNode): string {
1301
+ let text = "";
1302
+ const walk = (kids: any[]) => {
1303
+ for (const k of kids) {
1304
+ if (k.tag === "span") {
1305
+ for (const c of k.kids) {
1306
+ if (c.tag === "txt") {
1307
+ text += c.content;
1308
+ } else if (c.tag === "br") {
1309
+ text += "\n";
1310
+ }
1311
+ }
1312
+ } else if (k.tag === "link") {
1313
+ walk(k.kids);
1314
+ }
1315
+ }
1316
+ };
1317
+ walk(para.kids);
1318
+ return text;
1319
+ }
1320
+
1321
+ function fontSizeForPara(para: ParaNode, ctx: HwpxCtx): number {
510
1322
  for (const kid of para.kids) {
511
1323
  if (kid.tag === "span") {
512
- runs += encodeRun(kid, ctx);
513
- } else if (kid.tag === "img") {
514
- runs += encodeImage(kid, ctx);
1324
+ const id = ctx.charPrMap.get(charPrKey(kid.props));
1325
+ if (id !== undefined && ctx.charPrs[id]) return ctx.charPrs[id].height;
515
1326
  }
516
1327
  }
1328
+ return 1000; // 기본 10pt
1329
+ }
517
1330
 
518
- // If no runs, add default empty run
519
- if (runs === "") {
520
- runs = `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
521
- }
1331
+ // ─── 단락 인코딩 ─────────────────────────────────────────────
522
1332
 
523
- // Inject secPr into first run
524
- if (secPr) {
525
- // Insert secPr after the first charPrIDRef attribute close
526
- const firstRunEnd = runs.indexOf(">");
527
- if (firstRunEnd > -1) {
528
- runs =
529
- runs.substring(0, firstRunEnd + 1) +
530
- secPr +
531
- runs.substring(firstRunEnd + 1);
532
- }
1333
+ function encodeParaPositioned(
1334
+ para: ParaNode,
1335
+ ctx: HwpxCtx,
1336
+ vertPos: number,
1337
+ secPr = "",
1338
+ availWidth?: number,
1339
+ hfRun = "",
1340
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1341
+ // ✅ 표(Grid)를 포함하는 단락인지 확인
1342
+ const gridKid = para.kids.find((k): k is GridNode => k.tag === "grid");
1343
+ if (gridKid) {
1344
+ return encodeTablePara(para, gridKid, ctx, vertPos, secPr, hfRun);
533
1345
  }
534
1346
 
535
- // Compute lineseg values based on font size and actual line spacing.
536
- // In HWPX, vertsize = font height and spacing = extra gap = fontSize * (lineSpacing/100 - 1).
537
- // Total rendered line height = vertsize + spacing.
538
- const fontSize = getFontSizeForPara(para, ctx);
1347
+ const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props)) ?? 0;
1348
+ const styleIDRef = para.props.styleId
1349
+ ? (ctx.styleIdToHwpxId.get(para.props.styleId) ?? 0)
1350
+ : 0;
1351
+ const fontSize = fontSizeForPara(para, ctx);
539
1352
  const paraPr = ctx.paraPrs[paraPrId];
540
1353
  const lineSpacing = paraPr?.lineSpacing ?? 160;
541
- const vertsize = fontSize;
542
- const textheight = fontSize;
543
- const baseline = Math.round(fontSize * 0.85);
544
1354
  const spacing = Math.max(0, Math.round(fontSize * (lineSpacing / 100 - 1)));
545
- const horzsize = availWidth ?? ctx.availableWidth;
546
- const linesegarray = `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="${vertsize}" textheight="${textheight}" baseline="${baseline}" spacing="${spacing}" horzpos="0" horzsize="${horzsize}" flags="393216"/></hp:linesegarray>`;
1355
+ let vertSize = fontSize + spacing;
1356
+ const horzSize = availWidth ?? ctx.availableWidth;
1357
+
1358
+ // 코드 블록 감지 (Courier 폰트 또는 styleId "code")
1359
+ const isCourierFont = (kids: ParaNode["kids"]): boolean =>
1360
+ kids.some(
1361
+ (k) =>
1362
+ (k.tag === "span" && k.props.font?.toLowerCase().includes("courier")) ||
1363
+ (k.tag === "link" &&
1364
+ isCourierFont((k as LinkNode).kids as ParaNode["kids"])),
1365
+ );
1366
+ const isCode =
1367
+ availWidth === undefined &&
1368
+ (para.props.styleId?.toLowerCase().includes("code") ||
1369
+ isCourierFont(para.kids));
1370
+
1371
+ if (isCode)
1372
+ return encodeCodeBlockPositioned(
1373
+ para,
1374
+ ctx,
1375
+ vertPos,
1376
+ secPr,
1377
+ fontSize,
1378
+ spacing,
1379
+ vertSize,
1380
+ );
1381
+
1382
+ let runsXml = encodeParaKids(para.kids, ctx);
1383
+ if (!runsXml) runsXml = `<hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run>`;
1384
+
1385
+ const paraText = extractParaText(para);
1386
+ const { xml: linesegXml, totalHeight } = buildLinesegarray(
1387
+ paraText,
1388
+ vertPos,
1389
+ fontSize,
1390
+ lineSpacing,
1391
+ horzSize,
1392
+ );
547
1393
 
548
- return `<hp:p id="0" paraPrIDRef="${paraPrId}" styleIDRef="0" pageBreak="${hasPageBreak ? "1" : "0"}" columnBreak="0" merged="0">${runs}${linesegarray}</hp:p>`;
1394
+ const hasPageBreak = para.kids.some(
1395
+ (k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
1396
+ );
1397
+
1398
+ const xml =
1399
+ `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="${paraPrId}" styleIDRef="${styleIDRef}" ` +
1400
+ `pageBreak="${hasPageBreak ? 1 : 0}" columnBreak="0" merged="0" paraTcId="0">` +
1401
+ secPr +
1402
+ hfRun +
1403
+ runsXml +
1404
+ linesegXml +
1405
+ `</hp:p>`;
1406
+
1407
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak };
549
1408
  }
550
1409
 
551
- /** Get the font size (in HWPX height units, 1000=10pt) for lineseg computation */
552
- function getFontSizeForPara(para: ParaNode, ctx: HwpxCtx): number {
553
- for (const kid of para.kids) {
1410
+ /** 가이드 준수: 표를 포함하는 단락 인코딩 */
1411
+ function encodeTablePara(
1412
+ para: ParaNode,
1413
+ grid: GridNode,
1414
+ ctx: HwpxCtx,
1415
+ vertPos: number,
1416
+ secPr: string,
1417
+ hfRun: string,
1418
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1419
+ const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props)) ?? 0;
1420
+
1421
+ // 표 알맹이 생성 (기존 로직 재사용)
1422
+ const { xml: gridXml, height: tblHeight } = buildGridXml(grid, ctx);
1423
+
1424
+ // 가이드: 표 단락의 lineseg는 1개여야 하고, vertsize는 표 전체 높이여야 함
1425
+ const fontSize = 1000;
1426
+ const totalHeight = Math.max(1600, tblHeight);
1427
+ const baseline = 850;
1428
+ const spacing = Math.max(0, totalHeight - fontSize);
1429
+
1430
+ const linesegXml =
1431
+ `<hp:linesegarray>` +
1432
+ `<hp:lineseg textpos="0" vertpos="${vertPos}" vertsize="${totalHeight}" ` +
1433
+ `textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
1434
+ `horzpos="0" horzsize="${ctx.availableWidth}" flags="1441792"/>` +
1435
+ `</hp:linesegarray>`;
1436
+
1437
+ const hasPageBreak = para.kids.some(
1438
+ (k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
1439
+ );
1440
+
1441
+ const runId = ctx.nextElementId++;
1442
+ const xml =
1443
+ `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="${paraPrId}" styleIDRef="0" ` +
1444
+ `pageBreak="${hasPageBreak ? 1 : 0}" columnBreak="0" merged="0" paraTcId="0">` +
1445
+ secPr +
1446
+ `<hp:run id="${runId}" charPrIDRef="0" charTcId="0">` +
1447
+ gridXml +
1448
+ `</hp:run>` +
1449
+ hfRun +
1450
+ linesegXml +
1451
+ `</hp:p>`;
1452
+
1453
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak };
1454
+ }
1455
+
1456
+ function encodeCodeBlockPositioned(
1457
+ para: ParaNode,
1458
+ ctx: HwpxCtx,
1459
+ vertPos: number,
1460
+ secPr: string,
1461
+ fontSize: number,
1462
+ spacing: number,
1463
+ vertSize: number,
1464
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1465
+ const codeBfId = ctx.borderFillBank.addUniform(
1466
+ { kind: "solid", pt: 0.5, color: "aaaaaa" },
1467
+ "f4f4f4",
1468
+ );
1469
+ const cellW = ctx.availableWidth;
1470
+ const innerW = Math.max(cellW - 510, 100);
1471
+ const subListId = ctx.nextElementId++;
1472
+ const { xml: innerXml } = encodeParaPositioned(para, ctx, 0, "", innerW);
1473
+
1474
+ const paraText = extractParaText(para);
1475
+ const { xml: linesegXml, totalHeight } = buildLinesegarray(
1476
+ paraText,
1477
+ vertPos,
1478
+ fontSize,
1479
+ 160, // 코드 블록 기본 줄간격 160%
1480
+ ctx.availableWidth,
1481
+ );
1482
+
1483
+ const xml =
1484
+ `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" paraTcId="0">` +
1485
+ secPr +
1486
+ `<hp:run charPrIDRef="0" charTcId="0">` +
1487
+ `<hp:tbl id="${ctx.nextElementId++}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE" rowCnt="1" colCnt="1" cellSpacing="0" borderFillIDRef="${codeBfId}" noAdjust="0">` +
1488
+ `<hp:sz width="${cellW}" widthRelTo="ABSOLUTE" height="0" heightRelTo="ABSOLUTE" protect="0"/>` +
1489
+ `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
1490
+ `<hp:outMargin left="138" right="138" top="138" bottom="138"/>` +
1491
+ `<hp:inMargin left="138" right="138" top="138" bottom="138"/>` +
1492
+ `<hp:tr><hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${codeBfId}">` +
1493
+ `<hp:subList id="${subListId}" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">` +
1494
+ innerXml +
1495
+ `</hp:subList>` +
1496
+ `<hp:cellAddr colAddr="0" rowAddr="0"/>` +
1497
+ `<hp:cellSpan colSpan="1" rowSpan="1"/>` +
1498
+ `<hp:cellSz width="${cellW}" height="0"/>` +
1499
+ `<hp:cellMargin left="283" right="283" top="141" bottom="141"/>` +
1500
+ `</hp:tc></hp:tr></hp:tbl><hp:t xml:space="preserve"> </hp:t></hp:run>` +
1501
+ linesegXml +
1502
+ `</hp:p>`;
1503
+
1504
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak: false };
1505
+ }
1506
+
1507
+ function encodeParaKids(kids: ParaNode["kids"], ctx: HwpxCtx): string {
1508
+ let xml = "";
1509
+ let currentRunCharPrId: number | null = null;
1510
+ let currentRunContent = "";
1511
+
1512
+ const flushRun = () => {
1513
+ if (currentRunCharPrId !== null) {
1514
+ // 내용이 없더라도 빈 hp:t를 생성하여 '텍스트 없음' 오류 방지
1515
+ const content = currentRunContent || `<hp:t xml:space="preserve"> </hp:t>`;
1516
+ xml += `<hp:run charPrIDRef="${currentRunCharPrId}" charTcId="0">${content}</hp:run>`;
1517
+ }
1518
+ currentRunCharPrId = null;
1519
+ currentRunContent = "";
1520
+ };
1521
+
1522
+ for (const kid of kids) {
554
1523
  if (kid.tag === "span") {
555
- const charPrId = ctx.charPrMap.get(charPrKey(kid.props));
556
- if (charPrId !== undefined && ctx.charPrs[charPrId]) {
557
- return ctx.charPrs[charPrId].height;
1524
+ const span = kid as SpanNode;
1525
+ const charPrId = ctx.charPrMap.get(charPrKey(span.props)) ?? 0;
1526
+
1527
+ if (currentRunCharPrId !== null && currentRunCharPrId !== charPrId) {
1528
+ flushRun();
1529
+ }
1530
+
1531
+ currentRunCharPrId = charPrId;
1532
+ currentRunContent += encodeRunInner(span);
1533
+ }
1534
+ else if (kid.tag === "link") {
1535
+ const link = kid as LinkNode;
1536
+ // 링크의 첫 번째 span 스타일을 기준으로 함
1537
+ let charPrId = 0;
1538
+ if (link.kids.length > 0 && link.kids[0].tag === "span") {
1539
+ charPrId = ctx.charPrMap.get(charPrKey((link.kids[0] as SpanNode).props)) ?? 0;
558
1540
  }
1541
+
1542
+ if (currentRunCharPrId !== null && currentRunCharPrId !== charPrId) {
1543
+ flushRun();
1544
+ }
1545
+
1546
+ currentRunCharPrId = charPrId;
1547
+ currentRunContent += encodeLinkInner(link, ctx);
1548
+ }
1549
+ else if (kid.tag === "img") {
1550
+ flushRun();
1551
+ xml += encodeImgWrapped(kid, ctx);
559
1552
  }
560
1553
  }
561
- return 1000; // default 10pt
562
- }
563
1554
 
564
- function encodeRun(span: SpanNode, ctx: HwpxCtx): string {
565
- const charPrId = registerCharPr(span.props, ctx);
1555
+ flushRun();
1556
+ return xml;
1557
+ }
566
1558
 
567
- const parts: string[] = [];
1559
+ /** hp:run 내부의 태그들만 생성 (span용) */
1560
+ function encodeRunInner(span: SpanNode): string {
1561
+ let xml = "";
568
1562
  for (const kid of span.kids) {
569
1563
  if (kid.tag === "txt") {
570
- if (kid.content) {
571
- parts.push(`<hp:t>${esc(kid.content)}</hp:t>`);
572
- } else {
573
- parts.push(`<hp:t></hp:t>`);
574
- }
575
- } else if (kid.tag === "pagenum") {
576
- const fmt =
577
- kid.format === "roman"
578
- ? "ROMAN_LOWER"
579
- : kid.format === "romanCaps"
580
- ? "ROMAN_UPPER"
581
- : "DIGIT";
582
- parts.push(`<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`);
1564
+ const content = esc(kid.content);
1565
+ if (content) xml += `<hp:t xml:space="preserve">${content}</hp:t>`;
583
1566
  } else if (kid.tag === "br") {
584
- parts.push(`<hp:t>\n</hp:t>`);
1567
+ xml += `<hp:br/>`;
1568
+ } else if (kid.tag === "pagenum") {
1569
+ const fmt = (kid as any).format === "roman" ? "ROMAN_LOWER"
1570
+ : (kid as any).format === "romanCaps" ? "ROMAN_UPPER" : "DIGIT";
1571
+ xml += `<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`;
1572
+ }
1573
+ }
1574
+ return xml;
1575
+ }
1576
+
1577
+ /** hp:run 내부의 태그들만 생성 (link용) */
1578
+ function encodeLinkInner(link: LinkNode, ctx: HwpxCtx): string {
1579
+ const fieldId = 600000000 + (ctx.nextElementId++ % 100000000);
1580
+ const instanceId = 2100000000 + (ctx.nextElementId++ % 100000000);
1581
+ const url = link.href;
1582
+
1583
+ let xml = `<hp:ctrl>` +
1584
+ `<hp:fieldBegin id="${instanceId}" type="HYPERLINK" name="" editable="0" dirty="1" zorder="-1" fieldid="${fieldId}">` +
1585
+ `<hp:parameters cnt="6" name="">` +
1586
+ `<hp:integerParam name="Prop">0</hp:integerParam>` +
1587
+ `<hp:stringParam name="Command">${esc(url.replace(/:/g, "\\:"))};1;5;-1;</hp:stringParam>` +
1588
+ `<hp:stringParam name="Path">${esc(url)}</hp:stringParam>` +
1589
+ `<hp:stringParam name="Category">HWPHYPERLINK_TYPE_URL</hp:stringParam>` +
1590
+ `<hp:stringParam name="TargetType">HWPHYPERLINK_TARGET_HYPERLINK</hp:stringParam>` +
1591
+ `<hp:stringParam name="DocOpenType">HWPHYPERLINK_JUMP_DONTCARE</hp:stringParam>` +
1592
+ `</hp:parameters>` +
1593
+ `</hp:fieldBegin>` +
1594
+ `</hp:ctrl>`;
1595
+
1596
+ for (const kid of link.kids) {
1597
+ if (kid.tag === "span") {
1598
+ xml += encodeRunInner(kid as SpanNode);
585
1599
  }
586
- // pb is handled at paragraph level (pageBreak attribute), skip here
587
1600
  }
588
1601
 
589
- return `<hp:run charPrIDRef="${charPrId}">${parts.join("")}</hp:run>`;
1602
+ xml += `<hp:ctrl><hp:fieldEnd beginIDRef="${instanceId}"/></hp:ctrl>`;
1603
+ return xml;
590
1604
  }
591
1605
 
592
- // HWPX textWrap 매핑
593
- const WRAP_HWPX: Record<string, string> = {
594
- inline: 'TOP_AND_BOTTOM',
595
- square: 'SQUARE',
596
- tight: 'BOTH_SIDES',
597
- through: 'BOTH_SIDES',
598
- none: 'FRONT_TEXT',
599
- behind: 'BEHIND_TEXT',
600
- front: 'FRONT_TEXT',
601
- };
602
- // textFlow 매핑 (wrap 타입별)
603
- const TEXT_FLOW_HWPX: Record<string, string> = {
604
- inline: 'BOTH_SIDES',
605
- square: 'LARGEST_ONLY',
606
- tight: 'BOTH_SIDES',
607
- through: 'BOTH_SIDES',
608
- none: 'BOTH_SIDES',
609
- behind: 'BOTH_SIDES',
610
- front: 'BOTH_SIDES',
611
- };
612
- const HORZ_RELTO_HWPX: Record<string, string> = {
613
- para: 'PARA', margin: 'MARGIN', page: 'PAPER', column: 'COLUMN',
1606
+ // ─── 이미지 인코딩 (ANYTOHWP 영감: 픽셀 치수 추출) ──────────
1607
+
1608
+ const WRAP_MAP: Record<string, string> = {
1609
+ inline: "TOP_AND_BOTTOM",
1610
+ square: "SQUARE",
1611
+ tight: "BOTH_SIDES",
1612
+ through: "BOTH_SIDES",
1613
+ none: "FRONT_TEXT",
1614
+ behind: "BEHIND_TEXT",
1615
+ front: "FRONT_TEXT",
614
1616
  };
615
- const VERT_RELTO_HWPX: Record<string, string> = {
616
- para: 'PARA', margin: 'MARGIN', page: 'PAPER', line: 'LINE',
1617
+ const FLOW_MAP: Record<string, string> = {
1618
+ inline: "BOTH_SIDES",
1619
+ square: "LARGEST_ONLY",
1620
+ tight: "BOTH_SIDES",
1621
+ through: "BOTH_SIDES",
1622
+ none: "BOTH_SIDES",
1623
+ behind: "BOTH_SIDES",
1624
+ front: "BOTH_SIDES",
617
1625
  };
618
1626
 
619
1627
  function encodeImage(img: ImgNode, ctx: HwpxCtx): string {
1628
+ // 0. 플레이스홀더 처리 (차트 등 b64가 없는 경우)
1629
+ if (!img.b64) {
1630
+ return `<hp:t xml:space="preserve">${esc(img.alt || "[개체]")}</hp:t>`;
1631
+ }
1632
+
620
1633
  const binId = ctx.imgMap.get(img);
621
1634
  if (!binId) return "";
622
1635
 
623
- const charPrId = registerCharPr({}, ctx);
624
- const w = Metric.ptToHwp(img.w);
625
- const h = Metric.ptToHwp(img.h);
626
- const cx = Math.round(w / 2);
627
- const cy = Math.round(h / 2);
1636
+ // ANYTOHWP 영감: PNG/JPEG 바이너리 헤더에서 실제 픽셀 치수 추출
1637
+ // img.w / img.h는 pt 단위이지만, 이미지 실제 픽셀을 HWPUNIT으로 변환하면 더 정확
1638
+ const pixelDims = readPixelDims(img.b64, img.mime);
1639
+ let wHwp: number, hHwp: number;
1640
+
1641
+ if (pixelDims && pixelDims.w > 0 && pixelDims.h > 0) {
1642
+ // 픽셀 → pt (96dpi 기준) → HWPUNIT
1643
+ wHwp = Metric.ptToHwp((pixelDims.w * 72) / 96);
1644
+ hHwp = Metric.ptToHwp((pixelDims.h * 72) / 96);
1645
+ } else {
1646
+ wHwp = Metric.ptToHwp(img.w);
1647
+ hHwp = Metric.ptToHwp(img.h);
1648
+ }
1649
+
1650
+ // 가용 너비 초과 방지 (비율 유지)
1651
+ if (wHwp > ctx.availableWidth) {
1652
+ hHwp = Math.round((hHwp * ctx.availableWidth) / wHwp);
1653
+ wHwp = ctx.availableWidth;
1654
+ }
1655
+
1656
+ // 회전 중심점 (rotation center) 계산: 이미지 중앙을 기준으로 회전
1657
+ const rotationCenterX = Math.round(wHwp / 2);
1658
+ const rotationCenterY = Math.round(hHwp / 2);
628
1659
 
629
1660
  const layout = img.layout;
630
- const isInline = !layout || layout.wrap === 'inline';
631
-
632
- const textWrap = layout ? (WRAP_HWPX[layout.wrap] ?? 'TOP_AND_BOTTOM') : 'TOP_AND_BOTTOM';
633
- const textFlow = layout ? (TEXT_FLOW_HWPX[layout.wrap] ?? 'BOTH_SIDES') : 'BOTH_SIDES';
634
- const treatAsChar = isInline ? '1' : '0';
635
- const flowWithText = '1';
636
- // behind/front/inline 이미지는 다른 객체와 겹침 허용 불필요; square/tight는 허용
637
- const allowOverlap = (!isInline && layout?.wrap !== 'behind' && layout?.wrap !== 'front') ? '1' : '0';
638
-
639
- const horzRelTo = layout?.horzRelTo ? (HORZ_RELTO_HWPX[layout.horzRelTo] ?? 'PARA') : 'PARA';
640
- const vertRelTo = layout?.vertRelTo ? (VERT_RELTO_HWPX[layout.vertRelTo] ?? 'PARA') : 'PARA';
641
-
642
- const ALIGN_H: Record<string, string> = { left: 'LEFT', center: 'CENTER', right: 'RIGHT' };
643
- const ALIGN_V: Record<string, string> = { top: 'TOP', center: 'CENTER', bottom: 'BOTTOM' };
644
- const horzAlign = layout?.horzAlign ? (ALIGN_H[layout.horzAlign] ?? 'LEFT') : 'LEFT';
645
- const vertAlign = layout?.vertAlign ? (ALIGN_V[layout.vertAlign] ?? 'TOP') : 'TOP';
646
- const horzOffset = layout?.xPt != null ? Metric.ptToHwp(layout.xPt) : 0;
647
- const vertOffset = layout?.yPt != null ? Metric.ptToHwp(layout.yPt) : 0;
648
-
649
- // hp:pic children must follow the exact HWPX spec order.
1661
+ const isInline = !layout || layout.wrap === "inline";
1662
+ const textWrap = layout ? (WRAP_MAP[layout.wrap] ?? "SQUARE") : "SQUARE";
1663
+ const textFlow = layout
1664
+ ? (FLOW_MAP[layout.wrap] ?? "BOTH_SIDES")
1665
+ : "BOTH_SIDES";
650
1666
  const zOrder = ctx.nextZOrder++;
651
- return `<hp:run charPrIDRef="${charPrId}"><hp:pic id="${ctx.nextElementId++}" zOrder="${zOrder}" numberingType="PICTURE" textWrap="${textWrap}" textFlow="${textFlow}" lock="0" dropcapstyle="None" href="" groupLevel="0" instid="0" reverse="0"><hp:offset x="0" y="0"/><hp:orgSz width="${w}" height="${h}"/><hp:curSz width="${w}" height="${h}"/><hp:flip horizontal="0" vertical="0"/><hp:rotationInfo angle="0" centerX="${cx}" centerY="${cy}" rotateimage="1"/><hp:renderingInfo><hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/><hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/><hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/></hp:renderingInfo><hp:imgRect><hc:pt0 x="0" y="0"/><hc:pt1 x="${w}" y="0"/><hc:pt2 x="${w}" y="${h}"/><hc:pt3 x="0" y="${h}"/></hp:imgRect><hp:imgClip left="0" right="0" top="0" bottom="0"/><hp:inMargin left="0" right="0" top="0" bottom="0"/><hp:imgDim dimwidth="${w}" dimheight="${h}"/><hc:img binaryItemIDRef="${binId}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/><hp:effects/><hp:sz width="${w}" widthRelTo="ABSOLUTE" height="${h}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="${treatAsChar}" affectLSpacing="0" flowWithText="${flowWithText}" allowOverlap="${allowOverlap}" holdAnchorAndSO="0" vertRelTo="${vertRelTo}" horzRelTo="${horzRelTo}" vertAlign="${vertAlign}" horzAlign="${horzAlign}" vertOffset="${vertOffset}" horzOffset="${horzOffset}"/><hp:outMargin left="0" right="0" top="0" bottom="0"/></hp:pic><hp:t></hp:t></hp:run>`;
1667
+
1668
+ return (
1669
+ `<hp:pic id="${ctx.nextElementId++}" zOrder="${zOrder}" numberingType="PICTURE" ` +
1670
+ `textWrap="${textWrap}" textFlow="${textFlow}" lock="0" dropcapstyle="None" href="" groupLevel="0" instid="0" reverse="0">` +
1671
+ `<hp:offset x="0" y="0"/>` +
1672
+ `<hp:orgSz width="${wHwp}" height="${hHwp}"/>` +
1673
+ `<hp:curSz width="${wHwp}" height="${hHwp}"/>` +
1674
+ `<hp:flip horizontal="0" vertical="0"/>` +
1675
+ `<hp:rotationInfo angle="0" centerX="${rotationCenterX}" centerY="${rotationCenterY}" rotateimage="1"/>` +
1676
+ `<hp:renderingInfo>` +
1677
+ `<hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
1678
+ `<hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
1679
+ `<hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
1680
+ `</hp:renderingInfo>` +
1681
+ `<hp:imgRect>` +
1682
+ `<hc:pt0 x="0" y="0"/><hc:pt1 x="${wHwp}" y="0"/>` +
1683
+ `<hc:pt2 x="${wHwp}" y="${hHwp}"/><hc:pt3 x="0" y="${hHwp}"/>` +
1684
+ `</hp:imgRect>` +
1685
+ `<hp:imgClip left="0" right="0" top="0" bottom="0"/>` +
1686
+ `<hp:inMargin left="0" right="0" top="0" bottom="0"/>` +
1687
+ `<hp:imgDim dimwidth="${wHwp}" dimheight="${hHwp}"/>` +
1688
+ `<hc:img binaryItemIDRef="${binId}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>` +
1689
+ `<hp:effects/>` +
1690
+ `<hp:sz width="${wHwp}" widthRelTo="ABSOLUTE" height="${hHwp}" heightRelTo="ABSOLUTE" protect="0"/>` +
1691
+ `<hp:pos treatAsChar="${isInline ? 1 : 0}" affectLSpacing="0" flowWithText="1" ` +
1692
+ `allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" ` +
1693
+ `vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
1694
+ `<hp:outMargin left="0" right="0" top="0" bottom="0"/>` +
1695
+ `</hp:pic>`
1696
+ );
652
1697
  }
653
1698
 
654
- function encodeGrid(grid: GridNode, ctx: HwpxCtx): string {
1699
+ function encodeImgWrapped(img: ImgNode, ctx: HwpxCtx): string {
1700
+ const content = encodeImage(img, ctx);
1701
+ if (!img.b64) {
1702
+ return `<hp:run charPrIDRef="0" charTcId="0">${content}</hp:run>`;
1703
+ }
1704
+ return `<hp:run charPrIDRef="0" charTcId="0">${content}<hp:t xml:space="preserve"> </hp:t></hp:run>`;
1705
+ }
1706
+
1707
+ // ─── 표(Grid) 인코딩 ─────────────────────────────────────────
1708
+
1709
+ function encodeGridPositioned(
1710
+ grid: GridNode,
1711
+ ctx: HwpxCtx,
1712
+ vertPos: number,
1713
+ secPr = "",
1714
+ hfRun = "",
1715
+ ): { xml: string; nextVertPos: number; hasPageBreak: boolean } {
1716
+ const { xml: gridXml, height: tblHeight } = buildGridXml(grid, ctx);
1717
+ const totalHeight = Math.max(1600, tblHeight);
1718
+ const fontSize = 1000;
1719
+ const baseline = Math.round(fontSize * 0.83);
1720
+ const spacing = Math.max(0, totalHeight - fontSize);
1721
+
1722
+ const linesegXml =
1723
+ `<hp:linesegarray>` +
1724
+ `<hp:lineseg textpos="0" vertpos="${vertPos}" vertsize="${totalHeight}" ` +
1725
+ `textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
1726
+ `horzpos="0" horzsize="${ctx.availableWidth}" flags="${LINESEG_FLAGS_FIRST}"/>` +
1727
+ `</hp:linesegarray>`;
1728
+
1729
+ const runId = ctx.nextElementId++;
1730
+ const xml =
1731
+ `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
1732
+ secPr +
1733
+ hfRun +
1734
+ `<hp:run id="${runId}" charPrIDRef="0" charTcId="0">` +
1735
+ gridXml +
1736
+ `</hp:run>` +
1737
+ linesegXml +
1738
+ `</hp:p>`;
1739
+
1740
+ return { xml, nextVertPos: vertPos + totalHeight, hasPageBreak: false };
1741
+ }
1742
+ function buildGridXml(
1743
+ grid: GridNode,
1744
+ ctx: HwpxCtx,
1745
+ ): { xml: string; height: number } {
655
1746
  const rowCount = grid.kids.length;
1747
+ // ... (기존 tableMap 생성 로직 동일)
656
1748
 
657
- // 1단계: 가상 2D 맵핑 (Virtual Table Map) 생성
658
- interface CellMap {
659
- type: 'real' | 'absorbed';
1749
+ // 가상 2D 병합 처리
1750
+ interface CellEntry {
1751
+ type: "real" | "absorbed";
660
1752
  cell?: CellNode;
661
1753
  }
662
- const tableMap: CellMap[][] = Array.from({ length: rowCount }, () => []);
1754
+ const tableMap: CellEntry[][] = Array.from({ length: rowCount }, () => []);
663
1755
 
664
1756
  for (let ri = 0; ri < rowCount; ri++) {
665
1757
  let ci = 0;
666
1758
  for (const cell of grid.kids[ri].kids) {
667
- while (tableMap[ri][ci]) ci++; // 이미 점유된 자리 건너뜀
668
-
669
- tableMap[ri][ci] = { type: 'real', cell };
670
-
671
- // 병합 영역 예약
1759
+ while (tableMap[ri][ci]) ci++;
1760
+ tableMap[ri][ci] = { type: "real", cell };
672
1761
  for (let rr = 0; rr < cell.rs; rr++) {
673
- const targetRi = ri + rr;
674
- if (targetRi >= rowCount) break;
675
- if (!tableMap[targetRi]) tableMap[targetRi] = [];
1762
+ const tri = ri + rr;
1763
+ if (tri >= rowCount) break;
676
1764
  for (let cc = 0; cc < cell.cs; cc++) {
677
1765
  if (rr === 0 && cc === 0) continue;
678
- tableMap[targetRi][ci + cc] = { type: 'absorbed' };
1766
+ tableMap[tri][ci + cc] = { type: "absorbed" };
679
1767
  }
680
1768
  }
681
1769
  ci += cell.cs;
682
1770
  }
683
1771
  }
684
1772
 
685
- // 정확한 전체 열 개수 계산
686
1773
  let colCount = 0;
687
- for (let ri = 0; ri < rowCount; ri++) {
1774
+ for (let ri = 0; ri < rowCount; ri++)
688
1775
  colCount = Math.max(colCount, tableMap[ri].length);
689
- }
690
1776
  if (colCount === 0) colCount = 1;
691
1777
 
692
- // 2단계: 컬럼 너비 계산
693
- const totalWidth = ctx.availableWidth;
694
- const defaultColW = Math.round(totalWidth / colCount);
1778
+ // 컬럼 너비 계산 (Bug 6: 균등 배분 금지, 원본 보존)
1779
+ const totalW = ctx.availableWidth;
695
1780
  const colWidths: number[] = [];
1781
+
696
1782
  if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
697
- const srcPt = [...grid.props.colWidths];
698
- const knownTotal = srcPt.filter((w) => w > 0).reduce((s, w) => s + w, 0);
699
- const zeroCount = srcPt.filter((w) => w <= 0).length;
700
- const availPt = Metric.hwpToPt(totalWidth);
701
- const remaining = Math.max(0, availPt - knownTotal);
702
- const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
703
- for (let i = 0; i < srcPt.length; i++) {
704
- if (srcPt[i] <= 0) srcPt[i] = zeroFill > 0 ? zeroFill : Metric.hwpToPt(defaultColW);
1783
+ // pt -> HWPUNIT 변환하여 원본 값 보존
1784
+ for (const wPt of grid.props.colWidths) {
1785
+ colWidths.push(Metric.ptToHwp(wPt));
705
1786
  }
706
- for (const wPt of srcPt) colWidths.push(Metric.ptToHwp(wPt));
707
1787
  } else {
708
- for (let c = 0; c < colCount; c++) colWidths.push(defaultColW);
1788
+ // 너비 정보가 없을 때만 균등 배분
1789
+ const defW = Math.round(totalW / colCount);
1790
+ for (let c = 0; c < colCount; c++) colWidths.push(defW);
709
1791
  }
710
1792
 
1793
+ // 본문 너비 초과 시에만 비율 축소 보정
711
1794
  const rawTotal = colWidths.reduce((s, w) => s + w, 0);
712
- if (rawTotal > totalWidth * 1.05) {
713
- const scale = totalWidth / rawTotal;
714
- for (let i = 0; i < colWidths.length; i++) colWidths[i] = Math.round(colWidths[i] * scale);
1795
+ if (rawTotal > totalW && rawTotal > 0) {
1796
+ const scale = totalW / rawTotal;
1797
+ for (let i = 0; i < colWidths.length; i++) {
1798
+ colWidths[i] = Math.max(100, Math.round(colWidths[i] * scale));
1799
+ }
715
1800
  }
716
1801
  const actualTotal = colWidths.reduce((s, w) => s + w, 0);
717
1802
 
718
- // 3단계: 행 높이 계산
1803
+ // 행 높이 계산
719
1804
  const rowHeights: number[] = [];
720
1805
  for (let ri = 0; ri < rowCount; ri++) {
721
- const row = grid.kids[ri];
722
- if (row.heightPt != null && row.heightPt > 0) {
723
- rowHeights.push(Metric.ptToHwp(row.heightPt));
1806
+ if (
1807
+ grid.kids[ri].heightPt != null &&
1808
+ (grid.kids[ri].heightPt as number) > 0
1809
+ ) {
1810
+ rowHeights.push(Metric.ptToHwp(grid.kids[ri].heightPt as number));
724
1811
  } else {
725
1812
  let maxH = 0;
726
1813
  for (let ci = 0; ci < colCount; ci++) {
727
1814
  const entry = tableMap[ri][ci];
728
- if (entry?.type === 'real') {
1815
+ if (entry?.type === "real") {
729
1816
  const h = estimateCellHeight(entry.cell!, ctx);
730
1817
  if (h > maxH) maxH = h;
731
1818
  }
@@ -733,77 +1820,142 @@ function encodeGrid(grid: GridNode, ctx: HwpxCtx): string {
733
1820
  rowHeights.push(maxH || Math.round(1000 * 1.6));
734
1821
  }
735
1822
  }
736
- const totalTableHeight = rowHeights.reduce((s, h) => s + h, 0);
1823
+ const totalH = rowHeights.reduce((s, h) => s + h, 0);
737
1824
 
738
- // 4단계: XML 조립
739
- const tblBfId = grid.props.defaultStroke ? addBorderFill(ctx, grid.props.defaultStroke) : 2;
740
- let rowsXml = "";
1825
+ const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
1826
+ // 기본 테두리 BorderFillBank에서 실제 ID 조회
1827
+ const tblBfId = ctx.borderFillBank.addUniform(defStroke);
741
1828
 
1829
+ let rowsXml = "";
742
1830
  for (let ri = 0; ri < rowCount; ri++) {
743
1831
  let cellsXml = "";
744
1832
  for (let ci = 0; ci < colCount; ci++) {
745
1833
  const entry = tableMap[ri][ci];
746
- if (!entry || entry.type === 'absorbed') continue;
747
-
1834
+ if (!entry || entry.type === "absorbed") continue;
748
1835
  const cell = entry.cell!;
749
1836
  const cp = cell.props;
750
- let cellBfId = tblBfId;
751
-
752
- const hasPerSideBorder = cp.top || cp.bot || cp.left || cp.right;
753
- if (hasPerSideBorder || cp.bg) {
754
- const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
755
- cellBfId = hasPerSideBorder
756
- ? addBorderFillPerSide(ctx, cp.top ?? defStroke, cp.right ?? defStroke, cp.bot ?? defStroke, cp.left ?? defStroke, cp.bg)
757
- : addBorderFill(ctx, defStroke, cp.bg);
758
- }
759
1837
 
760
- let cellW = 0;
761
- for (let sc = ci; sc < ci + cell.cs && sc < colWidths.length; sc++) cellW += colWidths[sc];
762
- if (cellW === 0) cellW = defaultColW * cell.cs;
1838
+ // 테두리 — BorderFillBank에서 실제 ID 조회 (하드코딩 제거)
1839
+ const cellBfId = ctx.borderFillBank.addFromCellProps(cp, defStroke);
763
1840
 
764
- const cellInnerW = Math.max(cellW - 282, 100);
765
- const parasXml = cell.kids.map((p) => encodePara(p, ctx, "", cellInnerW)).join("");
1841
+ let cellW = 0;
1842
+ for (let sc = ci; sc < ci + cell.cs && sc < colWidths.length; sc++)
1843
+ cellW += colWidths[sc];
1844
+ if (!cellW) cellW = Math.round(totalW / colCount) * cell.cs;
1845
+
1846
+ const subListId = ctx.nextElementId++;
1847
+
1848
+ // 셀 여백 (기본 141)
1849
+ const padL = cp.padL !== undefined ? Metric.ptToHwp(cp.padL) : 141;
1850
+ const padR = cp.padR !== undefined ? Metric.ptToHwp(cp.padR) : 141;
1851
+ const padT = cp.padT !== undefined ? Metric.ptToHwp(cp.padT) : 141;
1852
+ const padB = cp.padB !== undefined ? Metric.ptToHwp(cp.padB) : 141;
1853
+
1854
+ const innerW = Math.max(cellW - padL - padR, 100);
1855
+ let parasXml = '';
1856
+ if (cell.kids.length > 0) {
1857
+ for (const kid of cell.kids) {
1858
+ if (kid.tag === 'grid') {
1859
+ // 중첩 표: <hp:p><hp:run><hp:tbl>...</hp:tbl></hp:run></hp:p> 형식으로 감싸기
1860
+ const { xml: tblXml } = buildGridXml(kid, ctx);
1861
+ const pid = ctx.nextElementId++;
1862
+ const rid = ctx.nextElementId++;
1863
+ parasXml += `<hp:p id="${pid}" paraPrIDRef="0" styleIDRef="0" paraTcId="0"><hp:run id="${rid}" charPrIDRef="0" charTcId="0">${tblXml}</hp:run></hp:p>`;
1864
+ } else {
1865
+ parasXml += encodeParaPositioned(kid, ctx, 0, '', innerW).xml;
1866
+ }
1867
+ }
1868
+ } else {
1869
+ parasXml = `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" paraTcId="0"><hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run></hp:p>`;
1870
+ }
766
1871
 
767
- cellsXml += `<hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBfId}">` +
768
- `<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="${cp.va === "mid" ? "CENTER" : cp.va === "bot" ? "BOTTOM" : "TOP"}" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">${parasXml}</hp:subList>` +
769
- `<hp:cellAddr colAddr="${ci}" rowAddr="${ri}"/><hp:cellSpan colSpan="${cell.cs}" rowSpan="${cell.rs}"/><hp:cellSz width="${cellW}" height="${rowHeights[ri]}"/><hp:cellMargin left="141" right="141" top="141" bottom="141"/></hp:tc>`;
1872
+ const vAlign =
1873
+ cp.va === "mid" ? "CENTER" : cp.va === "bot" ? "BOTTOM" : "TOP";
1874
+
1875
+ cellsXml +=
1876
+ `<hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBfId}">` +
1877
+ `<hp:cellAddr colAddr="${ci}" rowAddr="${ri}"/>` +
1878
+ `<hp:cellSpan colSpan="${cell.cs}" rowSpan="${cell.rs}"/>` +
1879
+ `<hp:cellSz width="${cellW}" height="${rowHeights[ri]}"/>` +
1880
+ `<hp:cellMargin left="${padL}" right="${padR}" top="${padT}" bottom="${padB}"/>` +
1881
+ `<hp:subList id="${subListId}" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="${vAlign}" ` +
1882
+ `linkListIDRef="0" linkListNextIDRef="0" textWidth="${innerW}" textHeight="${Math.max(100, rowHeights[ri] - padT - padB)}" hasTextRef="0" hasNumRef="0">` +
1883
+ parasXml +
1884
+ `</hp:subList>` +
1885
+ `</hp:tc>`;
770
1886
  }
771
1887
  rowsXml += `<hp:tr>${cellsXml}</hp:tr>`;
772
1888
  }
773
1889
 
1890
+ // 표 정렬 처리
1891
+ const alignMap: Record<string, string> = {
1892
+ left: 'LEFT', right: 'RIGHT', center: 'CENTER', justify: 'JUSTIFY',
1893
+ };
1894
+ const horzAlign = alignMap[grid.props.align ?? 'left'] ?? 'LEFT';
1895
+
774
1896
  const headerRow = grid.props.headerRow ? ' repeatHeader="1"' : "";
775
- return `<hp:tbl id="${ctx.nextElementId++}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE"${headerRow} rowCnt="${rowCount}" colCnt="${colCount}" cellSpacing="0" borderFillIDRef="${tblBfId}" noAdjust="0"><hp:sz width="${actualTotal}" widthRelTo="ABSOLUTE" height="${totalTableHeight}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/><hp:outMargin left="138" right="138" top="138" bottom="138"/><hp:inMargin left="138" right="138" top="138" bottom="138"/>${rowsXml}</hp:tbl>`;
1897
+ const xml =
1898
+ `<hp:tbl id="${ctx.nextElementId++}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE"${headerRow} rowCnt="${rowCount}" colCnt="${colCount}" cellSpacing="0" borderFillIDRef="${tblBfId}" noAdjust="0">` +
1899
+ `<hp:sz width="${actualTotal}" widthRelTo="ABSOLUTE" height="${totalH}" heightRelTo="ABSOLUTE" protect="0"/>` +
1900
+ `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="${horzAlign}" vertOffset="0" horzOffset="0"/>` +
1901
+ `<hp:outMargin left="138" right="138" top="138" bottom="138"/>` +
1902
+ `<hp:inMargin left="0" right="0" top="0" bottom="0"/>` +
1903
+ rowsXml +
1904
+ `</hp:tbl>`;
1905
+
1906
+ return { xml, height: totalH };
1907
+ }
1908
+
1909
+ function estimateCellHeight(cell: CellNode, ctx: HwpxCtx): number {
1910
+ const topPad = 141;
1911
+ const botPad = 141;
1912
+ let h = 0;
1913
+ for (const kid of cell.kids) {
1914
+ if (kid.tag === 'grid') {
1915
+ // 중첩 표 높이는 최소값으로 처리
1916
+ h += 1600;
1917
+ continue;
1918
+ }
1919
+ const para = kid;
1920
+ const fs = fontSizeForPara(para, ctx);
1921
+ const ppId = ctx.paraPrMap.get(paraPrKey(para.props));
1922
+ const pp = ppId !== undefined ? ctx.paraPrs[ppId] : null;
1923
+ const ls = pp?.lineSpacing ?? 160;
1924
+ const before = pp?.prevHwp ?? 0;
1925
+ const after = pp?.nextHwp ?? 0;
1926
+ h += Math.round((fs * ls) / 100) + before + after;
1927
+ }
1928
+ if (!h) h = Math.round(1000 * 1.6);
1929
+ return h + topPad + botPad;
776
1930
  }
777
1931
 
1932
+ // ─── 미리보기 텍스트 추출 ────────────────────────────────────
1933
+
778
1934
  function extractPreviewText(sheet?: SheetNode): string {
779
1935
  if (!sheet) return "";
780
1936
  const lines: string[] = [];
781
1937
  for (const kid of sheet.kids) {
782
1938
  if (kid.tag === "para") {
783
1939
  const text = kid.kids
784
- .map((k) => {
785
- if (k.tag === "span")
786
- return k.kids
787
- .map((c) => (c.tag === "txt" ? c.content : ""))
788
- .join("");
789
- return "";
790
- })
1940
+ .flatMap((k) =>
1941
+ k.tag === "span"
1942
+ ? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
1943
+ : [],
1944
+ )
791
1945
  .join("");
792
1946
  if (text) lines.push(text);
793
1947
  } else if (kid.tag === "grid") {
794
1948
  for (const row of kid.kids) {
795
1949
  const cells = row.kids.map((cell) =>
796
1950
  cell.kids
797
- .map((p) =>
798
- p.kids
799
- .map((k) => {
800
- if (k.tag === "span")
801
- return k.kids
802
- .map((c) => (c.tag === "txt" ? c.content : ""))
803
- .join("");
804
- return "";
805
- })
806
- .join(""),
1951
+ .flatMap((p) =>
1952
+ p.tag === 'para'
1953
+ ? p.kids.flatMap((k) =>
1954
+ k.tag === "span"
1955
+ ? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
1956
+ : [],
1957
+ )
1958
+ : [],
807
1959
  )
808
1960
  .join(""),
809
1961
  );
@@ -814,16 +1966,18 @@ function extractPreviewText(sheet?: SheetNode): string {
814
1966
  return lines.join("\r\n");
815
1967
  }
816
1968
 
1969
+ // ─── XML 이스케이프 ──────────────────────────────────────────
1970
+
817
1971
  function esc(s: string): string {
818
1972
  if (!s) return "";
819
- // 1. 내부 처리용 플레이스홀더(__EXT_0__ ) 제거
820
- s = s.replace(/__EXT_\d+__/g, "");
821
- // 2. 글자 깨짐을 유발하는 쓰레기값 및 BOM 기호 명시적 제거
822
- s = s.replace(/湰灧/g, "");
823
- s = s.replace(/\uFEFF/g, "");
824
- // 3. XML 1.0에서 허용하지 않는 보이지 않는 제어문자 모두 제거
825
- s = s.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]/g, "");
826
-
1973
+ s = s.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
1974
+ s = s.replace(/湰灧/g, "").replace(/\uFEFF/g, "");
1975
+ // XML 1.0 비허용 제어문자 제거
1976
+ // eslint-disable-next-line no-control-regex
1977
+ s = s.replace(
1978
+ /[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu,
1979
+ "",
1980
+ );
827
1981
  return TextKit.escapeXml(s);
828
1982
  }
829
1983