hwpkit-dev 0.0.2 → 0.0.3

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