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,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 ?? [];
22
-
23
- const images: ImageEntry[] = [];
24
- const ctx: EncCtx = { images, nextId: 10, nextImgNum: 1, warns: [], imgMap: new WeakMap() };
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);
25
39
 
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++}` : "";
67
+
68
+ // Numbering: collect list info from all sections
69
+ const numInfo = collectNumbering(allKids);
41
70
 
42
- // Numbering: collect list info
43
- const numInfo = collectNumbering(kids);
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,7 +315,7 @@ 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>
@@ -252,7 +326,7 @@ function stylesXml(): string {
252
326
  <w:style w:type="paragraph" w:styleId="Header"><w:name w:val="header"/><w:basedOn w:val="Normal"/></w:style>
253
327
  <w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/></w:style>
254
328
  <w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/></w:pPr></w:style>
255
- <w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders></w:tblPr></w:style>
329
+ <w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:left w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:bottom w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:right w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:insideH w:val="none" w:sz="0" w:space="0" w:color="auto"/><w:insideV w:val="none" w:sz="0" w:space="0" w:color="auto"/></w:tblBorders></w:tblPr></w:style>
256
330
  </w:styles>`;
257
331
  }
258
332
 
@@ -278,14 +352,14 @@ function settingsXml(): string {
278
352
  // ─── numbering.xml ──────────────────────────────────────────
279
353
 
280
354
  function numberingXml(info: NumInfo): string {
281
- let abstractNums = '';
282
- let nums = '';
355
+ let abstractNums = "";
356
+ let nums = "";
283
357
 
284
358
  // Bullet list: abstractNumId=0, numId=1
285
359
  if (info.hasBullet) {
286
360
  abstractNums += `<w:abstractNum w:abstractNumId="0">`;
287
361
  for (let lvl = 0; lvl < 9; lvl++) {
288
- const marker = lvl === 0 ? '' : lvl === 1 ? '' : '';
362
+ const marker = lvl === 0 ? "" : lvl === 1 ? "" : "";
289
363
  const indent = (lvl + 1) * 720;
290
364
  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
365
  }
@@ -297,7 +371,12 @@ function numberingXml(info: NumInfo): string {
297
371
  if (info.hasNumbered) {
298
372
  abstractNums += `<w:abstractNum w:abstractNumId="1">`;
299
373
  for (let lvl = 0; lvl < 9; lvl++) {
300
- const fmt = lvl % 3 === 0 ? 'decimal' : lvl % 3 === 1 ? 'lowerLetter' : 'lowerRoman';
374
+ const fmt =
375
+ lvl % 3 === 0
376
+ ? "decimal"
377
+ : lvl % 3 === 1
378
+ ? "lowerLetter"
379
+ : "lowerRoman";
301
380
  const indent = (lvl + 1) * 720;
302
381
  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
382
  }
@@ -314,9 +393,13 @@ function numberingXml(info: NumInfo): string {
314
393
 
315
394
  // ─── header / footer xml ────────────────────────────────────
316
395
 
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');
396
+ function headerFooterXml(
397
+ type: "hdr" | "ftr",
398
+ paras: ParaNode[],
399
+ ctx: EncCtx,
400
+ ): string {
401
+ const tag = type === "hdr" ? "w:hdr" : "w:ftr";
402
+ const body = paras.map((p) => encodeParaInner(p, ctx)).join("\n");
320
403
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
321
404
  <${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
405
  ${body}
@@ -325,66 +408,110 @@ ${body}
325
408
 
326
409
  // ─── document.xml ───────────────────────────────────────────
327
410
 
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');
411
+ function documentXml(
412
+ kids: ContentNode[],
413
+ dims: PageDims,
414
+ ctx: EncCtx,
415
+ headerRId?: string,
416
+ footerRId?: string,
417
+ ): string {
418
+ const body = kids.map((k) => encodeContent(k, ctx, dims)).join("\n");
330
419
 
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}"/>`;
420
+ let sectRefs = "";
421
+ if (headerRId)
422
+ sectRefs += `\n <w:headerReference w:type="default" r:id="${headerRId}"/>`;
423
+ if (footerRId)
424
+ sectRefs += `\n <w:footerReference w:type="default" r:id="${footerRId}"/>`;
334
425
 
335
426
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
336
427
  <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
428
  <w:body>
338
429
  ${body}
339
430
  <w:sectPr>${sectRefs}
340
- <w:pgSz w:w="${Metric.ptToDxa(dims.wPt)}" w:h="${Metric.ptToDxa(dims.hPt)}" w:orient="${dims.orient ?? 'portrait'}"/>
431
+ <w:pgSz w:w="${Metric.ptToDxa(dims.wPt)}" w:h="${Metric.ptToDxa(dims.hPt)}" w:orient="${dims.orient ?? "portrait"}"/>
341
432
  <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
433
  </w:sectPr>
343
434
  </w:body>
344
435
  </w:document>`;
345
436
  }
346
437
 
347
- function encodeContent(node: ContentNode, ctx: EncCtx, dims?: PageDims): string {
348
- return node.tag === 'grid' ? encodeGrid(node, ctx, dims) : encodeParaInner(node, ctx);
438
+ function encodeContent(
439
+ node: ContentNode,
440
+ ctx: EncCtx,
441
+ dims?: PageDims,
442
+ ): string {
443
+ return node.tag === "grid"
444
+ ? encodeGrid(node, ctx, dims)
445
+ : encodeParaInner(node, ctx);
349
446
  }
350
447
 
351
448
  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}"/>` : '';
449
+ const align = para.props.align ?? "left";
450
+ const headStyle = para.props.heading
451
+ ? `<w:pStyle w:val="Heading${para.props.heading}"/>`
452
+ : "";
354
453
 
355
454
  // List numbering
356
- let numPr = '';
455
+ let numPr = "";
357
456
  if (para.props.listOrd !== undefined) {
358
457
  const numId = para.props.listOrd ? 2 : 1;
359
458
  const ilvl = para.props.listLv ?? 0;
360
459
  numPr = `<w:pStyle w:val="ListParagraph"/><w:numPr><w:ilvl w:val="${ilvl}"/><w:numId w:val="${numId}"/></w:numPr>`;
361
460
  }
362
461
 
363
- // Spacing (before / after / line height)
364
- let spacingXml = '';
365
- const { spaceBefore, spaceAfter, lineHeight } = para.props;
366
- if (spaceBefore !== undefined || spaceAfter !== undefined || lineHeight !== undefined) {
462
+ // Spacing (before / after / line height) - ensure all values are non-negative
463
+ // ECMA-376 §17.3.1.33 spacing: line은 1/240th of a line(auto) 또는 dxa(exact/atLeast)
464
+ let spacingXml = "";
465
+ const { spaceBefore, spaceAfter, lineHeight, lineHeightFixed } = para.props;
466
+ if (
467
+ spaceBefore !== undefined ||
468
+ spaceAfter !== undefined ||
469
+ lineHeight !== undefined ||
470
+ lineHeightFixed !== undefined
471
+ ) {
367
472
  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)}"/>`;
473
+ if (spaceBefore !== undefined)
474
+ parts.push(`w:before="${Math.max(0, Metric.ptToDxa(spaceBefore))}"`);
475
+ if (spaceAfter !== undefined)
476
+ parts.push(`w:after="${Math.max(0, Metric.ptToDxa(spaceAfter))}"`);
477
+ if (lineHeightFixed !== undefined) {
478
+ // FIXED: lineRule="exact", line값은 dxa (1pt = 20dxa)
479
+ parts.push(
480
+ `w:line="${Math.max(1, Metric.ptToDxa(lineHeightFixed))}" w:lineRule="exact"`,
481
+ );
482
+ } else if (lineHeight !== undefined) {
483
+ parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
484
+ }
485
+ spacingXml = `<w:spacing ${parts.join(" ")}/>`;
378
486
  }
379
487
 
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('');
488
+ // Indentation ECMA-376 §17.3.1.12 ind
489
+ // w:left = 전체 왼쪽 여백(dxa), w:right = 전체 오른쪽 여백(dxa)
490
+ // w:firstLine = 첫 줄 추가 들여쓰기(dxa, 양수), w:hanging = 내어쓰기(dxa, 양수값으로 표현)
491
+ let indentXml = "";
492
+ const leftDxa = Math.round(Metric.ptToDxa(para.props.indentPt ?? 0));
493
+ const rightDxa = Math.round(Metric.ptToDxa(para.props.indentRightPt ?? 0));
494
+ const firstPt = para.props.firstLineIndentPt ?? 0;
495
+
496
+ const indParts: string[] = [];
497
+ if (leftDxa > 0) indParts.push(`w:left="${leftDxa}"`);
498
+ if (rightDxa > 0) indParts.push(`w:right="${rightDxa}"`);
499
+ if (firstPt > 0)
500
+ indParts.push(`w:firstLine="${Math.round(Metric.ptToDxa(firstPt))}"`);
501
+ if (firstPt < 0)
502
+ indParts.push(`w:hanging="${Math.round(Metric.ptToDxa(-firstPt))}"`);
503
+ if (indParts.length > 0) indentXml = `<w:ind ${indParts.join(" ")}/>`;
504
+
505
+ const runs = para.kids
506
+ .map((k) => {
507
+ if (k.tag === "span") return encodeRun(k, ctx);
508
+ if (k.tag === "img") return encodeImage(k, ctx);
509
+ return "";
510
+ })
511
+ .join("");
385
512
 
386
513
  return ` <w:p>
387
- <w:pPr>${headStyle}${numPr}${spacingXml}${indentXml}<w:jc w:val="${align === 'justify' ? 'both' : align}"/></w:pPr>
514
+ <w:pPr>${headStyle}${numPr}${spacingXml}${indentXml}<w:jc w:val="${align === "justify" ? "both" : align}"/></w:pPr>
388
515
  ${runs}
389
516
  </w:p>`;
390
517
  }
@@ -392,319 +519,497 @@ function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
392
519
  function encodeRun(span: SpanNode, _ctx: EncCtx): string {
393
520
  const p = span.props;
394
521
  const rPr: string[] = [];
395
- if (p.b) rPr.push('<w:b/>');
396
- if (p.i) rPr.push('<w:i/>');
522
+ if (p.b) rPr.push("<w:b/>");
523
+ if (p.i) rPr.push("<w:i/>");
397
524
  if (p.u) rPr.push('<w:u w:val="single"/>');
398
- if (p.s) rPr.push('<w:strike/>');
525
+ if (p.s) rPr.push("<w:strike/>");
399
526
  if (p.sup) rPr.push('<w:vertAlign w:val="superscript"/>');
400
527
  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)}"/>`);
528
+ if (p.pt)
529
+ rPr.push(
530
+ `<w:sz w:val="${Metric.ptToHalfPt(p.pt)}"/><w:szCs w:val="${Metric.ptToHalfPt(p.pt)}"/>`,
531
+ );
402
532
  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)}"/>`);
533
+ if (p.font)
534
+ rPr.push(
535
+ `<w:rFonts w:ascii="${esc(p.font)}" w:hAnsi="${esc(p.font)}" w:eastAsia="${esc(p.font)}"/>`,
536
+ );
404
537
  if (p.bg) rPr.push(`<w:shd w:val="clear" w:color="auto" w:fill="${p.bg}"/>`);
405
538
 
406
539
  // Process kids — text and pagenum
407
540
  const parts: string[] = [];
408
541
  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') {
542
+ if (kid.tag === "txt") {
543
+ // __EXT_N__ or __EXT_N_W<w>_H<h>__ 자리표시자 제거
544
+ const content = kid.content.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
545
+ if (content) {
546
+ parts.push(
547
+ `<w:r><w:rPr>${rPr.join("")}</w:rPr><w:t xml:space="preserve">${esc(content)}</w:t></w:r>`,
548
+ );
549
+ }
550
+ } else if (kid.tag === "pagenum") {
551
+ parts.push(
552
+ `<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>`,
553
+ );
554
+ } else if (kid.tag === "br") {
414
555
  parts.push(`<w:r><w:br/></w:r>`);
415
- } else if (kid.tag === 'pb') {
556
+ } else if (kid.tag === "pb") {
416
557
  parts.push(`<w:r><w:br w:type="page"/></w:r>`);
417
558
  }
418
559
  }
419
560
 
420
- return parts.join('');
561
+ return parts.join("");
421
562
  }
422
563
 
423
564
  function encodeImage(img: ImgNode, ctx: EncCtx): string {
424
565
  const rId = ctx.imgMap.get(img);
425
- if (!rId) return '';
566
+ if (!rId) return "";
426
567
 
427
- const cx = Metric.ptToEmu(img.w);
428
- const cy = Metric.ptToEmu(img.h);
429
- const alt = esc(img.alt ?? '');
568
+ // Fallback to 72pt (1 inch) when w/h are 0 to prevent invisible images
569
+ const cx = Metric.ptToEmu(img.w > 0 ? img.w : 72);
570
+ const cy = Metric.ptToEmu(img.h > 0 ? img.h : 72);
571
+ const alt = esc(img.alt ?? "");
430
572
  const docPrId = ctx.nextId++;
431
573
 
432
574
  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
575
 
434
576
  const layout = img.layout;
435
- const isInline = !layout || layout.wrap === 'inline';
436
577
 
437
- if (isInline) {
578
+ const isInline = !layout || layout.wrap === "inline";
579
+ // topAndBottom은 반드시 anchor (float)로 처리
580
+ const forceAnchor =
581
+ layout?.wrap === "topAndBottom" ||
582
+ layout?.wrap === "square" ||
583
+ layout?.wrap === "tight" ||
584
+ layout?.wrap === "behind" ||
585
+ layout?.wrap === "front";
586
+ if (isInline && !forceAnchor) {
438
587
  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
588
  }
440
-
441
- return `<w:r><w:drawing>${encodeAnchor(img, cx, cy, alt, docPrId, graphic, layout)}</w:drawing></w:r>`;
589
+ return `<w:r><w:drawing>${encodeAnchor(img, cx, cy, alt, docPrId, graphic, layout!)}</w:drawing></w:r>`;
442
590
  }
443
591
 
444
592
  function encodeAnchor(
445
- _img: ImgNode, cx: number, cy: number, alt: string, docPrId: number,
446
- graphic: string, layout: NonNullable<ImgNode['layout']>,
593
+ _img: ImgNode,
594
+ cx: number,
595
+ cy: number,
596
+ alt: string,
597
+ docPrId: number,
598
+ graphic: string,
599
+ layout: NonNullable<ImgNode["layout"]>,
447
600
  ): string {
448
601
  const distT = Metric.ptToEmu(layout.distT ?? 0);
449
602
  const distB = Metric.ptToEmu(layout.distB ?? 0);
450
- const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
603
+ const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
451
604
  const distR = Metric.ptToEmu(layout.distR ?? 9144);
452
- const behindDoc = (layout.behindDoc || layout.wrap === 'behind') ? '1' : '0';
605
+ const behindDoc = layout.behindDoc || layout.wrap === "behind" ? "1" : "0";
453
606
  const relH = layout.zOrder ?? 251658240;
454
607
 
455
608
  // 가로 위치
456
- const horzRelFrom = HORZ_RELTO_DOCX[layout.horzRelTo ?? 'column'] ?? 'column';
609
+ const horzRelFrom = HORZ_RELTO_DOCX[layout.horzRelTo ?? "column"] ?? "column";
457
610
  let posH: string;
458
611
  if (layout.xPt != null) {
459
612
  posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.xPt)}</wp:posOffset></wp:positionH>`;
460
613
  } else {
461
- const ha = HORZ_ALIGN_DOCX[layout.horzAlign ?? 'left'] ?? 'left';
614
+ const ha = HORZ_ALIGN_DOCX[layout.horzAlign ?? "left"] ?? "left";
462
615
  posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:align>${ha}</wp:align></wp:positionH>`;
463
616
  }
464
617
 
465
618
  // 세로 위치
466
- const vertRelFrom = VERT_RELTO_DOCX[layout.vertRelTo ?? 'para'] ?? 'paragraph';
619
+ const vertRelFrom =
620
+ VERT_RELTO_DOCX[layout.vertRelTo ?? "para"] ?? "paragraph";
467
621
  let posV: string;
468
622
  if (layout.yPt != null) {
469
623
  posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.yPt)}</wp:posOffset></wp:positionV>`;
470
624
  } else {
471
- const va = VERT_ALIGN_DOCX[layout.vertAlign ?? 'top'] ?? 'top';
625
+ const va = VERT_ALIGN_DOCX[layout.vertAlign ?? "top"] ?? "top";
472
626
  posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:align>${va}</wp:align></wp:positionV>`;
473
627
  }
474
628
 
475
629
  // 텍스트 감싸기
476
- const wrapXml = WRAP_DOCX[layout.wrap] ?? '<wp:wrapSquare wrapText="bothSides"/>';
630
+ const wrapXml =
631
+ WRAP_DOCX[layout.wrap] ?? '<wp:wrapSquare wrapText="bothSides"/>';
477
632
 
478
633
  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
634
  }
480
635
 
481
636
  const HORZ_RELTO_DOCX: Record<string, string> = {
482
- margin: 'margin', column: 'column', page: 'page', para: 'paragraph',
637
+ margin: "margin",
638
+ column: "column",
639
+ page: "page",
640
+ para: "paragraph",
483
641
  };
484
642
  const VERT_RELTO_DOCX: Record<string, string> = {
485
- margin: 'margin', line: 'line', page: 'page', para: 'paragraph',
643
+ margin: "margin",
644
+ line: "line",
645
+ page: "page",
646
+ para: "paragraph",
486
647
  };
487
648
  const HORZ_ALIGN_DOCX: Record<string, string> = {
488
- left: 'left', center: 'center', right: 'right',
649
+ left: "left",
650
+ center: "center",
651
+ right: "right",
489
652
  };
490
653
  const VERT_ALIGN_DOCX: Record<string, string> = {
491
- top: 'top', center: 'center', bottom: 'bottom',
654
+ top: "top",
655
+ center: "center",
656
+ bottom: "bottom",
492
657
  };
658
+ // ECMA-376 §20.4.2 wordprocessingDrawing wrapping elements
493
659
  const WRAP_DOCX: Record<string, string> = {
494
- 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/>',
660
+ square: '<wp:wrapSquare wrapText="bothSides"/>',
661
+ tight:
662
+ '<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>',
663
+ through:
664
+ '<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>',
665
+ // ECMA-376 §20.4.2.15: wrapTopAndBottom — 텍스트가 이미지 위아래로만 흐름
666
+ topAndBottom: "<wp:wrapTopAndBottom/>",
667
+ none: "<wp:wrapNone/>",
668
+ behind: "<wp:wrapNone/>",
669
+ front: "<wp:wrapNone/>",
500
670
  };
501
671
 
502
- function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
672
+ function encodeGrid(grid: GridNode, ctx: EncCtx, dims: PageDims = A4): string {
503
673
  const gp = grid.props;
504
674
  const look = gp.look;
505
675
 
506
676
  // 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';
513
-
514
- // Determine actual grid column count from colWidths or by scanning all rows
677
+ const firstRow = look?.firstRow ? "1" : "0";
678
+ const lastRow = look?.lastRow ? "1" : "0";
679
+ const firstCol = look?.firstCol ? "1" : "0";
680
+ const lastCol = look?.lastCol ? "1" : "0";
681
+ const noHBand = look?.bandedRows ? "0" : "1";
682
+ const noVBand = look?.bandedCols ? "0" : "1";
683
+
515
684
  const d = dims ?? A4;
516
685
  const availDxa = Metric.ptToDxa(d.wPt - d.ml - d.mr);
517
686
 
518
- // Compute true column count: max total colSpan across all rows
519
- let colCount = 0;
520
- for (const row of grid.kids) {
521
- let rowCols = 0;
522
- for (const cell of row.kids) rowCols += cell.cs;
523
- if (rowCols > colCount) colCount = rowCols;
524
- }
525
- if (colCount === 0) colCount = grid.kids[0]?.kids.length ?? 1;
526
-
527
- const defaultColDxa = Math.round(availDxa / colCount);
528
-
529
- // Use actual column widths if available from source format
530
- const colWidthsDxa: number[] = [];
531
- if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
532
- // Fill zero-width columns by distributing remaining space
533
- const srcPt = [...grid.props.colWidths];
534
- const knownTotal = srcPt.filter(w => w > 0).reduce((s, w) => s + w, 0);
535
- const zeroCount = srcPt.filter(w => w <= 0).length;
536
- const remaining = Math.max(0, Metric.dxaToPt(availDxa) - knownTotal);
537
- const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
538
- for (let i = 0; i < srcPt.length; i++) {
539
- if (srcPt[i] <= 0) srcPt[i] = zeroFill > 0 ? zeroFill : Metric.dxaToPt(defaultColDxa);
540
- }
541
- const srcWidths = srcPt.map(w => Metric.ptToDxa(w));
542
- const srcTotal = srcWidths.reduce((s, w) => s + w, 0);
543
- // Normalize to fit available page width if source widths exceed it
544
- const scale = srcTotal > availDxa ? availDxa / srcTotal : 1;
545
- for (const w of srcWidths) colWidthsDxa.push(Math.round(w * scale));
546
- } else {
547
- for (let c = 0; c < colCount; c++) colWidthsDxa.push(defaultColDxa);
687
+ // 1단계: 표의 가상 2D 맵핑 (Virtual Table Map) 생성
688
+ // 'real': 데이터 셀, 'continue': 세로 병합 지속 셀, 'absorbed': 가로/세로 병합으로 흡수된 자리, 'void': 빈 공간
689
+ interface CellMap {
690
+ type: "real" | "continue" | "absorbed" | "void";
691
+ cell?: any;
692
+ width?: number;
548
693
  }
549
- const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
694
+ const tableMap: CellMap[][] = Array.from(
695
+ { length: grid.kids.length },
696
+ () => [],
697
+ );
550
698
 
551
- // Grid columns
552
- const gridCols = colWidthsDxa.map(w => `<w:gridCol w:w="${Math.round(w)}"/>`).join('');
553
-
554
- // Pre-compute vMerge map: for each (ri, colIdx), track if a cell with rs>1 spans into this row
555
- // Key: "ri,colIdx", Value: 'restart' | 'continue'
556
- const vMergeMap = new Map<string, 'restart' | 'continue'>();
557
699
  for (let ri = 0; ri < grid.kids.length; ri++) {
558
- let colIdx = 0;
700
+ let c = 0;
559
701
  for (const cell of grid.kids[ri].kids) {
560
- if (cell.rs > 1) {
561
- vMergeMap.set(`${ri},${colIdx}`, 'restart');
562
- for (let sr = 1; sr < cell.rs; sr++) {
563
- vMergeMap.set(`${ri + sr},${colIdx}`, 'continue');
702
+ // 이미 이전 행의 rowspan이나 현재 행의 colspan으로 차지된 자리 건너뜀
703
+ while (tableMap[ri][c]) c++;
704
+
705
+ // 실제 데이터 셀 배치
706
+ tableMap[ri][c] = { type: "real", cell, width: cell.cs };
707
+
708
+ // 병합 영역(colspan, rowspan) 예약 처리
709
+ for (let rr = 0; rr < cell.rs; rr++) {
710
+ const targetRi = ri + rr;
711
+ if (targetRi >= grid.kids.length) break;
712
+ if (!tableMap[targetRi]) tableMap[targetRi] = [];
713
+
714
+ for (let cc = 0; cc < cell.cs; cc++) {
715
+ if (rr === 0 && cc === 0) continue; // 시작 셀은 이미 'real'로 처리됨
716
+
717
+ if (rr > 0 && cc === 0) {
718
+ // 세로 병합이 시작된 이후 행의 첫 번째 칸
719
+ tableMap[targetRi][c + cc] = { type: "continue", width: cell.cs };
720
+ } else {
721
+ // 가로 병합으로 흡수된 칸 또는 세로 병합 중 가로 병합된 칸
722
+ tableMap[targetRi][c + cc] = { type: "absorbed" };
723
+ }
564
724
  }
565
725
  }
566
- colIdx += cell.cs;
726
+ c += cell.cs;
567
727
  }
568
728
  }
569
729
 
570
- const rows = grid.kids.map((row, ri) => {
571
- let colIdx = 0;
730
+ // 정확한 전체 열 개수(colCount) 계산 (모든 최대 길이)
731
+ let colCount = 0;
732
+ for (let ri = 0; ri < grid.kids.length; ri++) {
733
+ colCount = Math.max(colCount, tableMap[ri].length);
734
+ }
735
+ if (colCount === 0) colCount = 1;
572
736
 
573
- // Build actual cells including continuation cells for vMerge
574
- const cellXmls: string[] = [];
575
- let srcCellIdx = 0;
737
+ // 공간(void) 채우기 colCount에 맞춰 배열 길이 정규화
738
+ for (let ri = 0; ri < grid.kids.length; ri++) {
739
+ for (let c = 0; c < colCount; c++) {
740
+ if (!tableMap[ri][c]) tableMap[ri][c] = { type: "void" };
741
+ }
742
+ }
576
743
 
577
- // Walk through grid columns, emitting either real cells or vMerge continue cells
578
- while (srcCellIdx < row.kids.length) {
579
- const cell = row.kids[srcCellIdx];
580
- const mergeType = vMergeMap.get(`${ri},${colIdx}`);
744
+ // 2단계: 컬럼 너비(dxa) 계산
745
+ const defaultColDxa = Math.round(availDxa / colCount);
746
+ let colWidthsDxa: number[] = [];
581
747
 
582
- const cp = cell.props;
583
- const tcPrParts: string[] = [];
748
+ if (grid.props.colWidths && grid.props.colWidths.length > 0) {
749
+ const srcPt = [...grid.props.colWidths];
750
+ while (srcPt.length < colCount) srcPt.push(0);
751
+ srcPt.length = colCount;
584
752
 
585
- // Cell width in DXA (sum of spanned columns)
586
- let cellW = 0;
587
- for (let sc = colIdx; sc < colIdx + cell.cs && sc < colWidthsDxa.length; sc++) cellW += colWidthsDxa[sc];
588
- if (cellW === 0) cellW = defaultColDxa * cell.cs;
589
- tcPrParts.push(`<w:tcW w:w="${Math.round(cellW)}" w:type="dxa"/>`);
753
+ const knownTotalPt = srcPt.filter((w) => w > 0).reduce((s, w) => s + w, 0);
754
+ const zeroCount = srcPt.filter((w) => w <= 0).length;
755
+ const availPt = Metric.dxaToPt(availDxa);
590
756
 
591
- if (cell.cs > 1) tcPrParts.push(`<w:gridSpan w:val="${cell.cs}"/>`);
757
+ const remainingPt = Math.max(0, availPt - knownTotalPt);
758
+ const zeroFillPt = zeroCount > 0 ? remainingPt / zeroCount : 0;
592
759
 
593
- // vMerge
594
- if (cell.rs > 1) {
595
- tcPrParts.push(`<w:vMerge w:val="restart"/>`);
760
+ for (let i = 0; i < srcPt.length; i++) {
761
+ if (srcPt[i] <= 0) {
762
+ srcPt[i] = zeroFillPt > 0 ? zeroFillPt : availPt / colCount;
596
763
  }
764
+ }
597
765
 
598
- // Cell borders
599
- const borders = encodeCellBorders(cp);
600
- if (borders) tcPrParts.push(borders);
766
+ colWidthsDxa = srcPt.map((w) => Math.round(Metric.ptToDxa(w)));
767
+ const computedTotalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
768
+ if (computedTotalDxa > availDxa) {
769
+ const scale = availDxa / computedTotalDxa;
770
+ colWidthsDxa = colWidthsDxa.map((w) => Math.round(w * scale));
771
+ }
772
+ } else {
773
+ for (let c = 0; c < colCount; c++) colWidthsDxa.push(defaultColDxa);
774
+ }
601
775
 
602
- // Cell background
603
- if (cp.bg) tcPrParts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`);
776
+ const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
777
+ const gridCols = colWidthsDxa.map((w) => `<w:gridCol w:w="${w}"/>`).join("");
778
+
779
+ // 3단계: XML 렌더링
780
+ const rows = tableMap
781
+ .map((rowMap, ri) => {
782
+ const cellXmls: string[] = [];
783
+
784
+ for (let c = 0; c < colCount; c++) {
785
+ const mapEntry = rowMap[c];
786
+
787
+ // 가로 병합으로 흡수된 칸은 렌더링하지 않음 (앞선 칸의 gridSpan이 차지)
788
+ if (mapEntry.type === "absorbed") continue;
789
+
790
+ // 세로 병합 지속(continue), 실제 셀(real), 또는 빈 공간(void) 처리
791
+ const isContinue = mapEntry.type === "continue";
792
+ const isReal = mapEntry.type === "real";
793
+ const isVoid = mapEntry.type === "void";
794
+
795
+ if (isContinue || isReal || isVoid) {
796
+ let cw = 0;
797
+ const cellWidth = mapEntry.width || 1;
798
+
799
+ // 너비 계산: 현재 칸부터 colspan(width) 만큼의 컬럼 너비 합산
800
+ // colWidthsDxa 가 colCount 보다 작은 경우를 대비하여 safe 하게 처리
801
+ const safeColWidths =
802
+ colWidthsDxa.length >= colCount
803
+ ? colWidthsDxa
804
+ : [
805
+ ...colWidthsDxa,
806
+ ...Array(colCount - colWidthsDxa.length).fill(defaultColDxa),
807
+ ];
808
+ for (
809
+ let sc = c;
810
+ sc < c + cellWidth && sc < safeColWidths.length;
811
+ sc++
812
+ ) {
813
+ cw += safeColWidths[sc];
814
+ }
815
+ if (cw <= 0) cw = defaultColDxa * cellWidth;
604
816
 
605
- // Vertical alignment
606
- if (cp.va) {
607
- const vaMap: Record<string, string> = { top: 'top', mid: 'center', bot: 'bottom' };
608
- tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? 'top'}"/>`);
609
- }
817
+ const tcPrParts: string[] = [];
818
+ tcPrParts.push(`<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`);
610
819
 
611
- const tcPr = `<w:tcPr>${tcPrParts.join('')}</w:tcPr>`;
612
- cellXmls.push(` <w:tc>${tcPr}${cell.kids.map(p => encodeParaInner(p, ctx)).join('')}</w:tc>`);
613
- colIdx += cell.cs;
614
- srcCellIdx++;
615
- }
820
+ if (cellWidth > 1) {
821
+ tcPrParts.push(`<w:gridSpan w:val="${cellWidth}"/>`);
822
+ }
616
823
 
617
- // Now emit vMerge continue cells for rows that are spanned into
618
- // We need to check if any cells from rows above span into this row
619
- // Re-walk grid columns looking for continue cells not covered by this row's cells
620
- const finalCells: string[] = [];
621
- let finalColIdx = 0;
622
- let cellIter = 0;
623
-
624
- // Use the computed colCount (max across all rows)
625
- const totalGridCols = colCount;
626
-
627
- // Re-check: we need to interleave vMerge continue cells
628
- // The current row may have fewer cells because the model already skipped continuation cells
629
- // So we need to insert continuation cells where vMergeMap says 'continue' for this row
630
- finalColIdx = 0;
631
- cellIter = 0;
632
- for (let gc = 0; gc < totalGridCols; ) {
633
- const mergeType = vMergeMap.get(`${ri},${gc}`);
634
- if (mergeType === 'continue') {
635
- // Find the original cell's cs from the restart row
636
- let origCs = 1;
637
- for (let sr = ri - 1; sr >= 0; sr--) {
638
- const mt = vMergeMap.get(`${sr},${gc}`);
639
- if (mt === 'restart') {
640
- // Find the cell at this column in that row
641
- let col = 0;
642
- for (const c of grid.kids[sr].kids) {
643
- if (col === gc) { origCs = c.cs; break; }
644
- col += c.cs;
824
+ if (isContinue) {
825
+ tcPrParts.push(`<w:vMerge/>`);
826
+ }
827
+
828
+ let cellContent = "";
829
+ if (isReal) {
830
+ const cell = mapEntry.cell!;
831
+ const cp = cell.props;
832
+ if (cell.rs > 1) tcPrParts.push(`<w:vMerge w:val="restart"/>`);
833
+
834
+ const borders = encodeCellBorders(cp);
835
+ if (borders) tcPrParts.push(borders);
836
+ if (cp.bg)
837
+ tcPrParts.push(
838
+ `<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`,
839
+ );
840
+ if (cp.va) {
841
+ const vaMap: Record<string, string> = {
842
+ top: "top",
843
+ mid: "center",
844
+ bot: "bottom",
845
+ };
846
+ tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? "top"}"/>`);
847
+ }
848
+ // Per-cell margin override (only when explicitly set)
849
+ const cPadT =
850
+ cp.padT != null ? Math.round(Metric.ptToDxa(cp.padT)) : null;
851
+ const cPadB =
852
+ cp.padB != null ? Math.round(Metric.ptToDxa(cp.padB)) : null;
853
+ const cPadL =
854
+ cp.padL != null ? Math.round(Metric.ptToDxa(cp.padL)) : null;
855
+ const cPadR =
856
+ cp.padR != null ? Math.round(Metric.ptToDxa(cp.padR)) : null;
857
+ if (
858
+ cPadT != null ||
859
+ cPadB != null ||
860
+ cPadL != null ||
861
+ cPadR != null
862
+ ) {
863
+ const t = cPadT ?? 28;
864
+ const b = cPadB ?? 28;
865
+ const l = cPadL ?? 72;
866
+ const r = cPadR ?? 72;
867
+ tcPrParts.push(
868
+ `<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>`,
869
+ );
645
870
  }
646
- break;
871
+ // Encode cell content: paragraphs and nested grids (중첩 표)
872
+ // DOCX cells must end with <w:p>; nested <w:tbl> goes between paras
873
+ const parts: string[] = [];
874
+ for (const kid of cell.kids) {
875
+ if (kid.tag === "grid") {
876
+ parts.push(encodeGrid(kid, ctx)); // dims 제거 — 중첩 표는 부모 dims 불필요
877
+ } else if (kid.tag === "para") {
878
+ parts.push(encodeParaInner(kid, ctx));
879
+ }
880
+ }
881
+ // DOCX 셀은 반드시 <w:p>로 끝나야 함
882
+ const lastKid = cell.kids[cell.kids.length - 1];
883
+ if (cell.kids.length === 0 || lastKid?.tag === "grid") {
884
+ parts.push('<w:p><w:pPr><w:spacing w:after="0"/></w:pPr></w:p>');
885
+ }
886
+ cellContent = parts.join("");
887
+ } else {
888
+ // continue 거나 void 인 경우 빈 단락 추가
889
+ cellContent = `<w:p><w:pPr/></w:p>`;
647
890
  }
648
- }
649
- let cw = 0;
650
- for (let sc = gc; sc < gc + origCs && sc < colWidthsDxa.length; sc++) cw += colWidthsDxa[sc];
651
- if (cw === 0) cw = defaultColDxa * origCs;
652
- let contParts = `<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`;
653
- if (origCs > 1) contParts += `<w:gridSpan w:val="${origCs}"/>`;
654
- contParts += `<w:vMerge/>`;
655
- finalCells.push(` <w:tc><w:tcPr>${contParts}</w:tcPr><w:p><w:pPr/></w:p></w:tc>`);
656
- gc += origCs;
657
- } else {
658
- if (cellIter < cellXmls.length) {
659
- finalCells.push(cellXmls[cellIter]);
660
- gc += row.kids[cellIter]?.cs ?? 1;
661
- cellIter++;
662
- } else {
663
- gc++;
891
+
892
+ const tcPr = `<w:tcPr>${tcPrParts.join("")}</w:tcPr>`;
893
+ cellXmls.push(` <w:tc>${tcPr}${cellContent}</w:tc>`);
664
894
  }
665
895
  }
666
- }
667
-
668
- // Header row
669
- let trPr = '';
670
- if (ri === 0 && (gp.headerRow || look?.firstRow)) {
671
- trPr = '<w:trPr><w:tblHeader/></w:trPr>';
672
- }
673
896
 
674
- return ` <w:tr>${trPr}\n${finalCells.join('\n')}\n </w:tr>`;
675
- }).join('\n');
897
+ const trPrParts: string[] = [];
898
+ if (ri === 0 && (gp.headerRow || look?.firstRow)) {
899
+ trPrParts.push("<w:tblHeader/>");
900
+ }
901
+ // 원본 행 정보에서 높이 가져오기 (tableMap 과 grid.kids 는 같은 인덱스)
902
+ const originalRow = grid.kids[ri];
903
+ if (originalRow?.heightPt != null && originalRow.heightPt > 0) {
904
+ const hDxa = Math.round(Metric.ptToDxa(originalRow.heightPt));
905
+ trPrParts.push(`<w:trHeight w:val="${hDxa}" w:hRule="atLeast"/>`);
906
+ }
907
+ const trPr =
908
+ trPrParts.length > 0 ? `<w:trPr>${trPrParts.join("")}</w:trPr>` : "";
909
+
910
+ return ` <w:tr>${trPr}\n${cellXmls.join("\n")}\n </w:tr>`;
911
+ })
912
+ .join("\n");
913
+
914
+ // 4단계: 테두리 및 최종 테이블 XML 조립
915
+ let tblBorders = "";
916
+ const strokeKindMap: Record<string, string> = {
917
+ solid: "single",
918
+ dash: "dash",
919
+ dot: "dot",
920
+ double: "double",
921
+ none: "none",
922
+ dotDash: "dotDash",
923
+ dotDotDash: "dotDotDash",
924
+ triple: "triple",
925
+ thinThickSmallGap: "thinThickSmallGap",
926
+ thickThinSmallGap: "thickThinSmallGap",
927
+ thinThickThinSmallGap: "thinThickThinSmallGap",
928
+ };
676
929
 
677
- // Table borders from defaultStroke
678
- let tblBorders = '';
679
930
  if (gp.defaultStroke) {
680
931
  const s = gp.defaultStroke;
681
- const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
682
- const val = strokeKindMap[s.kind] ?? 'single';
683
- const sz = Math.round(s.pt * 8);
684
- const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${s.color}"`;
685
- tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
932
+ const val = strokeKindMap[s.kind] ?? "single";
933
+
934
+ if (val === "none" || s.pt <= 0) {
935
+ tblBorders =
936
+ '<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>';
937
+ } else {
938
+ // DOCX sz는 1/8pt 단위. 최소 굵기 2(0.25pt) 보장
939
+ const sz = Math.max(2, Math.round(s.pt * 8));
940
+ // 색상 '#' 제거 및 빈 값일 경우 auto 처리
941
+ const clr = s.color ? s.color.replace("#", "") : "auto";
942
+ const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"`;
943
+ tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
944
+ }
686
945
  }
687
946
 
947
+ // ECMA-376 §17.4.58: tblJc — 표 가로 정렬 (start/center/end)
948
+ const tblAlignMap: Record<string, string> = {
949
+ left: "start",
950
+ center: "center",
951
+ right: "end",
952
+ justify: "start",
953
+ };
954
+ const tblJc = gp.align
955
+ ? `<w:jc w:val="${tblAlignMap[gp.align] ?? "start"}"/>`
956
+ : "";
957
+
688
958
  return ` <w:tbl>
689
- <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>
959
+ <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>
690
960
  <w:tblGrid>${gridCols}</w:tblGrid>
691
961
  ${rows}
692
962
  </w:tbl>`;
693
963
  }
694
-
695
964
  function encodeCellBorders(cp: CellProps): string {
696
- if (!cp.top && !cp.bot && !cp.left && !cp.right) return '';
697
- const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
965
+ if (!cp.top && !cp.bot && !cp.left && !cp.right) return "";
966
+ const strokeKindMap: Record<string, string> = {
967
+ solid: "single",
968
+ dash: "dash",
969
+ dot: "dot",
970
+ double: "double",
971
+ none: "none",
972
+ dotDash: "dotDash",
973
+ dotDotDash: "dotDotDash",
974
+ triple: "triple",
975
+ };
976
+
977
+ const encode = (
978
+ s?: { kind: string; pt: number; color: string },
979
+ tag?: string,
980
+ ) => {
981
+ if (!s || !tag) return "";
982
+ const val = strokeKindMap[s.kind] ?? "single";
983
+
984
+ // 선이 없거나 굵기가 0 이하인 경우 확실하게 제거 처리
985
+ if (val === "none" || s.pt <= 0) {
986
+ return `<w:${tag} w:val="none" w:sz="0" w:space="0" w:color="auto"/>`;
987
+ }
988
+
989
+ // 최소 굵기 sz=2 (0.25pt) 보장
990
+ const sz = Math.max(2, Math.round(s.pt * 8));
991
+ // 색상 '#' 제거 및 빈 값일 경우 auto 처리
992
+ const clr = s.color ? s.color.replace("#", "") : "auto";
698
993
 
699
- const encode = (s?: { kind: string; pt: number; color: string }, tag?: string) => {
700
- if (!s || !tag) return '';
701
- const val = strokeKindMap[s.kind] ?? 'single';
702
- return `<w:${tag} w:val="${val}" w:sz="${Math.round(s.pt * 8)}" w:space="0" w:color="${s.color}"/>`;
994
+ return `<w:${tag} w:val="${val}" w:sz="${sz}" w:space="0" w:color="${clr}"/>`;
703
995
  };
704
996
 
705
- return `<w:tcBorders>${encode(cp.top, 'top')}${encode(cp.bot, 'bottom')}${encode(cp.left, 'left')}${encode(cp.right, 'right')}</w:tcBorders>`;
997
+ return `<w:tcBorders>${encode(cp.top, "top")}${encode(cp.bot, "bottom")}${encode(cp.left, "left")}${encode(cp.right, "right")}</w:tcBorders>`;
706
998
  }
707
999
 
708
- function esc(s: string): string { return TextKit.escapeXml(s); }
1000
+ function esc(s: string): string {
1001
+ if (!s) return "";
1002
+ // 1. 내부 처리용 플레이스홀더(__EXT_0__ 또는 __EXT_0_W144_H108__ 등) 제거
1003
+ s = s.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
1004
+
1005
+ // 2. 글자 깨짐을 유발하는 쓰레기값 및 BOM 기호 명시적 제거 (이 부분 추가!)
1006
+ s = s.replace(/湰灧/g, "");
1007
+ s = s.replace(/\uFEFF/g, "");
1008
+
1009
+ // 3. DOCX(XML 1.0)에서 허용하지 않는 보이지 않는 제어문자 모두 제거
1010
+ s = s.replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]/g, "");
1011
+
1012
+ return TextKit.escapeXml(s);
1013
+ }
709
1014
 
710
1015
  registry.registerEncoder(new DocxEncoder());