hwpkit-dev 0.0.2 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/ .npmignore +4 -1
  2. package/README.md +44 -7
  3. package/dist/index.d.mts +46 -16
  4. package/dist/index.d.ts +46 -16
  5. package/dist/index.js +3964 -1227
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +3964 -1227
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -1
  10. package/playground/index.html +346 -0
  11. package/playground/main.ts +302 -0
  12. package/playground/vite.config.ts +16 -0
  13. package/src/contract/decoder.ts +1 -0
  14. package/src/contract/encoder.ts +6 -1
  15. package/src/core/BaseDecoder.ts +118 -0
  16. package/src/core/BaseEncoder.ts +146 -0
  17. package/src/decoders/docx/DocxDecoder.ts +743 -151
  18. package/src/decoders/html/HtmlDecoder.ts +366 -0
  19. package/src/decoders/hwp/HwpScanner.ts +478 -193
  20. package/src/decoders/hwpx/HwpxDecoder.ts +796 -297
  21. package/src/decoders/md/MdDecoder.ts +4 -4
  22. package/src/encoders/docx/DocxEncoder.ts +549 -240
  23. package/src/encoders/html/HtmlEncoder.ts +17 -19
  24. package/src/encoders/hwp/HwpEncoder.ts +1643 -890
  25. package/src/encoders/hwpx/HwpxEncoder.ts +1626 -472
  26. package/src/encoders/hwpx/constants.ts +148 -0
  27. package/src/encoders/hwpx/utils.ts +198 -0
  28. package/src/encoders/md/MdEncoder.ts +20 -15
  29. package/src/model/builders.ts +4 -4
  30. package/src/model/doc-props.ts +24 -10
  31. package/src/model/doc-tree.ts +13 -5
  32. package/src/pipeline/Pipeline.ts +7 -3
  33. package/src/pipeline/registry.ts +13 -2
  34. package/src/safety/StyleBridge.ts +51 -6
  35. package/src/toolkit/ArchiveKit.ts +56 -0
  36. package/src/toolkit/StyleMapper.ts +221 -0
  37. package/src/toolkit/UnitConverter.ts +138 -0
  38. package/src/toolkit/XmlKit.ts +0 -5
  39. package/hwp-analyze.ts +0 -90
  40. package/inspect-doc.ts +0 -57
  41. package/output_test.hwp +0 -0
  42. package/test-docx-to-hwp.ts +0 -45
@@ -1,34 +1,60 @@
1
- import type { Encoder } from '../../contract/encoder';
2
- import type { DocRoot, ParaNode, SpanNode, GridNode, ContentNode, ImgNode, SheetNode } from '../../model/doc-tree';
3
- import type { Outcome } from '../../contract/result';
4
- import type { PageDims, GridProps, CellProps } from '../../model/doc-props';
5
- import { A4, normalizeDims } from '../../model/doc-props';
6
- import { succeed, fail } from '../../contract/result';
7
- import { Metric } from '../../safety/StyleBridge';
8
- import { ArchiveKit } from '../../toolkit/ArchiveKit';
9
- import { TextKit } from '../../toolkit/TextKit';
10
- import { registry } from '../../pipeline/registry';
11
-
12
- interface ImageEntry { rId: string; name: string; data: Uint8Array; ext: string }
13
-
14
- export class DocxEncoder implements Encoder {
15
- readonly format = 'docx';
1
+ import type {
2
+ DocRoot,
3
+ ParaNode,
4
+ SpanNode,
5
+ GridNode,
6
+ ContentNode,
7
+ ImgNode,
8
+ SheetNode,
9
+ } from "../../model/doc-tree";
10
+ import type { Outcome } from "../../contract/result";
11
+ import type { PageDims, GridProps, CellProps } from "../../model/doc-props";
12
+ import { A4, normalizeDims } from "../../model/doc-props";
13
+ import { succeed, fail } from "../../contract/result";
14
+ import { Metric } from "../../safety/StyleBridge";
15
+ import { ArchiveKit } from "../../toolkit/ArchiveKit";
16
+ import { TextKit } from "../../toolkit/TextKit";
17
+ import { registry } from "../../pipeline/registry";
18
+ import { BaseEncoder } from "../../core/BaseEncoder";
19
+
20
+ interface ImageEntry {
21
+ rId: string;
22
+ name: string;
23
+ data: Uint8Array;
24
+ ext: string;
25
+ }
26
+
27
+ export class DocxEncoder extends BaseEncoder {
28
+ protected getFormat(): string {
29
+ return "docx";
30
+ }
16
31
 
17
32
  async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
18
33
  try {
19
- const sheet = doc.kids[0];
20
- const dims = normalizeDims(sheet?.dims ?? A4);
21
- const kids = sheet?.kids ?? [];
34
+ // 모든 섹션(SheetNode)을 처리 — 첫 번째 섹션에서 헤더/푸터/페이지 설정을 가져오고
35
+ // 이후 섹션들의 콘텐츠는 sectPr로 구분하여 단일 body에 병합한다.
36
+ const sheets = doc.kids.length > 0 ? doc.kids : [];
37
+ const firstSheet = sheets[0];
38
+ const dims = normalizeDims(firstSheet?.dims ?? A4);
22
39
 
23
- const images: ImageEntry[] = [];
24
- const ctx: EncCtx = { images, nextId: 10, nextImgNum: 1, warns: [], imgMap: new WeakMap() };
25
-
26
- // Collect images from content
27
- collectImages(kids, ctx);
40
+ // 모든 섹션의 콘텐츠를 합산
41
+ const allKids: ContentNode[] = sheets.flatMap((s) => s?.kids ?? []);
28
42
 
29
- // Header / footer
30
- let headerParas = sheet?.header;
31
- let footerParas = sheet?.footer;
43
+ const images: ImageEntry[] = [];
44
+ const ctx: EncCtx = {
45
+ images,
46
+ nextId: 10,
47
+ nextImgNum: 1,
48
+ warns: [],
49
+ imgMap: new WeakMap(),
50
+ };
51
+
52
+ // Collect images from all content
53
+ collectImages(allKids, ctx);
54
+
55
+ // Header / footer (첫 번째 섹션 기준)
56
+ let headerParas = firstSheet?.headers?.default;
57
+ let footerParas = firstSheet?.footers?.default;
32
58
  const hasHeader = headerParas && headerParas.length > 0;
33
59
  const hasFooter = footerParas && footerParas.length > 0;
34
60
 
@@ -36,34 +62,62 @@ export class DocxEncoder implements Encoder {
36
62
  if (hasHeader) collectImagesFromParas(headerParas!, ctx);
37
63
  if (hasFooter) collectImagesFromParas(footerParas!, ctx);
38
64
 
39
- const headerRId = hasHeader ? `rId${ctx.nextId++}` : '';
40
- const footerRId = hasFooter ? `rId${ctx.nextId++}` : '';
65
+ const headerRId = hasHeader ? `rId${ctx.nextId++}` : "";
66
+ const footerRId = hasFooter ? `rId${ctx.nextId++}` : "";
41
67
 
42
- // Numbering: collect list info
43
- const numInfo = collectNumbering(kids);
68
+ // Numbering: collect list info from all sections
69
+ const numInfo = collectNumbering(allKids);
70
+
71
+ // kids 참조를 allKids로 통일 (후속 코드 호환)
72
+ const kids = allKids;
44
73
 
45
74
  const entries: { name: string; data: Uint8Array }[] = [
46
- { name: '[Content_Types].xml', data: TextKit.encode(contentTypes(images, hasHeader, hasFooter)) },
47
- { name: '_rels/.rels', data: TextKit.encode(pkgRels()) },
48
- { name: 'word/document.xml', data: TextKit.encode(documentXml(kids, dims, ctx, headerRId, footerRId)) },
49
- { name: 'word/styles.xml', data: TextKit.encode(stylesXml()) },
50
- { name: 'word/settings.xml', data: TextKit.encode(settingsXml()) },
51
- { name: 'word/_rels/document.xml.rels', data: TextKit.encode(docRels(images, headerRId, footerRId, numInfo.hasLists)) },
52
- { name: 'docProps/app.xml', data: TextKit.encode(appXml()) },
53
- { name: 'docProps/core.xml', data: TextKit.encode(coreXml(doc.meta)) },
75
+ {
76
+ name: "[Content_Types].xml",
77
+ data: this.stringToBytes(contentTypes(images, hasHeader, hasFooter)),
78
+ },
79
+ { name: "_rels/.rels", data: this.stringToBytes(pkgRels()) },
80
+ {
81
+ name: "word/document.xml",
82
+ data: this.stringToBytes(
83
+ documentXml(kids, dims, ctx, headerRId, footerRId),
84
+ ),
85
+ },
86
+ { name: "word/styles.xml", data: this.stringToBytes(stylesXml()) },
87
+ { name: "word/settings.xml", data: this.stringToBytes(settingsXml()) },
88
+ {
89
+ name: "word/_rels/document.xml.rels",
90
+ data: this.stringToBytes(
91
+ docRels(images, headerRId, footerRId, numInfo.hasLists),
92
+ ),
93
+ },
94
+ { name: "docProps/app.xml", data: this.stringToBytes(appXml()) },
95
+ {
96
+ name: "docProps/core.xml",
97
+ data: this.stringToBytes(coreXml(doc.meta)),
98
+ },
54
99
  ];
55
100
 
56
101
  // Add numbering.xml if needed
57
102
  if (numInfo.hasLists) {
58
- entries.push({ name: 'word/numbering.xml', data: TextKit.encode(numberingXml(numInfo)) });
103
+ entries.push({
104
+ name: "word/numbering.xml",
105
+ data: this.stringToBytes(numberingXml(numInfo)),
106
+ });
59
107
  }
60
108
 
61
109
  // Add header/footer files
62
110
  if (hasHeader) {
63
- entries.push({ name: 'word/header1.xml', data: TextKit.encode(headerFooterXml('hdr', headerParas!, ctx)) });
111
+ entries.push({
112
+ name: "word/header1.xml",
113
+ data: this.stringToBytes(headerFooterXml("hdr", headerParas!, ctx)),
114
+ });
64
115
  }
65
116
  if (hasFooter) {
66
- entries.push({ name: 'word/footer1.xml', data: TextKit.encode(headerFooterXml('ftr', footerParas!, ctx)) });
117
+ entries.push({
118
+ name: "word/footer1.xml",
119
+ data: this.stringToBytes(headerFooterXml("ftr", footerParas!, ctx)),
120
+ });
67
121
  }
68
122
 
69
123
  // Add image media files
@@ -71,7 +125,7 @@ export class DocxEncoder implements Encoder {
71
125
  entries.push({ name: `word/media/${img.name}`, data: img.data });
72
126
  }
73
127
 
74
- return succeed(await ArchiveKit.zip(entries));
128
+ return succeed(await this.zip(entries));
75
129
  } catch (e: any) {
76
130
  return fail(`DOCX encode error: ${e?.message ?? String(e)}`);
77
131
  }
@@ -91,19 +145,21 @@ interface EncCtx {
91
145
  // ─── Image collection ───────────────────────────────────────
92
146
 
93
147
  function mimeToExt(mime: string): string {
94
- if (mime.includes('jpeg')) return 'jpeg';
95
- if (mime.includes('gif')) return 'gif';
96
- if (mime.includes('bmp')) return 'bmp';
97
- return 'png';
148
+ if (mime.includes("jpeg")) return "jpeg";
149
+ if (mime.includes("gif")) return "gif";
150
+ if (mime.includes("bmp")) return "bmp";
151
+ return "png";
98
152
  }
99
153
 
100
154
  function collectImages(kids: ContentNode[], ctx: EncCtx): void {
101
155
  for (const kid of kids) {
102
- if (kid.tag === 'para') collectImagesFromPara(kid, ctx);
103
- else if (kid.tag === 'grid') {
156
+ if (kid.tag === "para") collectImagesFromPara(kid, ctx);
157
+ else if (kid.tag === "grid") {
104
158
  for (const row of kid.kids)
105
159
  for (const cell of row.kids)
106
- for (const p of cell.kids) collectImagesFromPara(p, ctx);
160
+ for (const p of cell.kids)
161
+ if (p.tag === "para") collectImagesFromPara(p, ctx);
162
+ else collectImages([p], ctx);
107
163
  }
108
164
  }
109
165
  }
@@ -114,7 +170,7 @@ function collectImagesFromParas(paras: ParaNode[], ctx: EncCtx): void {
114
170
 
115
171
  function collectImagesFromPara(para: ParaNode, ctx: EncCtx): void {
116
172
  for (const kid of para.kids) {
117
- if (kid.tag === 'img') registerImage(kid, ctx);
173
+ if (kid.tag === "img") registerImage(kid, ctx);
118
174
  }
119
175
  }
120
176
 
@@ -140,7 +196,7 @@ function collectNumbering(kids: ContentNode[]): NumInfo {
140
196
  let hasBullet = false;
141
197
  let hasNumbered = false;
142
198
  for (const kid of kids) {
143
- if (kid.tag === 'para') {
199
+ if (kid.tag === "para") {
144
200
  if (kid.props.listOrd === true) hasNumbered = true;
145
201
  else if (kid.props.listOrd === false) hasBullet = true;
146
202
  }
@@ -150,7 +206,11 @@ function collectNumbering(kids: ContentNode[]): NumInfo {
150
206
 
151
207
  // ─── OOXML boilerplate ──────────────────────────────────────
152
208
 
153
- function contentTypes(images: ImageEntry[], hasHeader?: boolean, hasFooter?: boolean): string {
209
+ function contentTypes(
210
+ images: ImageEntry[],
211
+ hasHeader?: boolean,
212
+ hasFooter?: boolean,
213
+ ): string {
154
214
  const imgDefaults = new Set<string>();
155
215
  for (const img of images) imgDefaults.add(img.ext);
156
216
 
@@ -158,7 +218,14 @@ function contentTypes(images: ImageEntry[], hasHeader?: boolean, hasFooter?: boo
158
218
  <Default Extension="xml" ContentType="application/xml"/>`;
159
219
 
160
220
  for (const ext of imgDefaults) {
161
- const ct = ext === 'png' ? 'image/png' : ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : 'image/bmp';
221
+ const ct =
222
+ ext === "png"
223
+ ? "image/png"
224
+ : ext === "jpeg"
225
+ ? "image/jpeg"
226
+ : ext === "gif"
227
+ ? "image/gif"
228
+ : "image/bmp";
162
229
  defaults += `\n <Default Extension="${ext}" ContentType="${ct}"/>`;
163
230
  }
164
231
 
@@ -168,8 +235,10 @@ function contentTypes(images: ImageEntry[], hasHeader?: boolean, hasFooter?: boo
168
235
  <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
169
236
  <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>`;
170
237
 
171
- if (hasHeader) overrides += `\n <Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>`;
172
- if (hasFooter) overrides += `\n <Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>`;
238
+ if (hasHeader)
239
+ overrides += `\n <Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>`;
240
+ if (hasFooter)
241
+ overrides += `\n <Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>`;
173
242
 
174
243
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
175
244
  <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
@@ -187,7 +256,12 @@ function pkgRels(): string {
187
256
  </Relationships>`;
188
257
  }
189
258
 
190
- function docRels(images: ImageEntry[], headerRId?: string, footerRId?: string, hasLists?: boolean): string {
259
+ function docRels(
260
+ images: ImageEntry[],
261
+ headerRId?: string,
262
+ footerRId?: string,
263
+ hasLists?: boolean,
264
+ ): string {
191
265
  let rels = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
192
266
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>`;
193
267
 
@@ -224,8 +298,8 @@ function coreXml(meta: any): string {
224
298
  const now = new Date().toISOString();
225
299
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
226
300
  <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
227
- <dc:title>${esc(meta.title ?? '')}</dc:title>
228
- <dc:creator>${esc(meta.author ?? '')}</dc:creator>
301
+ <dc:title>${esc(meta.title ?? "")}</dc:title>
302
+ <dc:creator>${esc(meta.author ?? "")}</dc:creator>
229
303
  <dcterms:created xsi:type="dcterms:W3CDTF">${meta.created ?? now}</dcterms:created>
230
304
  <dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>
231
305
  </cp:coreProperties>`;
@@ -241,11 +315,45 @@ function stylesXml(): string {
241
315
  <w:szCs w:val="20"/>
242
316
  </w:rPr></w:rPrDefault>
243
317
  <w:pPrDefault><w:pPr>
244
- <w:spacing w:after="0" w:line="384" w:lineRule="auto"/>
318
+ <w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
245
319
  <w:jc w:val="both"/>
246
320
  </w:pPr></w:pPrDefault>
247
321
  </w:docDefaults>
248
322
  <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
323
+ <w:style w:type="paragraph" w:styleId="0"><w:name w:val="바탕글"/><w:basedOn w:val="Normal"/></w:style>
324
+ <w:style w:type="paragraph" w:styleId="1"><w:name w:val="본문"/><w:basedOn w:val="Normal"/></w:style>
325
+ <w:style w:type="paragraph" w:styleId="2"><w:name w:val="개요 1"/><w:basedOn w:val="Normal"/></w:style>
326
+ <w:style w:type="paragraph" w:styleId="3"><w:name w:val="개요 2"/><w:basedOn w:val="Normal"/></w:style>
327
+ <w:style w:type="paragraph" w:styleId="4"><w:name w:val="개요 3"/><w:basedOn w:val="Normal"/></w:style>
328
+ <w:style w:type="paragraph" w:styleId="5"><w:name w:val="개요 4"/><w:basedOn w:val="Normal"/></w:style>
329
+ <w:style w:type="paragraph" w:styleId="6"><w:name w:val="개요 5"/><w:basedOn w:val="Normal"/></w:style>
330
+ <w:style w:type="paragraph" w:styleId="7"><w:name w:val="개요 6"/><w:basedOn w:val="Normal"/></w:style>
331
+ <w:style w:type="paragraph" w:styleId="8"><w:name w:val="개요 7"/><w:basedOn w:val="Normal"/></w:style>
332
+ <w:style w:type="paragraph" w:styleId="9"><w:name w:val="개요 8"/><w:basedOn w:val="Normal"/></w:style>
333
+ <w:style w:type="paragraph" w:styleId="10"><w:name w:val="개요 9"/><w:basedOn w:val="Normal"/></w:style>
334
+ <w:style w:type="paragraph" w:styleId="11"><w:name w:val="개요 10"/><w:basedOn w:val="Normal"/></w:style>
335
+ <w:style w:type="paragraph" w:styleId="12"><w:name w:val="쪽 번호"/><w:basedOn w:val="Normal"/></w:style>
336
+ <w:style w:type="paragraph" w:styleId="13"><w:name w:val="머리말"/><w:basedOn w:val="Normal"/></w:style>
337
+ <w:style w:type="paragraph" w:styleId="14"><w:name w:val="각주"/><w:basedOn w:val="Normal"/></w:style>
338
+ <w:style w:type="paragraph" w:styleId="15"><w:name w:val="미주"/><w:basedOn w:val="Normal"/></w:style>
339
+ <w:style w:type="paragraph" w:styleId="16"><w:name w:val="메모"/><w:basedOn w:val="Normal"/></w:style>
340
+ <w:style w:type="paragraph" w:styleId="17"><w:name w:val="차례 제목"/><w:basedOn w:val="Normal"/></w:style>
341
+ <w:style w:type="paragraph" w:styleId="18"><w:name w:val="차례 1"/><w:basedOn w:val="Normal"/></w:style>
342
+ <w:style w:type="paragraph" w:styleId="19"><w:name w:val="차례 2"/><w:basedOn w:val="Normal"/></w:style>
343
+ <w:style w:type="paragraph" w:styleId="20"><w:name w:val="차례 3"/><w:basedOn w:val="Normal"/></w:style>
344
+ <w:style w:type="paragraph" w:styleId="21"><w:name w:val="본문 제목"/><w:basedOn w:val="Normal"/></w:style>
345
+ <w:style w:type="paragraph" w:styleId="22"><w:name w:val="그림"/><w:basedOn w:val="Normal"/></w:style>
346
+ <w:style w:type="paragraph" w:styleId="23"><w:name w:val="표"/><w:basedOn w:val="Normal"/></w:style>
347
+ <w:style w:type="paragraph" w:styleId="24"><w:name w:val="수식"/><w:basedOn w:val="Normal"/></w:style>
348
+ <w:style w:type="paragraph" w:styleId="25"><w:name w:val="인용문"/><w:basedOn w:val="Normal"/></w:style>
349
+ <w:style w:type="paragraph" w:styleId="26"><w:name w:val="날짜"/><w:basedOn w:val="Normal"/></w:style>
350
+ <w:style w:type="paragraph" w:styleId="27"><w:name w:val="발신명의"/><w:basedOn w:val="Normal"/></w:style>
351
+ <w:style w:type="paragraph" w:styleId="28"><w:name w:val="제목"/><w:basedOn w:val="Normal"/></w:style>
352
+ <w:style w:type="paragraph" w:styleId="29"><w:name w:val="부제목"/><w:basedOn w:val="Normal"/></w:style>
353
+ <w:style w:type="paragraph" w:styleId="30"><w:name w:val="문단 제목"/><w:basedOn w:val="Normal"/></w:style>
354
+ <w:style w:type="paragraph" w:styleId="31"><w:name w:val="MEMO"/><w:basedOn w:val="Normal"/></w:style>
355
+ <w:style w:type="paragraph" w:styleId="32"><w:name w:val="개요"/><w:basedOn w:val="Normal"/></w:style>
356
+ <w:style w:type="paragraph" w:styleId="33"><w:name w:val="표 제목"/><w:basedOn w:val="Normal"/></w:style>
249
357
  <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/></w:rPr></w:style>
250
358
  <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="36"/><w:szCs w:val="36"/></w:rPr></w:style>
251
359
  <w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="2"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr></w:style>
@@ -278,14 +386,14 @@ function settingsXml(): string {
278
386
  // ─── numbering.xml ──────────────────────────────────────────
279
387
 
280
388
  function numberingXml(info: NumInfo): string {
281
- let abstractNums = '';
282
- let nums = '';
389
+ let abstractNums = "";
390
+ let nums = "";
283
391
 
284
392
  // Bullet list: abstractNumId=0, numId=1
285
393
  if (info.hasBullet) {
286
394
  abstractNums += `<w:abstractNum w:abstractNumId="0">`;
287
395
  for (let lvl = 0; lvl < 9; lvl++) {
288
- const marker = lvl === 0 ? '' : lvl === 1 ? '' : '';
396
+ const marker = lvl === 0 ? "" : lvl === 1 ? "" : "";
289
397
  const indent = (lvl + 1) * 720;
290
398
  abstractNums += `<w:lvl w:ilvl="${lvl}"><w:numFmt w:val="bullet"/><w:lvlText w:val="${marker}"/><w:pPr><w:ind w:left="${indent}" w:hanging="360"/></w:pPr></w:lvl>`;
291
399
  }
@@ -297,7 +405,12 @@ function numberingXml(info: NumInfo): string {
297
405
  if (info.hasNumbered) {
298
406
  abstractNums += `<w:abstractNum w:abstractNumId="1">`;
299
407
  for (let lvl = 0; lvl < 9; lvl++) {
300
- const fmt = lvl % 3 === 0 ? 'decimal' : lvl % 3 === 1 ? 'lowerLetter' : 'lowerRoman';
408
+ const fmt =
409
+ lvl % 3 === 0
410
+ ? "decimal"
411
+ : lvl % 3 === 1
412
+ ? "lowerLetter"
413
+ : "lowerRoman";
301
414
  const indent = (lvl + 1) * 720;
302
415
  abstractNums += `<w:lvl w:ilvl="${lvl}"><w:start w:val="1"/><w:numFmt w:val="${fmt}"/><w:lvlText w:val="%${lvl + 1}."/><w:pPr><w:ind w:left="${indent}" w:hanging="360"/></w:pPr></w:lvl>`;
303
416
  }
@@ -314,9 +427,13 @@ function numberingXml(info: NumInfo): string {
314
427
 
315
428
  // ─── header / footer xml ────────────────────────────────────
316
429
 
317
- function headerFooterXml(type: 'hdr' | 'ftr', paras: ParaNode[], ctx: EncCtx): string {
318
- const tag = type === 'hdr' ? 'w:hdr' : 'w:ftr';
319
- const body = paras.map(p => encodeParaInner(p, ctx)).join('\n');
430
+ function headerFooterXml(
431
+ type: "hdr" | "ftr",
432
+ paras: ParaNode[],
433
+ ctx: EncCtx,
434
+ ): string {
435
+ const tag = type === "hdr" ? "w:hdr" : "w:ftr";
436
+ const body = paras.map((p) => encodeParaInner(p, ctx)).join("\n");
320
437
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
321
438
  <${tag} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
322
439
  ${body}
@@ -325,66 +442,119 @@ ${body}
325
442
 
326
443
  // ─── document.xml ───────────────────────────────────────────
327
444
 
328
- function documentXml(kids: ContentNode[], dims: PageDims, ctx: EncCtx, headerRId?: string, footerRId?: string): string {
329
- const body = kids.map(k => encodeContent(k, ctx, dims)).join('\n');
445
+ function documentXml(
446
+ kids: ContentNode[],
447
+ dims: PageDims,
448
+ ctx: EncCtx,
449
+ headerRId?: string,
450
+ footerRId?: string,
451
+ ): string {
452
+ const body = kids.map((k) => encodeContent(k, ctx, dims)).join("\n");
330
453
 
331
- let sectRefs = '';
332
- if (headerRId) sectRefs += `\n <w:headerReference w:type="default" r:id="${headerRId}"/>`;
333
- if (footerRId) sectRefs += `\n <w:footerReference w:type="default" r:id="${footerRId}"/>`;
454
+ let sectRefs = "";
455
+ if (headerRId)
456
+ sectRefs += `\n <w:headerReference w:type="default" r:id="${headerRId}"/>`;
457
+ if (footerRId)
458
+ sectRefs += `\n <w:footerReference w:type="default" r:id="${footerRId}"/>`;
334
459
 
335
460
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
336
461
  <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
337
462
  <w:body>
338
463
  ${body}
339
464
  <w:sectPr>${sectRefs}
340
- <w:pgSz w:w="${Metric.ptToDxa(dims.wPt)}" w:h="${Metric.ptToDxa(dims.hPt)}" w:orient="${dims.orient ?? 'portrait'}"/>
465
+ <w:pgSz w:w="${Metric.ptToDxa(dims.wPt)}" w:h="${Metric.ptToDxa(dims.hPt)}" w:orient="${dims.orient ?? "portrait"}"/>
341
466
  <w:pgMar w:top="${Metric.ptToDxa(dims.mt)}" w:right="${Metric.ptToDxa(dims.mr)}" w:bottom="${Metric.ptToDxa(dims.mb)}" w:left="${Metric.ptToDxa(dims.ml)}" w:header="709" w:footer="709" w:gutter="0"/>
342
467
  </w:sectPr>
343
468
  </w:body>
344
469
  </w:document>`;
345
470
  }
346
471
 
347
- function encodeContent(node: ContentNode, ctx: EncCtx, dims?: PageDims): string {
348
- return node.tag === 'grid' ? encodeGrid(node, ctx, dims) : encodeParaInner(node, ctx);
472
+ function encodeContent(
473
+ node: ContentNode,
474
+ ctx: EncCtx,
475
+ dims?: PageDims,
476
+ ): string {
477
+ return node.tag === "grid"
478
+ ? encodeGrid(node, ctx, dims)
479
+ : encodeParaInner(node, ctx);
349
480
  }
350
481
 
351
482
  function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
352
- const align = para.props.align ?? 'left';
353
- const headStyle = para.props.heading ? `<w:pStyle w:val="Heading${para.props.heading}"/>` : '';
483
+ const align = para.props.align ?? "left";
484
+ // P3: hwpStyleId(숫자 ID) 우선, 없으면 heading 스타일, 둘 다 없으면 빈 문자열
485
+ let headStyle = "";
486
+ if (para.props.hwpStyleId !== undefined) {
487
+ headStyle = `<w:pStyle w:val="${para.props.hwpStyleId}"/>`;
488
+ } else if (para.props.heading) {
489
+ headStyle = `<w:pStyle w:val="Heading${para.props.heading}"/>`;
490
+ }
354
491
 
355
492
  // List numbering
356
- let numPr = '';
493
+ let numPr = "";
357
494
  if (para.props.listOrd !== undefined) {
358
495
  const numId = para.props.listOrd ? 2 : 1;
359
496
  const ilvl = para.props.listLv ?? 0;
360
497
  numPr = `<w:pStyle w:val="ListParagraph"/><w:numPr><w:ilvl w:val="${ilvl}"/><w:numId w:val="${numId}"/></w:numPr>`;
361
498
  }
362
499
 
363
- // Spacing (before / after / line height)
364
- let spacingXml = '';
365
- const { spaceBefore, spaceAfter, lineHeight } = para.props;
366
- if (spaceBefore !== undefined || spaceAfter !== undefined || lineHeight !== undefined) {
500
+ // Spacing (before / after / line height) - ensure all values are non-negative
501
+ // ECMA-376 §17.3.1.33 spacing: line은 1/240th of a line(auto) 또는 dxa(exact/atLeast)
502
+ let spacingXml = "";
503
+ const { spaceBefore, spaceAfter, lineHeight, lineHeightFixed } = para.props;
504
+ if (
505
+ spaceBefore !== undefined ||
506
+ spaceAfter !== undefined ||
507
+ lineHeight !== undefined ||
508
+ lineHeightFixed !== undefined
509
+ ) {
367
510
  const parts: string[] = [];
368
- if (spaceBefore !== undefined) parts.push(`w:before="${Metric.ptToDxa(spaceBefore)}"`);
369
- if (spaceAfter !== undefined) parts.push(`w:after="${Metric.ptToDxa(spaceAfter)}"`);
370
- if (lineHeight !== undefined) parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
371
- spacingXml = `<w:spacing ${parts.join(' ')}/>`;
372
- }
373
-
374
- // Indentation
375
- let indentXml = '';
376
- if (para.props.indentPt !== undefined) {
377
- indentXml = `<w:ind w:left="${Metric.ptToDxa(para.props.indentPt)}"/>`;
511
+ if (spaceBefore !== undefined)
512
+ parts.push(`w:before="${Math.max(0, Metric.ptToDxa(spaceBefore))}"`);
513
+ if (spaceAfter !== undefined)
514
+ parts.push(`w:after="${Math.max(0, Metric.ptToDxa(spaceAfter))}"`);
515
+ if (lineHeightFixed !== undefined) {
516
+ // FIXED: lineRule="exact", line값은 dxa (1pt = 20dxa)
517
+ parts.push(
518
+ `w:line="${Math.max(1, Metric.ptToDxa(lineHeightFixed))}" w:lineRule="exact"`,
519
+ );
520
+ } else if (lineHeight !== undefined) {
521
+ parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
522
+ }
523
+ spacingXml = `<w:spacing ${parts.join(" ")}/>`;
378
524
  }
379
525
 
380
- const runs = para.kids.map(k => {
381
- if (k.tag === 'span') return encodeRun(k, ctx);
382
- if (k.tag === 'img') return encodeImage(k, ctx);
383
- return '';
384
- }).join('');
526
+ // Indentation ECMA-376 §17.3.1.12 ind
527
+ // w:left = 전체 왼쪽 여백(dxa), w:right = 전체 오른쪽 여백(dxa)
528
+ // w:firstLine = 첫 줄 추가 들여쓰기(dxa, 양수), w:hanging = 내어쓰기(dxa, 양수값으로 표현)
529
+ let indentXml = "";
530
+ const leftDxa = Math.round(Metric.ptToDxa(para.props.indentPt ?? 0));
531
+ const rightDxa = Math.round(Metric.ptToDxa(para.props.indentRightPt ?? 0));
532
+ const firstPt = para.props.firstLineIndentPt ?? 0;
533
+
534
+ const indParts: string[] = [];
535
+ if (leftDxa > 0) indParts.push(`w:left="${leftDxa}"`);
536
+ if (rightDxa > 0) indParts.push(`w:right="${rightDxa}"`);
537
+ if (firstPt > 0)
538
+ indParts.push(`w:firstLine="${Math.round(Metric.ptToDxa(firstPt))}"`);
539
+ if (firstPt < 0)
540
+ indParts.push(`w:hanging="${Math.round(Metric.ptToDxa(-firstPt))}"`);
541
+ if (indParts.length > 0) indentXml = `<w:ind ${indParts.join(" ")}/>`;
542
+
543
+ const runs = para.kids
544
+ .map((k) => {
545
+ if (k.tag === "span") return encodeRun(k, ctx);
546
+ if (k.tag === "img") return encodeImage(k, ctx);
547
+ // P9: PageNumNode가 para.kids에 직접 있는 경우 (머리말/꼬리말 등)
548
+ if (k.tag === "pagenum") {
549
+ const instr = k.format === "total" ? " NUMPAGES " : " PAGE ";
550
+ return `<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>${instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>`;
551
+ }
552
+ return "";
553
+ })
554
+ .join("");
385
555
 
386
556
  return ` <w:p>
387
- <w:pPr>${headStyle}${numPr}${spacingXml}${indentXml}<w:jc w:val="${align === 'justify' ? 'both' : align}"/></w:pPr>
557
+ <w:pPr>${headStyle}${numPr}${spacingXml}${indentXml}<w:jc w:val="${align === "justify" ? "both" : align}"/></w:pPr>
388
558
  ${runs}
389
559
  </w:p>`;
390
560
  }
@@ -392,124 +562,169 @@ function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
392
562
  function encodeRun(span: SpanNode, _ctx: EncCtx): string {
393
563
  const p = span.props;
394
564
  const rPr: string[] = [];
395
- if (p.b) rPr.push('<w:b/>');
396
- if (p.i) rPr.push('<w:i/>');
565
+ if (p.b) rPr.push("<w:b/>");
566
+ if (p.i) rPr.push("<w:i/>");
397
567
  if (p.u) rPr.push('<w:u w:val="single"/>');
398
- if (p.s) rPr.push('<w:strike/>');
568
+ if (p.s) rPr.push("<w:strike/>");
399
569
  if (p.sup) rPr.push('<w:vertAlign w:val="superscript"/>');
400
570
  if (p.sub) rPr.push('<w:vertAlign w:val="subscript"/>');
401
- if (p.pt) rPr.push(`<w:sz w:val="${Metric.ptToHalfPt(p.pt)}"/><w:szCs w:val="${Metric.ptToHalfPt(p.pt)}"/>`);
571
+ if (p.pt)
572
+ rPr.push(
573
+ `<w:sz w:val="${Metric.ptToHalfPt(p.pt)}"/><w:szCs w:val="${Metric.ptToHalfPt(p.pt)}"/>`,
574
+ );
402
575
  if (p.color) rPr.push(`<w:color w:val="${p.color}"/>`);
403
- if (p.font) rPr.push(`<w:rFonts w:ascii="${esc(p.font)}" w:hAnsi="${esc(p.font)}" w:eastAsia="${esc(p.font)}"/>`);
576
+ if (p.font)
577
+ rPr.push(
578
+ `<w:rFonts w:ascii="${esc(p.font)}" w:hAnsi="${esc(p.font)}" w:eastAsia="${esc(p.font)}"/>`,
579
+ );
404
580
  if (p.bg) rPr.push(`<w:shd w:val="clear" w:color="auto" w:fill="${p.bg}"/>`);
405
581
 
406
582
  // Process kids — text and pagenum
407
583
  const parts: string[] = [];
408
584
  for (const kid of span.kids) {
409
- if (kid.tag === 'txt') {
410
- parts.push(`<w:r><w:rPr>${rPr.join('')}</w:rPr><w:t xml:space="preserve">${esc(kid.content)}</w:t></w:r>`);
411
- } else if (kid.tag === 'pagenum') {
412
- parts.push(`<w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:instrText> PAGE </w:instrText></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:t>1</w:t></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>`);
413
- } else if (kid.tag === 'br') {
585
+ if (kid.tag === "txt") {
586
+ // __EXT_N__ or __EXT_N_W<w>_H<h>__ 자리표시자 제거
587
+ const content = kid.content.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
588
+ if (content) {
589
+ parts.push(
590
+ `<w:r><w:rPr>${rPr.join("")}</w:rPr><w:t xml:space="preserve">${esc(content)}</w:t></w:r>`,
591
+ );
592
+ }
593
+ } else if (kid.tag === "pagenum") {
594
+ // P9: format === 'total' → NUMPAGES 필드, 나머지 → PAGE 필드
595
+ const instr = kid.format === "total" ? " NUMPAGES " : " PAGE ";
596
+ parts.push(
597
+ `<w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:instrText>${instr}</w:instrText></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:t>1</w:t></w:r><w:r><w:rPr>${rPr.join("")}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>`,
598
+ );
599
+ } else if (kid.tag === "br") {
414
600
  parts.push(`<w:r><w:br/></w:r>`);
415
- } else if (kid.tag === 'pb') {
601
+ } else if (kid.tag === "pb") {
416
602
  parts.push(`<w:r><w:br w:type="page"/></w:r>`);
417
603
  }
418
604
  }
419
605
 
420
- return parts.join('');
606
+ return parts.join("");
421
607
  }
422
608
 
423
609
  function encodeImage(img: ImgNode, ctx: EncCtx): string {
424
610
  const rId = ctx.imgMap.get(img);
425
- if (!rId) return '';
611
+ if (!rId) return "";
426
612
 
427
- const cx = Metric.ptToEmu(img.w);
428
- const cy = Metric.ptToEmu(img.h);
429
- const alt = esc(img.alt ?? '');
613
+ // Fallback to 72pt (1 inch) when w/h are 0 to prevent invisible images
614
+ const cx = Metric.ptToEmu(img.w > 0 ? img.w : 72);
615
+ const cy = Metric.ptToEmu(img.h > 0 ? img.h : 72);
616
+ const alt = esc(img.alt ?? "");
430
617
  const docPrId = ctx.nextId++;
431
618
 
432
619
  const graphic = `<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="Image"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic>`;
433
620
 
434
621
  const layout = img.layout;
435
- const isInline = !layout || layout.wrap === 'inline';
436
622
 
437
- if (isInline) {
623
+ const isInline = !layout || layout.wrap === "inline";
624
+ // topAndBottom은 반드시 anchor (float)로 처리
625
+ const forceAnchor =
626
+ layout?.wrap === "topAndBottom" ||
627
+ layout?.wrap === "square" ||
628
+ layout?.wrap === "tight" ||
629
+ layout?.wrap === "behind" ||
630
+ layout?.wrap === "front";
631
+ if (isInline && !forceAnchor) {
438
632
  return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${docPrId}" name="Image" descr="${alt}"/>${graphic}</wp:inline></w:drawing></w:r>`;
439
633
  }
440
-
441
- return `<w:r><w:drawing>${encodeAnchor(img, cx, cy, alt, docPrId, graphic, layout)}</w:drawing></w:r>`;
634
+ return `<w:r><w:drawing>${encodeAnchor(img, cx, cy, alt, docPrId, graphic, layout!)}</w:drawing></w:r>`;
442
635
  }
443
636
 
444
637
  function encodeAnchor(
445
- _img: ImgNode, cx: number, cy: number, alt: string, docPrId: number,
446
- graphic: string, layout: NonNullable<ImgNode['layout']>,
638
+ _img: ImgNode,
639
+ cx: number,
640
+ cy: number,
641
+ alt: string,
642
+ docPrId: number,
643
+ graphic: string,
644
+ layout: NonNullable<ImgNode["layout"]>,
447
645
  ): string {
448
646
  const distT = Metric.ptToEmu(layout.distT ?? 0);
449
647
  const distB = Metric.ptToEmu(layout.distB ?? 0);
450
- const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
648
+ const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
451
649
  const distR = Metric.ptToEmu(layout.distR ?? 9144);
452
- const behindDoc = (layout.behindDoc || layout.wrap === 'behind') ? '1' : '0';
650
+ const behindDoc = layout.behindDoc || layout.wrap === "behind" ? "1" : "0";
453
651
  const relH = layout.zOrder ?? 251658240;
454
652
 
455
653
  // 가로 위치
456
- const horzRelFrom = HORZ_RELTO_DOCX[layout.horzRelTo ?? 'column'] ?? 'column';
654
+ const horzRelFrom = HORZ_RELTO_DOCX[layout.horzRelTo ?? "column"] ?? "column";
457
655
  let posH: string;
458
656
  if (layout.xPt != null) {
459
657
  posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.xPt)}</wp:posOffset></wp:positionH>`;
460
658
  } else {
461
- const ha = HORZ_ALIGN_DOCX[layout.horzAlign ?? 'left'] ?? 'left';
659
+ const ha = HORZ_ALIGN_DOCX[layout.horzAlign ?? "left"] ?? "left";
462
660
  posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:align>${ha}</wp:align></wp:positionH>`;
463
661
  }
464
662
 
465
663
  // 세로 위치
466
- const vertRelFrom = VERT_RELTO_DOCX[layout.vertRelTo ?? 'para'] ?? 'paragraph';
664
+ const vertRelFrom =
665
+ VERT_RELTO_DOCX[layout.vertRelTo ?? "para"] ?? "paragraph";
467
666
  let posV: string;
468
667
  if (layout.yPt != null) {
469
668
  posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.yPt)}</wp:posOffset></wp:positionV>`;
470
669
  } else {
471
- const va = VERT_ALIGN_DOCX[layout.vertAlign ?? 'top'] ?? 'top';
670
+ const va = VERT_ALIGN_DOCX[layout.vertAlign ?? "top"] ?? "top";
472
671
  posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:align>${va}</wp:align></wp:positionV>`;
473
672
  }
474
673
 
475
674
  // 텍스트 감싸기
476
- const wrapXml = WRAP_DOCX[layout.wrap] ?? '<wp:wrapSquare wrapText="bothSides"/>';
675
+ const wrapXml =
676
+ WRAP_DOCX[layout.wrap] ?? '<wp:wrapSquare wrapText="bothSides"/>';
477
677
 
478
678
  return `<wp:anchor distT="${distT}" distB="${distB}" distL="${distL}" distR="${distR}" simplePos="0" relativeHeight="${relH}" behindDoc="${behindDoc}" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/>${posH}${posV}<wp:extent cx="${cx}" cy="${cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/>${wrapXml}<wp:docPr id="${docPrId}" name="Image" descr="${alt}"/>${graphic}</wp:anchor>`;
479
679
  }
480
680
 
481
681
  const HORZ_RELTO_DOCX: Record<string, string> = {
482
- margin: 'margin', column: 'column', page: 'page', para: 'paragraph',
682
+ margin: "margin",
683
+ column: "column",
684
+ page: "page",
685
+ para: "paragraph",
483
686
  };
484
687
  const VERT_RELTO_DOCX: Record<string, string> = {
485
- margin: 'margin', line: 'line', page: 'page', para: 'paragraph',
688
+ margin: "margin",
689
+ line: "line",
690
+ page: "page",
691
+ para: "paragraph",
486
692
  };
487
693
  const HORZ_ALIGN_DOCX: Record<string, string> = {
488
- left: 'left', center: 'center', right: 'right',
694
+ left: "left",
695
+ center: "center",
696
+ right: "right",
489
697
  };
490
698
  const VERT_ALIGN_DOCX: Record<string, string> = {
491
- top: 'top', center: 'center', bottom: 'bottom',
699
+ top: "top",
700
+ center: "center",
701
+ bottom: "bottom",
492
702
  };
703
+ // ECMA-376 §20.4.2 wordprocessingDrawing wrapping elements
493
704
  const WRAP_DOCX: Record<string, string> = {
494
705
  square: '<wp:wrapSquare wrapText="bothSides"/>',
495
- tight: '<wp:wrapTight><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapTight>',
496
- through: '<wp:wrapThrough wrapText="bothSides"><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapThrough>',
497
- none: '<wp:wrapNone/>',
498
- behind: '<wp:wrapNone/>',
499
- front: '<wp:wrapNone/>',
706
+ tight:
707
+ '<wp:wrapTight><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapTight>',
708
+ through:
709
+ '<wp:wrapThrough wrapText="bothSides"><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapThrough>',
710
+ // ECMA-376 §20.4.2.15: wrapTopAndBottom — 텍스트가 이미지 위아래로만 흐름
711
+ topAndBottom: "<wp:wrapTopAndBottom/>",
712
+ none: "<wp:wrapNone/>",
713
+ behind: "<wp:wrapNone/>",
714
+ front: "<wp:wrapNone/>",
500
715
  };
501
716
 
502
- function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
717
+ function encodeGrid(grid: GridNode, ctx: EncCtx, dims: PageDims = A4): string {
503
718
  const gp = grid.props;
504
719
  const look = gp.look;
505
720
 
506
721
  // tblLook attributes
507
- const firstRow = look?.firstRow ? '1' : '0';
508
- const lastRow = look?.lastRow ? '1' : '0';
509
- const firstCol = look?.firstCol ? '1' : '0';
510
- const lastCol = look?.lastCol ? '1' : '0';
511
- const noHBand = look?.bandedRows ? '0' : '1';
512
- const noVBand = look?.bandedCols ? '0' : '1';
722
+ const firstRow = look?.firstRow ? "1" : "0";
723
+ const lastRow = look?.lastRow ? "1" : "0";
724
+ const firstCol = look?.firstCol ? "1" : "0";
725
+ const lastCol = look?.lastCol ? "1" : "0";
726
+ const noHBand = look?.bandedRows ? "0" : "1";
727
+ const noVBand = look?.bandedCols ? "0" : "1";
513
728
 
514
729
  const d = dims ?? A4;
515
730
  const availDxa = Metric.ptToDxa(d.wPt - d.ml - d.mr);
@@ -517,11 +732,14 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
517
732
  // 1단계: 표의 가상 2D 맵핑 (Virtual Table Map) 생성
518
733
  // 'real': 데이터 셀, 'continue': 세로 병합 지속 셀, 'absorbed': 가로/세로 병합으로 흡수된 자리, 'void': 빈 공간
519
734
  interface CellMap {
520
- type: 'real' | 'continue' | 'absorbed' | 'void';
735
+ type: "real" | "continue" | "absorbed" | "void";
521
736
  cell?: any;
522
737
  width?: number;
523
738
  }
524
- const tableMap: CellMap[][] = Array.from({ length: grid.kids.length }, () => []);
739
+ const tableMap: CellMap[][] = Array.from(
740
+ { length: grid.kids.length },
741
+ () => [],
742
+ );
525
743
 
526
744
  for (let ri = 0; ri < grid.kids.length; ri++) {
527
745
  let c = 0;
@@ -530,7 +748,7 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
530
748
  while (tableMap[ri][c]) c++;
531
749
 
532
750
  // 실제 데이터 셀 배치
533
- tableMap[ri][c] = { type: 'real', cell, width: cell.cs };
751
+ tableMap[ri][c] = { type: "real", cell, width: cell.cs };
534
752
 
535
753
  // 병합 영역(colspan, rowspan) 예약 처리
536
754
  for (let rr = 0; rr < cell.rs; rr++) {
@@ -543,10 +761,10 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
543
761
 
544
762
  if (rr > 0 && cc === 0) {
545
763
  // 세로 병합이 시작된 이후 행의 첫 번째 칸
546
- tableMap[targetRi][c + cc] = { type: 'continue', width: cell.cs };
764
+ tableMap[targetRi][c + cc] = { type: "continue", width: cell.cs };
547
765
  } else {
548
766
  // 가로 병합으로 흡수된 칸 또는 세로 병합 중 가로 병합된 칸
549
- tableMap[targetRi][c + cc] = { type: 'absorbed' };
767
+ tableMap[targetRi][c + cc] = { type: "absorbed" };
550
768
  }
551
769
  }
552
770
  }
@@ -564,7 +782,7 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
564
782
  // 빈 공간(void) 채우기 및 colCount에 맞춰 배열 길이 정규화
565
783
  for (let ri = 0; ri < grid.kids.length; ri++) {
566
784
  for (let c = 0; c < colCount; c++) {
567
- if (!tableMap[ri][c]) tableMap[ri][c] = { type: 'void' };
785
+ if (!tableMap[ri][c]) tableMap[ri][c] = { type: "void" };
568
786
  }
569
787
  }
570
788
 
@@ -577,8 +795,8 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
577
795
  while (srcPt.length < colCount) srcPt.push(0);
578
796
  srcPt.length = colCount;
579
797
 
580
- const knownTotalPt = srcPt.filter(w => w > 0).reduce((s, w) => s + w, 0);
581
- const zeroCount = srcPt.filter(w => w <= 0).length;
798
+ const knownTotalPt = srcPt.filter((w) => w > 0).reduce((s, w) => s + w, 0);
799
+ const zeroCount = srcPt.filter((w) => w <= 0).length;
582
800
  const availPt = Metric.dxaToPt(availDxa);
583
801
 
584
802
  const remainingPt = Math.max(0, availPt - knownTotalPt);
@@ -586,164 +804,255 @@ function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
586
804
 
587
805
  for (let i = 0; i < srcPt.length; i++) {
588
806
  if (srcPt[i] <= 0) {
589
- srcPt[i] = zeroFillPt > 0 ? zeroFillPt : (availPt / colCount);
807
+ srcPt[i] = zeroFillPt > 0 ? zeroFillPt : availPt / colCount;
590
808
  }
591
809
  }
592
810
 
593
- colWidthsDxa = srcPt.map(w => Math.round(Metric.ptToDxa(w)));
811
+ colWidthsDxa = srcPt.map((w) => Math.round(Metric.ptToDxa(w)));
594
812
  const computedTotalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
595
813
  if (computedTotalDxa > availDxa) {
596
814
  const scale = availDxa / computedTotalDxa;
597
- colWidthsDxa = colWidthsDxa.map(w => Math.round(w * scale));
815
+ colWidthsDxa = colWidthsDxa.map((w) => Math.round(w * scale));
598
816
  }
599
817
  } else {
600
818
  for (let c = 0; c < colCount; c++) colWidthsDxa.push(defaultColDxa);
601
819
  }
602
820
 
603
821
  const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
604
- const gridCols = colWidthsDxa.map(w => `<w:gridCol w:w="${w}"/>`).join('');
822
+ const gridCols = colWidthsDxa.map((w) => `<w:gridCol w:w="${w}"/>`).join("");
605
823
 
606
824
  // 3단계: XML 렌더링
607
- const rows = grid.kids.map((row, ri) => {
608
- const cellXmls: string[] = [];
609
-
610
- for (let c = 0; c < colCount; c++) {
611
- const mapEntry = tableMap[ri][c];
612
-
613
- // 가로 병합으로 흡수된 칸은 렌더링하지 않음 (앞선 칸의 gridSpan이 차지)
614
- if (mapEntry.type === 'absorbed') continue;
615
-
616
- // 세로 병합 지속(continue), 실제 셀(real), 또는 빈 공간(void) 처리
617
- const isContinue = mapEntry.type === 'continue';
618
- const isReal = mapEntry.type === 'real';
619
- const isVoid = mapEntry.type === 'void';
620
-
621
- if (isContinue || isReal || isVoid) {
622
- let cw = 0;
623
- const cellWidth = mapEntry.width || 1;
624
-
625
- // 너비 계산: 현재 칸부터 colspan(width) 만큼의 컬럼 너비 합산
626
- for (let sc = c; sc < c + cellWidth && sc < colWidthsDxa.length; sc++) {
627
- cw += colWidthsDxa[sc];
628
- }
629
- if (cw === 0) cw = defaultColDxa * cellWidth;
825
+ const rows = tableMap
826
+ .map((rowMap, ri) => {
827
+ const cellXmls: string[] = [];
828
+
829
+ for (let c = 0; c < colCount; c++) {
830
+ const mapEntry = rowMap[c];
831
+
832
+ // 가로 병합으로 흡수된 칸은 렌더링하지 않음 (앞선 칸의 gridSpan이 차지)
833
+ if (mapEntry.type === "absorbed") continue;
834
+
835
+ // 세로 병합 지속(continue), 실제 셀(real), 또는 빈 공간(void) 처리
836
+ const isContinue = mapEntry.type === "continue";
837
+ const isReal = mapEntry.type === "real";
838
+ const isVoid = mapEntry.type === "void";
839
+
840
+ if (isContinue || isReal || isVoid) {
841
+ let cw = 0;
842
+ const cellWidth = mapEntry.width || 1;
843
+
844
+ // 너비 계산: 현재 칸부터 colspan(width) 만큼의 컬럼 너비 합산
845
+ // colWidthsDxa 가 colCount 보다 작은 경우를 대비하여 safe 하게 처리
846
+ const safeColWidths =
847
+ colWidthsDxa.length >= colCount
848
+ ? colWidthsDxa
849
+ : [
850
+ ...colWidthsDxa,
851
+ ...Array(colCount - colWidthsDxa.length).fill(defaultColDxa),
852
+ ];
853
+ for (
854
+ let sc = c;
855
+ sc < c + cellWidth && sc < safeColWidths.length;
856
+ sc++
857
+ ) {
858
+ cw += safeColWidths[sc];
859
+ }
860
+ if (cw <= 0) cw = defaultColDxa * cellWidth;
630
861
 
631
- const tcPrParts: string[] = [];
632
- tcPrParts.push(`<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`);
862
+ const tcPrParts: string[] = [];
863
+ tcPrParts.push(`<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`);
633
864
 
634
- if (cellWidth > 1) {
635
- tcPrParts.push(`<w:gridSpan w:val="${cellWidth}"/>`);
636
- }
865
+ if (cellWidth > 1) {
866
+ tcPrParts.push(`<w:gridSpan w:val="${cellWidth}"/>`);
867
+ }
637
868
 
638
- if (isContinue) {
639
- tcPrParts.push(`<w:vMerge/>`);
640
- }
869
+ if (isContinue) {
870
+ tcPrParts.push(`<w:vMerge/>`);
871
+ }
641
872
 
642
- let cellContent = '';
643
- if (isReal) {
644
- const cell = mapEntry.cell!;
645
- const cp = cell.props;
646
- if (cell.rs > 1) tcPrParts.push(`<w:vMerge w:val="restart"/>`);
647
-
648
- const borders = encodeCellBorders(cp);
649
- if (borders) tcPrParts.push(borders);
650
- if (cp.bg) tcPrParts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`);
651
- if (cp.va) {
652
- const vaMap: Record<string, string> = { top: 'top', mid: 'center', bot: 'bottom' };
653
- tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? 'top'}"/>`);
873
+ let cellContent = "";
874
+ if (isReal) {
875
+ const cell = mapEntry.cell!;
876
+ const cp = cell.props;
877
+ if (cell.rs > 1) tcPrParts.push(`<w:vMerge w:val="restart"/>`);
878
+
879
+ const borders = encodeCellBorders(cp);
880
+ if (borders) tcPrParts.push(borders);
881
+ if (cp.bg)
882
+ tcPrParts.push(
883
+ `<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`,
884
+ );
885
+ if (cp.va) {
886
+ const vaMap: Record<string, string> = {
887
+ top: "top",
888
+ mid: "center",
889
+ bot: "bottom",
890
+ };
891
+ tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? "top"}"/>`);
892
+ }
893
+ // Per-cell margin override (only when explicitly set)
894
+ const cPadT =
895
+ cp.padT != null ? Math.round(Metric.ptToDxa(cp.padT)) : null;
896
+ const cPadB =
897
+ cp.padB != null ? Math.round(Metric.ptToDxa(cp.padB)) : null;
898
+ const cPadL =
899
+ cp.padL != null ? Math.round(Metric.ptToDxa(cp.padL)) : null;
900
+ const cPadR =
901
+ cp.padR != null ? Math.round(Metric.ptToDxa(cp.padR)) : null;
902
+ if (
903
+ cPadT != null ||
904
+ cPadB != null ||
905
+ cPadL != null ||
906
+ cPadR != null
907
+ ) {
908
+ const t = cPadT ?? 28;
909
+ const b = cPadB ?? 28;
910
+ const l = cPadL ?? 72;
911
+ const r = cPadR ?? 72;
912
+ tcPrParts.push(
913
+ `<w:tcMar><w:top w:w="${t}" w:type="dxa"/><w:left w:w="${l}" w:type="dxa"/><w:bottom w:w="${b}" w:type="dxa"/><w:right w:w="${r}" w:type="dxa"/></w:tcMar>`,
914
+ );
915
+ }
916
+ // Encode cell content: paragraphs and nested grids (중첩 표)
917
+ // DOCX cells must end with <w:p>; nested <w:tbl> goes between paras
918
+ const parts: string[] = [];
919
+ for (const kid of cell.kids) {
920
+ if (kid.tag === "grid") {
921
+ parts.push(encodeGrid(kid, ctx)); // dims 제거 — 중첩 표는 부모 dims 불필요
922
+ } else if (kid.tag === "para") {
923
+ parts.push(encodeParaInner(kid, ctx));
924
+ }
925
+ }
926
+ // DOCX 셀은 반드시 <w:p>로 끝나야 함
927
+ const lastKid = cell.kids[cell.kids.length - 1];
928
+ if (cell.kids.length === 0 || lastKid?.tag === "grid") {
929
+ parts.push('<w:p><w:pPr><w:spacing w:after="0"/></w:pPr></w:p>');
930
+ }
931
+ cellContent = parts.join("");
932
+ } else {
933
+ // continue 거나 void 인 경우 빈 단락 추가
934
+ cellContent = `<w:p><w:pPr/></w:p>`;
654
935
  }
655
- cellContent = cell.kids.map((p: any) => encodeParaInner(p, ctx)).join('');
656
- } else {
657
- // continue 거나 void 인 경우 빈 단락 추가
658
- cellContent = `<w:p><w:pPr/></w:p>`;
659
- }
660
936
 
661
- const tcPr = `<w:tcPr>${tcPrParts.join('')}</w:tcPr>`;
662
- cellXmls.push(` <w:tc>${tcPr}${cellContent}</w:tc>`);
937
+ const tcPr = `<w:tcPr>${tcPrParts.join("")}</w:tcPr>`;
938
+ cellXmls.push(` <w:tc>${tcPr}${cellContent}</w:tc>`);
939
+ }
663
940
  }
664
- }
665
941
 
666
- const trPrParts: string[] = [];
667
- if (ri === 0 && (gp.headerRow || look?.firstRow)) {
668
- trPrParts.push('<w:tblHeader/>');
669
- }
670
- if (row.heightPt != null && row.heightPt > 0) {
671
- const hDxa = Math.round(Metric.ptToDxa(row.heightPt));
672
- trPrParts.push(`<w:trHeight w:val="${hDxa}" w:hRule="exact"/>`);
673
- }
674
- const trPr = trPrParts.length > 0 ? `<w:trPr>${trPrParts.join('')}</w:trPr>` : '';
942
+ const trPrParts: string[] = [];
943
+ if (ri === 0 && (gp.headerRow || look?.firstRow)) {
944
+ trPrParts.push("<w:tblHeader/>");
945
+ }
946
+ // 원본 정보에서 높이 가져오기 (tableMap 과 grid.kids 같은 인덱스)
947
+ const originalRow = grid.kids[ri];
948
+ if (originalRow?.heightPt != null && originalRow.heightPt > 0) {
949
+ const hDxa = Math.round(Metric.ptToDxa(originalRow.heightPt));
950
+ trPrParts.push(`<w:trHeight w:val="${hDxa}" w:hRule="atLeast"/>`);
951
+ }
952
+ const trPr =
953
+ trPrParts.length > 0 ? `<w:trPr>${trPrParts.join("")}</w:trPr>` : "";
675
954
 
676
- return ` <w:tr>${trPr}\n${cellXmls.join('\n')}\n </w:tr>`;
677
- }).join('\n');
955
+ return ` <w:tr>${trPr}\n${cellXmls.join("\n")}\n </w:tr>`;
956
+ })
957
+ .join("\n");
678
958
 
679
959
  // 4단계: 테두리 및 최종 테이블 XML 조립
680
- let tblBorders = '';
960
+ let tblBorders = "";
681
961
  const strokeKindMap: Record<string, string> = {
682
- solid: 'single', dash: 'dash', dot: 'dot', double: 'double', none: 'none',
683
- dotDash: 'dotDash', dotDotDash: 'dotDotDash', triple: 'triple', thinThickSmallGap: 'thinThickSmallGap',
684
- thickThinSmallGap: 'thickThinSmallGap', thinThickThinSmallGap: 'thinThickThinSmallGap',
962
+ solid: "single",
963
+ dash: "dash",
964
+ dot: "dot",
965
+ double: "double",
966
+ none: "none",
967
+ dotDash: "dotDash",
968
+ dotDotDash: "dotDotDash",
969
+ triple: "triple",
970
+ thinThickSmallGap: "thinThickSmallGap",
971
+ thickThinSmallGap: "thickThinSmallGap",
972
+ thinThickThinSmallGap: "thinThickThinSmallGap",
685
973
  };
686
974
 
687
975
  if (gp.defaultStroke) {
688
976
  const s = gp.defaultStroke;
689
- const val = strokeKindMap[s.kind] ?? 'single';
977
+ const val = strokeKindMap[s.kind] ?? "single";
690
978
 
691
- if (val === 'none' || s.pt <= 0) {
692
- tblBorders = '<w:tblBorders><w:top w:val="none"/><w:left w:val="none"/><w:bottom w:val="none"/><w:right w:val="none"/><w:insideH w:val="none"/><w:insideV w:val="none"/></w:tblBorders>';
979
+ if (val === "none" || s.pt <= 0) {
980
+ tblBorders =
981
+ '<w:tblBorders><w:top w:val="none"/><w:left w:val="none"/><w:bottom w:val="none"/><w:right w:val="none"/><w:insideH w:val="none"/><w:insideV w:val="none"/></w:tblBorders>';
693
982
  } else {
694
983
  // DOCX sz는 1/8pt 단위. 최소 굵기 2(0.25pt) 보장
695
984
  const sz = Math.max(2, Math.round(s.pt * 8));
696
985
  // 색상 '#' 제거 및 빈 값일 경우 auto 처리
697
- const clr = s.color ? s.color.replace('#', '') : 'auto';
986
+ const clr = s.color ? s.color.replace("#", "") : "auto";
698
987
  const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"`;
699
988
  tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
700
989
  }
701
990
  }
702
991
 
992
+ // ECMA-376 §17.4.58: tblJc — 표 가로 정렬 (start/center/end)
993
+ const tblAlignMap: Record<string, string> = {
994
+ left: "start",
995
+ center: "center",
996
+ right: "end",
997
+ justify: "start",
998
+ };
999
+ const tblJc = gp.align
1000
+ ? `<w:jc w:val="${tblAlignMap[gp.align] ?? "start"}"/>`
1001
+ : "";
1002
+
703
1003
  return ` <w:tbl>
704
- <w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="${Math.round(totalDxa)}" w:type="dxa"/><w:tblLayout w:type="fixed"/><w:tblLook w:val="04A0" w:firstRow="${firstRow}" w:lastRow="${lastRow}" w:firstColumn="${firstCol}" w:lastColumn="${lastCol}" w:noHBand="${noHBand}" w:noVBand="${noVBand}"/>${tblBorders}<w:tblCellMar><w:top w:w="28" w:type="dxa"/><w:left w:w="102" w:type="dxa"/><w:bottom w:w="28" w:type="dxa"/><w:right w:w="102" w:type="dxa"/></w:tblCellMar></w:tblPr>
1004
+ <w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="${Math.round(totalDxa)}" w:type="dxa"/><w:tblLayout w:type="fixed"/><w:tblLook w:val="04A0" w:firstRow="${firstRow}" w:lastRow="${lastRow}" w:firstColumn="${firstCol}" w:lastColumn="${lastCol}" w:noHBand="${noHBand}" w:noVBand="${noVBand}"/>${tblBorders}${tblJc}<w:tblCellMar><w:top w:w="28" w:type="dxa"/><w:left w:w="72" w:type="dxa"/><w:bottom w:w="28" w:type="dxa"/><w:right w:w="72" w:type="dxa"/></w:tblCellMar></w:tblPr>
705
1005
  <w:tblGrid>${gridCols}</w:tblGrid>
706
1006
  ${rows}
707
1007
  </w:tbl>`;
708
1008
  }
709
1009
  function encodeCellBorders(cp: CellProps): string {
710
- if (!cp.top && !cp.bot && !cp.left && !cp.right) return '';
1010
+ if (!cp.top && !cp.bot && !cp.left && !cp.right) return "";
711
1011
  const strokeKindMap: Record<string, string> = {
712
- solid: 'single', dash: 'dash', dot: 'dot', double: 'double', none: 'none',
713
- dotDash: 'dotDash', dotDotDash: 'dotDotDash', triple: 'triple',
1012
+ solid: "single",
1013
+ dash: "dash",
1014
+ dot: "dot",
1015
+ double: "double",
1016
+ none: "none",
1017
+ dotDash: "dotDash",
1018
+ dotDotDash: "dotDotDash",
1019
+ triple: "triple",
714
1020
  };
715
1021
 
716
- const encode = (s?: { kind: string; pt: number; color: string }, tag?: string) => {
717
- if (!s || !tag) return '';
718
- const val = strokeKindMap[s.kind] ?? 'single';
1022
+ const encode = (
1023
+ s?: { kind: string; pt: number; color: string },
1024
+ tag?: string,
1025
+ ) => {
1026
+ if (!s || !tag) return "";
1027
+ const val = strokeKindMap[s.kind] ?? "single";
719
1028
 
720
1029
  // 선이 없거나 굵기가 0 이하인 경우 확실하게 제거 처리
721
- if (val === 'none' || s.pt <= 0) {
1030
+ if (val === "none" || s.pt <= 0) {
722
1031
  return `<w:${tag} w:val="none" w:sz="0" w:space="0" w:color="auto"/>`;
723
1032
  }
724
1033
 
725
1034
  // 최소 굵기 sz=2 (0.25pt) 보장
726
1035
  const sz = Math.max(2, Math.round(s.pt * 8));
727
1036
  // 색상 '#' 제거 및 빈 값일 경우 auto 처리
728
- const clr = s.color ? s.color.replace('#', '') : 'auto';
1037
+ const clr = s.color ? s.color.replace("#", "") : "auto";
729
1038
 
730
1039
  return `<w:${tag} w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"/>`;
731
1040
  };
732
1041
 
733
- return `<w:tcBorders>${encode(cp.top, 'top')}${encode(cp.bot, 'bottom')}${encode(cp.left, 'left')}${encode(cp.right, 'right')}</w:tcBorders>`;
1042
+ return `<w:tcBorders>${encode(cp.top, "top")}${encode(cp.bot, "bottom")}${encode(cp.left, "left")}${encode(cp.right, "right")}</w:tcBorders>`;
734
1043
  }
735
1044
 
736
1045
  function esc(s: string): string {
737
- if (!s) return '';
738
- // 1. 내부 처리용 플레이스홀더(__EXT_0__ 등) 제거
739
- s = s.replace(/__EXT_\d+__/g, '');
1046
+ if (!s) return "";
1047
+ // 1. 내부 처리용 플레이스홀더(__EXT_0__ 또는 __EXT_0_W144_H108__ 등) 제거
1048
+ s = s.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
740
1049
 
741
1050
  // 2. 글자 깨짐을 유발하는 쓰레기값 및 BOM 기호 명시적 제거 (이 부분 추가!)
742
- s = s.replace(/湰灧/g, '');
743
- s = s.replace(/\uFEFF/g, '');
1051
+ s = s.replace(/湰灧/g, "");
1052
+ s = s.replace(/\uFEFF/g, "");
744
1053
 
745
1054
  // 3. DOCX(XML 1.0)에서 허용하지 않는 보이지 않는 제어문자 모두 제거
746
- s = s.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]/g, '');
1055
+ s = s.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]/g, "");
747
1056
 
748
1057
  return TextKit.escapeXml(s);
749
1058
  }