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