hwpkit-dev 0.0.1 → 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 (40) hide show
  1. package/ .npmignore +4 -1
  2. package/README.md +39 -2
  3. package/dist/index.d.mts +74 -16
  4. package/dist/index.d.ts +70 -16
  5. package/dist/index.js +4985 -698
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +4981 -698
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +4 -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 +867 -150
  18. package/src/decoders/html/HtmlDecoder.ts +366 -0
  19. package/src/decoders/hwp/HwpScanner.ts +477 -88
  20. package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
  21. package/src/decoders/md/MdDecoder.ts +4 -4
  22. package/src/encoders/docx/DocxEncoder.ts +600 -295
  23. package/src/encoders/html/HtmlEncoder.ts +203 -0
  24. package/src/encoders/hwp/HwpEncoder.ts +1647 -398
  25. package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
  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 +117 -30
  29. package/src/index.ts +1 -0
  30. package/src/model/builders.ts +8 -6
  31. package/src/model/doc-props.ts +19 -5
  32. package/src/model/doc-tree.ts +13 -5
  33. package/src/pipeline/Pipeline.ts +21 -4
  34. package/src/pipeline/registry.ts +13 -2
  35. package/src/safety/StyleBridge.ts +52 -7
  36. package/src/toolkit/ArchiveKit.ts +56 -0
  37. package/src/toolkit/StyleMapper.ts +221 -0
  38. package/src/toolkit/UnitConverter.ts +138 -0
  39. package/src/toolkit/XmlKit.ts +0 -5
  40. package/test-styling.ts +210 -0
@@ -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,76 +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)
371
+ interface BinEntry {
372
+ id: string; // "BIN0001"
373
+ name: string; // "BIN0001.png"
374
+ data: Uint8Array;
91
375
  }
92
376
 
93
377
  function charPrKey(p: TextProps): string {
94
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 ?? ""}`;
95
379
  }
96
380
 
381
+ /**
382
+ * ParaProps 를 해시 키로 변환 (동일 포맷팅 감지용)
383
+ * null/undefined는 0 으로 처리하여 일관성 유지
384
+ */
97
385
  function paraPrKey(p: ParaProps): string {
98
- 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 ?? ""}`;
99
387
  }
100
388
 
101
- function registerFont(name: string, ctx: HwpxCtx): number {
102
- const n = name || "굴림체";
103
- const existing = ctx.fontMap.get(n);
104
- if (existing !== undefined) return existing;
105
- const id = ctx.fonts.length;
106
- ctx.fonts.push(n);
107
- ctx.fontMap.set(n, id);
108
- 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[];
109
406
  }
110
407
 
111
408
  function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
@@ -113,11 +410,11 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
113
410
  const existing = ctx.charPrMap.get(key);
114
411
  if (existing !== undefined) return existing;
115
412
 
116
- const fontName = safeFontToKr(props.font) || "굴림체";
117
- const fontId = registerFont(fontName, ctx);
118
-
413
+ const rawFont = props.font ?? "함초롬바탕";
414
+ const { hangulId, latinId } = ctx.fontBank.registerFont(rawFont);
119
415
  const id = ctx.charPrs.length;
120
- const def: CharPrDef = {
416
+
417
+ ctx.charPrs.push({
121
418
  id,
122
419
  height: Metric.ptToHHeight(props.pt ?? 10),
123
420
  bold: !!props.b,
@@ -125,11 +422,10 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
125
422
  underline: props.u ? "BOTTOM" : "NONE",
126
423
  strikeout: props.s ? "SOLID" : "NONE",
127
424
  textColor: props.color ? `#${props.color}` : "#000000",
128
- fontName,
129
- fontId,
425
+ hangulId,
426
+ latinId,
130
427
  bg: props.bg,
131
- };
132
- ctx.charPrs.push(def);
428
+ });
133
429
  ctx.charPrMap.set(key, id);
134
430
  return id;
135
431
  }
@@ -143,9 +439,11 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
143
439
  const def: ParaPrDef = {
144
440
  id,
145
441
  align: (props.align ?? "left").toUpperCase(),
146
- intentHwp: props.indentPt ? Metric.ptToHwp(props.indentPt) : 0,
147
- prevHwp: props.spaceBefore ? Metric.ptToHwp(props.spaceBefore) : 0,
148
- 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),
149
447
  lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
150
448
  };
151
449
  if (props.listOrd !== undefined) {
@@ -157,33 +455,7 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
157
455
  return id;
158
456
  }
159
457
 
160
- // ─── Pre-scan: collect all charPr/paraPr used ───────────────
161
-
162
- function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
163
- for (const kid of kids) {
164
- if (kid.tag === "para") scanPara(kid, ctx);
165
- else if (kid.tag === "grid") scanGrid(kid, ctx);
166
- }
167
- }
168
-
169
- function scanPara(para: ParaNode, ctx: HwpxCtx): void {
170
- registerParaPr(para.props, ctx);
171
- for (const kid of para.kids) {
172
- if (kid.tag === "span") registerCharPr(kid.props, ctx);
173
- else if (kid.tag === "img") registerImage(kid, ctx);
174
- }
175
- }
176
-
177
- function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
178
- for (const row of grid.kids)
179
- for (const cell of row.kids) for (const p of cell.kids) scanPara(p, ctx);
180
- }
181
-
182
- function scanParas(paras: ParaNode[], ctx: HwpxCtx): void {
183
- for (const p of paras) scanPara(p, ctx);
184
- }
185
-
186
- // ─── Image handling ─────────────────────────────────────────
458
+ // ─── 이미지 등록 ─────────────────────────────────────────────
187
459
 
188
460
  function mimeToExt(mime: string): string {
189
461
  if (mime.includes("jpeg")) return "jpg";
@@ -203,139 +475,269 @@ function registerImage(img: ImgNode, ctx: HwpxCtx): void {
203
475
  ctx.imgMap.set(img, id);
204
476
  }
205
477
 
206
- // ─── 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
+ };
207
490
 
208
- function addBorderFill(
491
+ function registerStyle(
492
+ styleId: string,
493
+ paraPrId: number,
494
+ charPrId: number,
209
495
  ctx: HwpxCtx,
210
- stroke?: Stroke,
211
- bgColor?: string,
212
- ): number {
213
- const id = ctx.borderFills.length + 1;
214
- const s = stroke ?? DEFAULT_STROKE;
215
- const kindMap: Record<string, string> = {
216
- solid: "SOLID",
217
- dash: "DASH",
218
- dot: "DOT",
219
- double: "DOUBLE",
220
- none: "NONE",
221
- };
222
- const type = kindMap[s.kind] ?? "SOLID";
223
- const w = `${(s.pt * 0.3528).toFixed(2)} mm`;
224
- const c = s.color.startsWith("#") ? s.color : `#${s.color}`;
496
+ ): void {
497
+ if (!styleId || ctx.styleIdToHwpxId.has(styleId)) return;
498
+ if (styleId === "Normal") {
499
+ ctx.styleIdToHwpxId.set(styleId, 0);
500
+ return;
501
+ }
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
+ });
511
+ }
225
512
 
226
- let fill = "";
227
- if (bgColor) {
228
- const bc = bgColor.startsWith("#") ? bgColor : `#${bgColor}`;
229
- fill = `<hc:fillBrush><hc:winBrush faceColor="${bc}" hatchColor="none" alpha="0"/></hc:fillBrush>`;
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
+ }
230
534
  }
535
+ scanKids(para.kids);
536
+ if (para.props.styleId)
537
+ registerStyle(para.props.styleId, paraPrId, firstCharPrId, ctx);
538
+ }
231
539
 
232
- 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="SOLID" width="0.1 mm" color="#000000"/>${fill}</hh:borderFill>`;
233
- ctx.borderFills.push({ id, xml });
234
- return id;
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
+ }
552
+ }
553
+ }
554
+
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
+ }
235
560
  }
236
561
 
237
- // ─── Encoder class ──────────────────────────────────────────
562
+ // ─── Encoder 클래스 ──────────────────────────────────────────
238
563
 
239
- export class HwpxEncoder implements Encoder {
240
- 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"]; }
241
567
 
242
568
  async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
243
569
  try {
244
570
  const sheet = doc.kids[0];
245
571
  const dims = normalizeDims(sheet?.dims ?? A4);
246
572
 
247
- // Available width = page width - left margin - right margin (in HWPUNIT)
248
- 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(
249
576
  Metric.ptToHwp(dims.wPt) -
250
- Metric.ptToHwp(dims.ml) -
251
- Metric.ptToHwp(dims.mr);
577
+ Metric.ptToHwp(safeML) -
578
+ Metric.ptToHwp(safeMR),
579
+ );
252
580
 
581
+ // 컨텍스트 초기화
253
582
  const ctx: HwpxCtx = {
583
+ fontBank: new LangFontBank(), // ANYTOHWP 방식 언어별 폰트
584
+ borderFillBank: new BorderFillBank(), // 하드코딩 없는 테두리 관리
254
585
  charPrs: [],
255
586
  charPrMap: new Map(),
256
587
  paraPrs: [],
257
588
  paraPrMap: new Map(),
258
- borderFills: [],
259
589
  bins: [],
260
590
  nextBinNum: 1,
261
591
  nextElementId: 10000,
262
592
  availableWidth,
263
- fonts: [],
264
- fontMap: new Map(),
265
593
  imgMap: new WeakMap(),
594
+ nextZOrder: 0,
595
+ styleIdToHwpxId: new Map(),
596
+ hwpxStyles: [],
266
597
  };
267
598
 
268
- // Default borderFill (id=1, no border)
269
- addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
270
- // Table border borderFill (id=2)
271
- addBorderFill(ctx, DEFAULT_STROKE);
272
- // Default no-border for text areas (id=3)
273
- addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
274
-
275
- // Register default charPr (id=0) and paraPr (id=0)
599
+ // id=0 기본 charPr/paraPr 등록
276
600
  registerCharPr({}, ctx);
277
601
  registerParaPr({}, ctx);
278
602
 
279
- // 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/이미지/테두리 사전 등록
280
614
  scanContent(sheet?.kids ?? [], ctx);
281
- if (sheet?.header) scanParas(sheet.header, ctx);
282
- 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);
283
617
 
284
- // 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));
285
621
  const previewText = extractPreviewText(sheet);
286
622
 
287
- // IMPORTANT: Generate section XML FIRST so that borderFills created
288
- // during table encoding are registered in ctx before headerXml runs.
289
- const sectionData = TextKit.encode(sectionXml(sheet, dims, ctx));
290
- const headerData = TextKit.encode(headerXml(dims, doc.meta, ctx));
291
-
292
- const entries: { name: string; data: Uint8Array }[] = [
293
- { name: "mimetype", data: TextKit.encode("application/hwp+zip") },
294
- { name: "version.xml", data: TextKit.encode(VERSION_XML) },
295
- { name: "Contents/header.xml", data: headerData },
296
- { name: "Contents/section0.xml", data: sectionData },
297
- { name: "Preview/PrvText.txt", data: TextKit.encode(previewText) },
298
- { name: "settings.xml", data: TextKit.encode(SETTINGS_XML) },
299
- { 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
+ },
300
645
  {
301
646
  name: "Contents/content.hpf",
302
- 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",
303
669
  },
304
- { name: "META-INF/container.xml", data: TextKit.encode(CONTAINER_XML) },
305
- { name: "META-INF/manifest.xml", data: TextKit.encode(MANIFEST_XML) },
306
670
  ];
307
671
 
308
672
  for (const bin of ctx.bins) {
309
- 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 });
310
683
  }
311
684
 
312
- return succeed(await ArchiveKit.zip(entries));
685
+ return succeed(await this.zip(entries));
313
686
  } catch (e: any) {
314
- return fail(`HWPX encode error: ${e?.message ?? String(e)}`);
687
+ return fail(`HWPX 인코딩 오류: ${e?.message ?? String(e)}`);
315
688
  }
316
689
  }
317
690
  }
318
691
 
319
- // ─── Constants ──────────────────────────────────────────────
320
-
321
- 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"/>`;
322
-
323
- 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>`;
324
-
325
- 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>`;
326
-
327
- 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"/>`;
328
-
329
- 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>`;
330
-
331
- 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 {
332
727
  const title = esc(meta?.title ?? "");
333
- 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;
334
735
 
335
736
  let items =
336
- `<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"/>` +
337
738
  `<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>` +
338
- `<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
739
+ `<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
740
+
339
741
  for (const bin of ctx.bins) {
340
742
  const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
341
743
  const ct =
@@ -348,411 +750,1066 @@ function contentHpf(ctx: HwpxCtx, meta?: any): string {
348
750
  : "image/bmp";
349
751
  items += `<opf:item id="${bin.id}" href="BinData/${bin.name}" media-type="${ct}" isEmbeded="1"/>`;
350
752
  }
351
- 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>`;
352
- }
353
-
354
- // ─── header.xml ─────────────────────────────────────────────
355
-
356
- function headerXml(dims: PageDims, meta: any, ctx: HwpxCtx): string {
357
- // Font face definitions — register all unique fonts per language group
358
- const fontCount = ctx.fonts.length || 1;
359
- const langs = [
360
- "HANGUL",
361
- "LATIN",
362
- "HANJA",
363
- "JAPANESE",
364
- "OTHER",
365
- "SYMBOL",
366
- "USER",
367
- ];
368
- let fontFaces = `<hh:fontfaces itemCnt="${langs.length}">`;
369
- for (const lang of langs) {
370
- fontFaces += `<hh:fontface lang="${lang}" fontCnt="${fontCount}">`;
371
- for (let fi = 0; fi < ctx.fonts.length; fi++) {
372
- 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>`;
373
- }
374
- if (ctx.fonts.length === 0) {
375
- 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>`;
376
- }
377
- fontFaces += `</hh:fontface>`;
378
- }
379
- fontFaces += `</hh:fontfaces>`;
380
753
 
381
- // 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 핵심 개선)
382
825
  let charPrXml = "";
383
826
  for (const cp of ctx.charPrs) {
384
827
  const bold = cp.bold ? "<hh:bold/>" : "";
385
828
  const italic = cp.italic ? "<hh:italic/>" : "";
386
- const fid = cp.fontId ?? 0;
387
- 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>`;
388
847
  }
389
848
 
390
- // ParaPr definitions
849
+ // paraPr 목록
391
850
  let paraPrXml = "";
392
851
  for (const pp of ctx.paraPrs) {
393
- 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>`;
394
- 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>`;
395
868
  }
396
869
 
397
- // BorderFill definitions
398
- const borderFillXml = ctx.borderFills.map((bf) => bf.xml).join("");
399
-
400
- 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
+ );
401
942
  }
402
943
 
403
- // ─── section0.xml ───────────────────────────────────────────
944
+ // ─── section0.xml ────────────────────────────────────────────
404
945
 
405
- 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(
406
1010
  sheet: SheetNode | undefined,
407
1011
  dims: PageDims,
408
1012
  ctx: HwpxCtx,
409
1013
  ): string {
1014
+ const secPrXml = buildSecPrXml(dims);
410
1015
  const kids = sheet?.kids ?? [];
1016
+ const hfRunXml = sheet ? buildHeaderFooterRunXml(sheet, dims, ctx) : "";
411
1017
 
412
- // First paragraph includes secPr
413
- // WIDELY = portrait (standard), NARROWLY = landscape
414
- 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;
415
1024
 
416
1025
  let contentXml = "";
417
- let isFirst = true;
1026
+ let vertPos = 0;
418
1027
 
419
- 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 : "";
420
1033
 
421
- for (const kid of kids) {
422
1034
  if (kid.tag === "para") {
423
- contentXml += encodePara(kid, ctx, isFirst ? secPr : "");
424
- 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;
425
1045
  } else if (kid.tag === "grid") {
426
- // Grid is embedded inside a paragraph's run.
427
- // When secPr is present, put it in a separate run (matching real HWPX structure).
428
- const gridXml = encodeGrid(kid, ctx);
429
- const prefix = isFirst ? secPr : "";
430
- const runsXml = prefix
431
- ? `<hp:run charPrIDRef="0">${prefix}</hp:run><hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`
432
- : `<hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`;
433
- contentXml += `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">${runsXml}${defaultLineseg}</hp:p>`;
434
- isFirst = false;
1046
+ const { xml, nextVertPos } = encodeGridPositioned(
1047
+ kid,
1048
+ ctx,
1049
+ vertPos,
1050
+ curSecPr,
1051
+ curHfRun,
1052
+ );
1053
+ contentXml += xml;
1054
+ vertPos = nextVertPos;
435
1055
  }
436
1056
  }
437
1057
 
438
- // If empty, add one empty paragraph with secPr
439
- if (contentXml === "") {
440
- 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>`;
441
1070
  }
442
1071
 
443
- 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>`;
444
1073
  }
445
1074
 
446
- function estimateCellHeight(cell: CellNode, ctx: HwpxCtx): number {
447
- const topPad = 141;
448
- const botPad = 141;
449
- let contentHeight = 0;
450
- for (const para of cell.kids) {
451
- const fontSize = getFontSizeForPara(para, ctx);
452
- const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props));
453
- const paraPr = paraPrId !== undefined ? ctx.paraPrs[paraPrId] : null;
454
- const lineSpacing = paraPr ? paraPr.lineSpacing : 160;
455
- const spaceBefore = paraPr ? paraPr.prevHwp : 0;
456
- const spaceAfter = paraPr ? paraPr.nextHwp : 0;
457
- const lineHeight = Math.round((fontSize * lineSpacing) / 100);
458
- contentHeight += lineHeight + spaceBefore + spaceAfter;
459
- }
460
- if (contentHeight === 0) contentHeight = Math.round(1000 * 1.6); // 10pt @ 160%
461
- return contentHeight + topPad + botPad;
462
- }
463
-
464
- function encodePara(
465
- para: ParaNode,
466
- ctx: HwpxCtx,
467
- secPr: string = "",
468
- availWidth?: number,
469
- ): string {
470
- const paraPrId = registerParaPr(para.props, ctx);
471
-
472
- // Detect page break: paragraph starts on new page if any span contains a pb node
473
- const hasPageBreak = para.kids.some(
474
- (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>`
475
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
+ }
476
1162
 
477
- let runs = "";
1163
+ return {
1164
+ xml: `<hp:linesegarray>${linesegParts.join("")}</hp:linesegarray>`,
1165
+ totalHeight: lineCount * vertsizeLine,
1166
+ };
1167
+ }
1168
+
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 {
478
1186
  for (const kid of para.kids) {
479
1187
  if (kid.tag === "span") {
480
- runs += encodeRun(kid, ctx);
481
- } else if (kid.tag === "img") {
482
- 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;
483
1190
  }
484
1191
  }
1192
+ return 1000; // 기본 10pt
1193
+ }
485
1194
 
486
- // If no runs, add default empty run
487
- if (runs === "") {
488
- runs = `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
489
- }
1195
+ // ─── 단락 인코딩 ─────────────────────────────────────────────
490
1196
 
491
- // Inject secPr into first run
492
- if (secPr) {
493
- // Insert secPr after the first charPrIDRef attribute close
494
- const firstRunEnd = runs.indexOf(">");
495
- if (firstRunEnd > -1) {
496
- runs =
497
- runs.substring(0, firstRunEnd + 1) +
498
- secPr +
499
- runs.substring(firstRunEnd + 1);
500
- }
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);
501
1209
  }
502
1210
 
503
- // Compute lineseg values based on font size and actual line spacing.
504
- // In HWPX, vertsize = font height and spacing = extra gap = fontSize * (lineSpacing/100 - 1).
505
- // Total rendered line height = vertsize + spacing.
506
- 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);
507
1216
  const paraPr = ctx.paraPrs[paraPrId];
508
1217
  const lineSpacing = paraPr?.lineSpacing ?? 160;
509
- const vertsize = fontSize;
510
- const textheight = fontSize;
511
- const baseline = Math.round(fontSize * 0.85);
512
1218
  const spacing = Math.max(0, Math.round(fontSize * (lineSpacing / 100 - 1)));
513
- const horzsize = availWidth ?? ctx.availableWidth;
514
- 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
+ );
515
1261
 
516
- return `<hp:p id="0" paraPrIDRef="${paraPrId}" styleIDRef="0" pageBreak="${hasPageBreak ? "1" : "0"}" columnBreak="0" merged="0">${runs}${linesegarray}</hp:p>`;
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>`;
1270
+
1271
+ return { xml, nextVertPos: vertPos + totalHeight };
517
1272
  }
518
1273
 
519
- /** Get the font size (in HWPX height units, 1000=10pt) for lineseg computation */
520
- function getFontSizeForPara(para: ParaNode, ctx: HwpxCtx): number {
521
- 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) {
522
1380
  if (kid.tag === "span") {
523
- const charPrId = ctx.charPrMap.get(charPrKey(kid.props));
524
- if (charPrId !== undefined && ctx.charPrs[charPrId]) {
525
- 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();
526
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;
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);
527
1409
  }
528
1410
  }
529
- return 1000; // default 10pt
530
- }
531
1411
 
532
- function encodeRun(span: SpanNode, ctx: HwpxCtx): string {
533
- const charPrId = registerCharPr(span.props, ctx);
1412
+ flushRun();
1413
+ return xml;
1414
+ }
534
1415
 
535
- const parts: string[] = [];
1416
+ /** hp:run 내부의 태그들만 생성 (span용) */
1417
+ function encodeRunInner(span: SpanNode): string {
1418
+ let xml = "";
536
1419
  for (const kid of span.kids) {
537
1420
  if (kid.tag === "txt") {
538
- if (kid.content) {
539
- parts.push(`<hp:t>${esc(kid.content)}</hp:t>`);
540
- } else {
541
- parts.push(`<hp:t></hp:t>`);
542
- }
543
- } else if (kid.tag === "pagenum") {
544
- const fmt =
545
- kid.format === "roman"
546
- ? "ROMAN_LOWER"
547
- : kid.format === "romanCaps"
548
- ? "ROMAN_UPPER"
549
- : "DIGIT";
550
- 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>`;
551
1423
  } else if (kid.tag === "br") {
552
- 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}"/>`;
553
1429
  }
554
- // pb is handled at paragraph level (pageBreak attribute), skip here
555
1430
  }
1431
+ return xml;
1432
+ }
556
1433
 
557
- 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;
558
1461
  }
559
1462
 
560
- // HWPX textWrap 매핑
561
- const WRAP_HWPX: Record<string, string> = {
562
- inline: 'TOP_AND_BOTTOM',
563
- square: 'SQUARE',
564
- tight: 'BOTH_SIDES',
565
- through: 'BOTH_SIDES',
566
- none: 'FRONT_TEXT',
567
- behind: 'BEHIND_TEXT',
568
- front: 'FRONT_TEXT',
569
- };
570
- // textFlow 매핑 (wrap 타입별)
571
- const TEXT_FLOW_HWPX: Record<string, string> = {
572
- inline: 'BOTH_SIDES',
573
- square: 'LARGEST_ONLY',
574
- tight: 'BOTH_SIDES',
575
- through: 'BOTH_SIDES',
576
- none: 'BOTH_SIDES',
577
- behind: 'BOTH_SIDES',
578
- front: 'BOTH_SIDES',
579
- };
580
- const HORZ_RELTO_HWPX: Record<string, string> = {
581
- 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",
582
1473
  };
583
- const VERT_RELTO_HWPX: Record<string, string> = {
584
- 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",
585
1482
  };
586
1483
 
587
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
+
588
1490
  const binId = ctx.imgMap.get(img);
589
1491
  if (!binId) return "";
590
1492
 
591
- const charPrId = registerCharPr({}, ctx);
592
- const w = Metric.ptToHwp(img.w);
593
- const h = Metric.ptToHwp(img.h);
594
- const cx = Math.round(w / 2);
595
- 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;
596
1497
 
597
- const layout = img.layout;
598
- const isInline = !layout || layout.wrap === 'inline';
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
+ }
599
1506
 
600
- const textWrap = layout ? (WRAP_HWPX[layout.wrap] ?? 'TOP_AND_BOTTOM') : 'TOP_AND_BOTTOM';
601
- const textFlow = layout ? (TEXT_FLOW_HWPX[layout.wrap] ?? 'BOTH_SIDES') : 'BOTH_SIDES';
602
- const treatAsChar = isInline ? '1' : '0';
603
- const flowWithText = '1';
604
- // behind/front/inline 이미지는 다른 객체와 겹침 허용 불필요; square/tight는 허용
605
- const allowOverlap = (!isInline && layout?.wrap !== 'behind' && layout?.wrap !== 'front') ? '1' : '0';
1507
+ // 가용 너비 초과 방지 (비율 유지)
1508
+ if (wHwp > ctx.availableWidth) {
1509
+ hHwp = Math.round((hHwp * ctx.availableWidth) / wHwp);
1510
+ wHwp = ctx.availableWidth;
1511
+ }
606
1512
 
607
- const horzRelTo = layout?.horzRelTo ? (HORZ_RELTO_HWPX[layout.horzRelTo] ?? 'PARA') : 'PARA';
608
- const vertRelTo = layout?.vertRelTo ? (VERT_RELTO_HWPX[layout.vertRelTo] ?? 'PARA') : 'PARA';
1513
+ // 회전 중심점 (rotation center) 계산: 이미지 중앙을 기준으로 회전
1514
+ const rotationCenterX = Math.round(wHwp / 2);
1515
+ const rotationCenterY = Math.round(hHwp / 2);
609
1516
 
610
- const ALIGN_H: Record<string, string> = { left: 'LEFT', center: 'CENTER', right: 'RIGHT' };
611
- const ALIGN_V: Record<string, string> = { top: 'TOP', center: 'CENTER', bottom: 'BOTTOM' };
612
- const horzAlign = layout?.horzAlign ? (ALIGN_H[layout.horzAlign] ?? 'LEFT') : 'LEFT';
613
- const vertAlign = layout?.vertAlign ? (ALIGN_V[layout.vertAlign] ?? 'TOP') : 'TOP';
614
- const horzOffset = layout?.xPt != null ? Metric.ptToHwp(layout.xPt) : 0;
615
- const vertOffset = layout?.yPt != null ? Metric.ptToHwp(layout.yPt) : 0;
1517
+ const layout = img.layout;
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";
1523
+ const zOrder = ctx.nextZOrder++;
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
+ );
1554
+ }
616
1555
 
617
- // hp:pic children must follow the exact HWPX spec order.
618
- return `<hp:run charPrIDRef="${charPrId}"><hp:pic id="${ctx.nextElementId++}" zOrder="0" 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>`;
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>`;
619
1562
  }
620
1563
 
621
- function encodeGrid(grid: GridNode, ctx: HwpxCtx): string {
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 } {
622
1600
  const rowCount = grid.kids.length;
1601
+ // ... (기존 tableMap 생성 로직 동일)
623
1602
 
624
- // Compute true column count: max total colSpan across all rows
625
- let colCount = 0;
626
- for (const row of grid.kids) {
627
- let rowCols = 0;
628
- for (const cell of row.kids) rowCols += cell.cs;
629
- if (rowCols > colCount) colCount = rowCols;
1603
+ // 가상 2D 병합 처리
1604
+ interface CellEntry {
1605
+ type: "real" | "absorbed";
1606
+ cell?: CellNode;
630
1607
  }
631
- if (colCount === 0) colCount = grid.kids[0]?.kids.length ?? 1;
1608
+ const tableMap: CellEntry[][] = Array.from({ length: rowCount }, () => []);
1609
+
1610
+ for (let ri = 0; ri < rowCount; ri++) {
1611
+ let ci = 0;
1612
+ for (const cell of grid.kids[ri].kids) {
1613
+ while (tableMap[ri][ci]) ci++;
1614
+ tableMap[ri][ci] = { type: "real", cell };
1615
+ for (let rr = 0; rr < cell.rs; rr++) {
1616
+ const tri = ri + rr;
1617
+ if (tri >= rowCount) break;
1618
+ for (let cc = 0; cc < cell.cs; cc++) {
1619
+ if (rr === 0 && cc === 0) continue;
1620
+ tableMap[tri][ci + cc] = { type: "absorbed" };
1621
+ }
1622
+ }
1623
+ ci += cell.cs;
1624
+ }
1625
+ }
1626
+
1627
+ let colCount = 0;
1628
+ for (let ri = 0; ri < rowCount; ri++)
1629
+ colCount = Math.max(colCount, tableMap[ri].length);
1630
+ if (colCount === 0) colCount = 1;
632
1631
 
633
- // Calculate column widths in HWPUNIT
634
- const totalWidth = ctx.availableWidth;
635
- const defaultColW = Math.round(totalWidth / (colCount || 1));
1632
+ // 컬럼 너비 계산 (Bug 6: 균등 배분 금지, 원본 보존)
1633
+ const totalW = ctx.availableWidth;
636
1634
  const colWidths: number[] = [];
1635
+
637
1636
  if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
638
- // Fill zero-width columns by distributing remaining space
639
- const srcPt = [...grid.props.colWidths];
640
- const knownTotal = srcPt.filter((w) => w > 0).reduce((s, w) => s + w, 0);
641
- const zeroCount = srcPt.filter((w) => w <= 0).length;
642
- const remaining = Math.max(0, Metric.hwpToPt(totalWidth) - knownTotal);
643
- const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
644
- for (let i = 0; i < srcPt.length; i++) {
645
- if (srcPt[i] <= 0)
646
- 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));
647
1640
  }
648
- for (const wPt of srcPt) colWidths.push(Metric.ptToHwp(wPt));
649
1641
  } else {
650
- 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);
651
1645
  }
652
- // Scale to fit available width
1646
+
1647
+ // 본문 너비 초과 시에만 비율 축소 보정
653
1648
  const rawTotal = colWidths.reduce((s, w) => s + w, 0);
654
- if (rawTotal > totalWidth * 1.05) {
655
- const scale = totalWidth / rawTotal;
656
- for (let i = 0; i < colWidths.length; i++)
657
- 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
+ }
658
1654
  }
659
1655
  const actualTotal = colWidths.reduce((s, w) => s + w, 0);
660
1656
 
661
- // Table borderFillIDRef
662
- const tblBfId = grid.props.defaultStroke
663
- ? addBorderFill(ctx, grid.props.defaultStroke)
664
- : 2; // default table border
665
-
666
- // Pre-calculate row heights (max cell height per row)
1657
+ // 높이 계산
667
1658
  const rowHeights: number[] = [];
668
- for (const row of grid.kids) {
669
- let maxH = 0;
670
- for (const cell of row.kids) {
671
- const h = estimateCellHeight(cell, ctx);
672
- if (h > maxH) maxH = h;
1659
+ for (let ri = 0; ri < rowCount; ri++) {
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));
1665
+ } else {
1666
+ let maxH = 0;
1667
+ for (let ci = 0; ci < colCount; ci++) {
1668
+ const entry = tableMap[ri][ci];
1669
+ if (entry?.type === "real") {
1670
+ const h = estimateCellHeight(entry.cell!, ctx);
1671
+ if (h > maxH) maxH = h;
1672
+ }
1673
+ }
1674
+ rowHeights.push(maxH || Math.round(1000 * 1.6));
673
1675
  }
674
- rowHeights.push(maxH);
675
1676
  }
676
- const totalTableHeight = rowHeights.reduce((s, h) => s + h, 0);
1677
+ const totalH = rowHeights.reduce((s, h) => s + h, 0);
1678
+
1679
+ const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
1680
+ // 표 기본 테두리 — BorderFillBank에서 실제 ID 조회
1681
+ const tblBfId = ctx.borderFillBank.addUniform(defStroke);
677
1682
 
678
- // Rows
679
1683
  let rowsXml = "";
680
- for (let ri = 0; ri < grid.kids.length; ri++) {
681
- const row = grid.kids[ri];
682
- const rowH = rowHeights[ri];
1684
+ for (let ri = 0; ri < rowCount; ri++) {
683
1685
  let cellsXml = "";
684
- let colIdx = 0;
685
- for (let ci = 0; ci < row.kids.length; ci++) {
686
- const cell = row.kids[ci];
687
-
688
- // Cell borderFill
689
- let cellBfId = tblBfId;
690
- if (cell.props.bg) {
691
- cellBfId = addBorderFill(
692
- ctx,
693
- grid.props.defaultStroke ?? DEFAULT_STROKE,
694
- cell.props.bg,
695
- );
696
- }
1686
+ for (let ci = 0; ci < colCount; ci++) {
1687
+ const entry = tableMap[ri][ci];
1688
+ if (!entry || entry.type === "absorbed") continue;
1689
+ const cell = entry.cell!;
1690
+ const cp = cell.props;
1691
+
1692
+ // 셀 테두리 — BorderFillBank에서 실제 ID 조회 (하드코딩 제거)
1693
+ const cellBfId = ctx.borderFillBank.addFromCellProps(cp, defStroke);
697
1694
 
698
- // Calculate cell width from column widths
699
1695
  let cellW = 0;
700
- for (
701
- let sc = colIdx;
702
- sc < colIdx + cell.cs && sc < colWidths.length;
703
- sc++
704
- )
1696
+ for (let sc = ci; sc < ci + cell.cs && sc < colWidths.length; sc++)
705
1697
  cellW += colWidths[sc];
706
- if (cellW === 0) cellW = defaultColW * cell.cs;
707
-
708
- // Cell inner width for lineseg (subtract left + right cell margins)
709
- const cellInnerW = Math.max(cellW - 282, 100);
710
-
711
- // Encode cell paragraphs with correct inner width
712
- const parasXml = cell.kids
713
- .map((p) => encodePara(p, ctx, "", cellInnerW))
714
- .join("");
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
+ }
715
1725
 
716
- cellsXml += `<hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBfId}"><hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="${cell.props.va === "mid" ? "CENTER" : cell.props.va === "bot" ? "BOTTOM" : "TOP"}" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">${parasXml}</hp:subList><hp:cellAddr colAddr="${colIdx}" rowAddr="${ri}"/><hp:cellSpan colSpan="${cell.cs}" rowSpan="${cell.rs}"/><hp:cellSz width="${cellW}" height="${rowH}"/><hp:cellMargin left="141" right="141" top="141" bottom="141"/></hp:tc>`;
717
- colIdx += cell.cs;
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>`;
718
1740
  }
719
1741
  rowsXml += `<hp:tr>${cellsXml}</hp:tr>`;
720
1742
  }
721
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
+
722
1750
  const headerRow = grid.props.headerRow ? ' repeatHeader="1"' : "";
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
+ }
723
1762
 
724
- 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>`;
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;
725
1784
  }
726
1785
 
1786
+ // ─── 미리보기 텍스트 추출 ────────────────────────────────────
1787
+
727
1788
  function extractPreviewText(sheet?: SheetNode): string {
728
1789
  if (!sheet) return "";
729
1790
  const lines: string[] = [];
730
1791
  for (const kid of sheet.kids) {
731
1792
  if (kid.tag === "para") {
732
1793
  const text = kid.kids
733
- .map((k) => {
734
- if (k.tag === "span")
735
- return k.kids
736
- .map((c) => (c.tag === "txt" ? c.content : ""))
737
- .join("");
738
- return "";
739
- })
1794
+ .flatMap((k) =>
1795
+ k.tag === "span"
1796
+ ? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
1797
+ : [],
1798
+ )
740
1799
  .join("");
741
1800
  if (text) lines.push(text);
742
1801
  } else if (kid.tag === "grid") {
743
1802
  for (const row of kid.kids) {
744
1803
  const cells = row.kids.map((cell) =>
745
1804
  cell.kids
746
- .map((p) =>
747
- p.kids
748
- .map((k) => {
749
- if (k.tag === "span")
750
- return k.kids
751
- .map((c) => (c.tag === "txt" ? c.content : ""))
752
- .join("");
753
- return "";
754
- })
755
- .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
+ : [],
756
1813
  )
757
1814
  .join(""),
758
1815
  );
@@ -763,7 +1820,18 @@ function extractPreviewText(sheet?: SheetNode): string {
763
1820
  return lines.join("\r\n");
764
1821
  }
765
1822
 
1823
+ // ─── XML 이스케이프 ──────────────────────────────────────────
1824
+
766
1825
  function esc(s: string): string {
1826
+ if (!s) return "";
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
+ );
767
1835
  return TextKit.escapeXml(s);
768
1836
  }
769
1837