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.
- package/ .npmignore +4 -1
- package/README.md +39 -2
- package/dist/index.d.mts +74 -16
- package/dist/index.d.ts +70 -16
- package/dist/index.js +4985 -698
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4981 -698
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -1
- package/playground/index.html +346 -0
- package/playground/main.ts +302 -0
- package/playground/vite.config.ts +16 -0
- package/src/contract/decoder.ts +1 -0
- package/src/contract/encoder.ts +6 -1
- package/src/core/BaseDecoder.ts +118 -0
- package/src/core/BaseEncoder.ts +146 -0
- package/src/decoders/docx/DocxDecoder.ts +867 -150
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +477 -88
- package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +600 -295
- package/src/encoders/html/HtmlEncoder.ts +203 -0
- package/src/encoders/hwp/HwpEncoder.ts +1647 -398
- package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +117 -30
- package/src/index.ts +1 -0
- package/src/model/builders.ts +8 -6
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +13 -5
- package/src/pipeline/Pipeline.ts +21 -4
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +52 -7
- package/src/toolkit/ArchiveKit.ts +56 -0
- package/src/toolkit/StyleMapper.ts +221 -0
- package/src/toolkit/UnitConverter.ts +138 -0
- package/src/toolkit/XmlKit.ts +0 -5
- package/test-styling.ts +210 -0
|
@@ -1,34 +1,60 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
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
|
-
//
|
|
27
|
-
|
|
40
|
+
// 모든 섹션의 콘텐츠를 합산
|
|
41
|
+
const allKids: ContentNode[] = sheets.flatMap((s) => s?.kids ?? []);
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
43
|
-
const
|
|
71
|
+
// kids 참조를 allKids로 통일 (후속 코드 호환)
|
|
72
|
+
const kids = allKids;
|
|
44
73
|
|
|
45
74
|
const entries: { name: string; data: Uint8Array }[] = [
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{ name:
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
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(
|
|
95
|
-
if (mime.includes(
|
|
96
|
-
if (mime.includes(
|
|
97
|
-
return
|
|
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 ===
|
|
103
|
-
else if (kid.tag ===
|
|
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)
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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 =
|
|
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)
|
|
172
|
-
|
|
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(
|
|
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 ??
|
|
228
|
-
<dc:creator>${esc(meta.author ??
|
|
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="
|
|
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="
|
|
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 ?
|
|
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 =
|
|
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(
|
|
318
|
-
|
|
319
|
-
|
|
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(
|
|
329
|
-
|
|
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)
|
|
333
|
-
|
|
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 ??
|
|
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(
|
|
348
|
-
|
|
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 ??
|
|
353
|
-
const headStyle = 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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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)
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 ===
|
|
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(
|
|
396
|
-
if (p.i) rPr.push(
|
|
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(
|
|
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)
|
|
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)
|
|
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 ===
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 ===
|
|
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
|
-
|
|
428
|
-
const
|
|
429
|
-
const
|
|
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
|
-
|
|
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,
|
|
446
|
-
|
|
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);
|
|
603
|
+
const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
|
|
451
604
|
const distR = Metric.ptToEmu(layout.distR ?? 9144);
|
|
452
|
-
const behindDoc =
|
|
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 ??
|
|
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 ??
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
649
|
+
left: "left",
|
|
650
|
+
center: "center",
|
|
651
|
+
right: "right",
|
|
489
652
|
};
|
|
490
653
|
const VERT_ALIGN_DOCX: Record<string, string> = {
|
|
491
|
-
top:
|
|
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:
|
|
495
|
-
tight:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
|
508
|
-
const lastRow
|
|
509
|
-
const firstCol
|
|
510
|
-
const lastCol
|
|
511
|
-
const noHBand
|
|
512
|
-
const noVBand
|
|
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
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
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
|
|
700
|
+
let c = 0;
|
|
559
701
|
for (const cell of grid.kids[ri].kids) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
726
|
+
c += cell.cs;
|
|
567
727
|
}
|
|
568
728
|
}
|
|
569
729
|
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
let
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const mergeType = vMergeMap.get(`${ri},${colIdx}`);
|
|
744
|
+
// 2단계: 컬럼 너비(dxa) 계산
|
|
745
|
+
const defaultColDxa = Math.round(availDxa / colCount);
|
|
746
|
+
let colWidthsDxa: number[] = [];
|
|
581
747
|
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
757
|
+
const remainingPt = Math.max(0, availPt - knownTotalPt);
|
|
758
|
+
const zeroFillPt = zeroCount > 0 ? remainingPt / zeroCount : 0;
|
|
592
759
|
|
|
593
|
-
|
|
594
|
-
if (
|
|
595
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
srcCellIdx++;
|
|
615
|
-
}
|
|
820
|
+
if (cellWidth > 1) {
|
|
821
|
+
tcPrParts.push(`<w:gridSpan w:val="${cellWidth}"/>`);
|
|
822
|
+
}
|
|
616
823
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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="
|
|
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> = {
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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());
|