hwpkit-dev 0.0.1
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 +11 -0
- package/README.md +223 -0
- package/dist/index.d.mts +313 -0
- package/dist/index.d.ts +317 -0
- package/dist/index.js +3546 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3479 -0
- package/dist/index.mjs.map +1 -0
- package/license.md +136 -0
- package/package.json +45 -0
- package/src/contract/decoder.ts +7 -0
- package/src/contract/encoder.ts +7 -0
- package/src/contract/result.ts +21 -0
- package/src/decoders/docx/DocxDecoder.ts +986 -0
- package/src/decoders/hwp/HwpScanner.ts +809 -0
- package/src/decoders/hwpx/HwpxDecoder.ts +759 -0
- package/src/decoders/md/MdDecoder.ts +180 -0
- package/src/encoders/docx/DocxEncoder.ts +710 -0
- package/src/encoders/hwp/HwpEncoder.ts +711 -0
- package/src/encoders/hwpx/HwpxEncoder.ts +770 -0
- package/src/encoders/md/MdEncoder.ts +108 -0
- package/src/index.ts +47 -0
- package/src/model/builders.ts +66 -0
- package/src/model/doc-props.ts +138 -0
- package/src/model/doc-tree.ts +90 -0
- package/src/pipeline/Pipeline.ts +71 -0
- package/src/pipeline/registry.ts +18 -0
- package/src/safety/ShieldedParser.ts +91 -0
- package/src/safety/StyleBridge.ts +106 -0
- package/src/toolkit/ArchiveKit.ts +150 -0
- package/src/toolkit/BinaryKit.ts +187 -0
- package/src/toolkit/TextKit.ts +57 -0
- package/src/toolkit/XmlKit.ts +91 -0
- package/src/walk/TreeWalker.ts +42 -0
- package/src/walk/tree-ops.ts +26 -0
- package/tsconfig.json +23 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import type { Encoder } from '../../contract/encoder';
|
|
2
|
+
import type { DocRoot, ParaNode, SpanNode, GridNode, ContentNode, ImgNode, SheetNode } from '../../model/doc-tree';
|
|
3
|
+
import type { Outcome } from '../../contract/result';
|
|
4
|
+
import type { PageDims, GridProps, CellProps } from '../../model/doc-props';
|
|
5
|
+
import { A4, normalizeDims } from '../../model/doc-props';
|
|
6
|
+
import { succeed, fail } from '../../contract/result';
|
|
7
|
+
import { Metric } from '../../safety/StyleBridge';
|
|
8
|
+
import { ArchiveKit } from '../../toolkit/ArchiveKit';
|
|
9
|
+
import { TextKit } from '../../toolkit/TextKit';
|
|
10
|
+
import { registry } from '../../pipeline/registry';
|
|
11
|
+
|
|
12
|
+
interface ImageEntry { rId: string; name: string; data: Uint8Array; ext: string }
|
|
13
|
+
|
|
14
|
+
export class DocxEncoder implements Encoder {
|
|
15
|
+
readonly format = 'docx';
|
|
16
|
+
|
|
17
|
+
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
18
|
+
try {
|
|
19
|
+
const sheet = doc.kids[0];
|
|
20
|
+
const dims = normalizeDims(sheet?.dims ?? A4);
|
|
21
|
+
const kids = sheet?.kids ?? [];
|
|
22
|
+
|
|
23
|
+
const images: ImageEntry[] = [];
|
|
24
|
+
const ctx: EncCtx = { images, nextId: 10, nextImgNum: 1, warns: [], imgMap: new WeakMap() };
|
|
25
|
+
|
|
26
|
+
// Collect images from content
|
|
27
|
+
collectImages(kids, ctx);
|
|
28
|
+
|
|
29
|
+
// Header / footer
|
|
30
|
+
let headerParas = sheet?.header;
|
|
31
|
+
let footerParas = sheet?.footer;
|
|
32
|
+
const hasHeader = headerParas && headerParas.length > 0;
|
|
33
|
+
const hasFooter = footerParas && footerParas.length > 0;
|
|
34
|
+
|
|
35
|
+
// Collect images from header/footer
|
|
36
|
+
if (hasHeader) collectImagesFromParas(headerParas!, ctx);
|
|
37
|
+
if (hasFooter) collectImagesFromParas(footerParas!, ctx);
|
|
38
|
+
|
|
39
|
+
const headerRId = hasHeader ? `rId${ctx.nextId++}` : '';
|
|
40
|
+
const footerRId = hasFooter ? `rId${ctx.nextId++}` : '';
|
|
41
|
+
|
|
42
|
+
// Numbering: collect list info
|
|
43
|
+
const numInfo = collectNumbering(kids);
|
|
44
|
+
|
|
45
|
+
const entries: { name: string; data: Uint8Array }[] = [
|
|
46
|
+
{ name: '[Content_Types].xml', data: TextKit.encode(contentTypes(images, hasHeader, hasFooter)) },
|
|
47
|
+
{ name: '_rels/.rels', data: TextKit.encode(pkgRels()) },
|
|
48
|
+
{ name: 'word/document.xml', data: TextKit.encode(documentXml(kids, dims, ctx, headerRId, footerRId)) },
|
|
49
|
+
{ name: 'word/styles.xml', data: TextKit.encode(stylesXml()) },
|
|
50
|
+
{ name: 'word/settings.xml', data: TextKit.encode(settingsXml()) },
|
|
51
|
+
{ name: 'word/_rels/document.xml.rels', data: TextKit.encode(docRels(images, headerRId, footerRId, numInfo.hasLists)) },
|
|
52
|
+
{ name: 'docProps/app.xml', data: TextKit.encode(appXml()) },
|
|
53
|
+
{ name: 'docProps/core.xml', data: TextKit.encode(coreXml(doc.meta)) },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Add numbering.xml if needed
|
|
57
|
+
if (numInfo.hasLists) {
|
|
58
|
+
entries.push({ name: 'word/numbering.xml', data: TextKit.encode(numberingXml(numInfo)) });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Add header/footer files
|
|
62
|
+
if (hasHeader) {
|
|
63
|
+
entries.push({ name: 'word/header1.xml', data: TextKit.encode(headerFooterXml('hdr', headerParas!, ctx)) });
|
|
64
|
+
}
|
|
65
|
+
if (hasFooter) {
|
|
66
|
+
entries.push({ name: 'word/footer1.xml', data: TextKit.encode(headerFooterXml('ftr', footerParas!, ctx)) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Add image media files
|
|
70
|
+
for (const img of images) {
|
|
71
|
+
entries.push({ name: `word/media/${img.name}`, data: img.data });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return succeed(await ArchiveKit.zip(entries));
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
return fail(`DOCX encode error: ${e?.message ?? String(e)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Context ────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
interface EncCtx {
|
|
84
|
+
images: ImageEntry[];
|
|
85
|
+
nextId: number;
|
|
86
|
+
nextImgNum: number;
|
|
87
|
+
warns: string[];
|
|
88
|
+
imgMap: WeakMap<ImgNode, string>; // ImgNode → rId (no mutation)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Image collection ───────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function mimeToExt(mime: string): string {
|
|
94
|
+
if (mime.includes('jpeg')) return 'jpeg';
|
|
95
|
+
if (mime.includes('gif')) return 'gif';
|
|
96
|
+
if (mime.includes('bmp')) return 'bmp';
|
|
97
|
+
return 'png';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectImages(kids: ContentNode[], ctx: EncCtx): void {
|
|
101
|
+
for (const kid of kids) {
|
|
102
|
+
if (kid.tag === 'para') collectImagesFromPara(kid, ctx);
|
|
103
|
+
else if (kid.tag === 'grid') {
|
|
104
|
+
for (const row of kid.kids)
|
|
105
|
+
for (const cell of row.kids)
|
|
106
|
+
for (const p of cell.kids) collectImagesFromPara(p, ctx);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectImagesFromParas(paras: ParaNode[], ctx: EncCtx): void {
|
|
112
|
+
for (const p of paras) collectImagesFromPara(p, ctx);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collectImagesFromPara(para: ParaNode, ctx: EncCtx): void {
|
|
116
|
+
for (const kid of para.kids) {
|
|
117
|
+
if (kid.tag === 'img') registerImage(kid, ctx);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function registerImage(img: ImgNode, ctx: EncCtx): void {
|
|
122
|
+
if (ctx.imgMap.has(img)) return;
|
|
123
|
+
const ext = mimeToExt(img.mime);
|
|
124
|
+
const name = `image${ctx.nextImgNum++}.${ext}`;
|
|
125
|
+
const rId = `rId${ctx.nextId++}`;
|
|
126
|
+
const data = TextKit.base64Decode(img.b64);
|
|
127
|
+
ctx.images.push({ rId, name, data, ext });
|
|
128
|
+
ctx.imgMap.set(img, rId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── List/numbering collection ──────────────────────────────
|
|
132
|
+
|
|
133
|
+
interface NumInfo {
|
|
134
|
+
hasLists: boolean;
|
|
135
|
+
hasBullet: boolean;
|
|
136
|
+
hasNumbered: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectNumbering(kids: ContentNode[]): NumInfo {
|
|
140
|
+
let hasBullet = false;
|
|
141
|
+
let hasNumbered = false;
|
|
142
|
+
for (const kid of kids) {
|
|
143
|
+
if (kid.tag === 'para') {
|
|
144
|
+
if (kid.props.listOrd === true) hasNumbered = true;
|
|
145
|
+
else if (kid.props.listOrd === false) hasBullet = true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { hasLists: hasBullet || hasNumbered, hasBullet, hasNumbered };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── OOXML boilerplate ──────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function contentTypes(images: ImageEntry[], hasHeader?: boolean, hasFooter?: boolean): string {
|
|
154
|
+
const imgDefaults = new Set<string>();
|
|
155
|
+
for (const img of images) imgDefaults.add(img.ext);
|
|
156
|
+
|
|
157
|
+
let defaults = `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
158
|
+
<Default Extension="xml" ContentType="application/xml"/>`;
|
|
159
|
+
|
|
160
|
+
for (const ext of imgDefaults) {
|
|
161
|
+
const ct = ext === 'png' ? 'image/png' : ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : 'image/bmp';
|
|
162
|
+
defaults += `\n <Default Extension="${ext}" ContentType="${ct}"/>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let overrides = `<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
166
|
+
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
|
167
|
+
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
|
|
168
|
+
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
|
169
|
+
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>`;
|
|
170
|
+
|
|
171
|
+
if (hasHeader) overrides += `\n <Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>`;
|
|
172
|
+
if (hasFooter) overrides += `\n <Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>`;
|
|
173
|
+
|
|
174
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
175
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
176
|
+
${defaults}
|
|
177
|
+
${overrides}
|
|
178
|
+
</Types>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pkgRels(): string {
|
|
182
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
183
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
184
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
185
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
|
186
|
+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
|
187
|
+
</Relationships>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function docRels(images: ImageEntry[], headerRId?: string, footerRId?: string, hasLists?: boolean): string {
|
|
191
|
+
let rels = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
192
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>`;
|
|
193
|
+
|
|
194
|
+
// Numbering relationship — only when lists exist
|
|
195
|
+
if (hasLists) {
|
|
196
|
+
rels += `\n <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const img of images) {
|
|
200
|
+
rels += `\n <Relationship Id="${img.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${img.name}"/>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (headerRId) {
|
|
204
|
+
rels += `\n <Relationship Id="${headerRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>`;
|
|
205
|
+
}
|
|
206
|
+
if (footerRId) {
|
|
207
|
+
rels += `\n <Relationship Id="${footerRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
211
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
212
|
+
${rels}
|
|
213
|
+
</Relationships>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function appXml(): string {
|
|
217
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
218
|
+
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
|
|
219
|
+
<Application>hwpkit</Application>
|
|
220
|
+
</Properties>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function coreXml(meta: any): string {
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
226
|
+
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
227
|
+
<dc:title>${esc(meta.title ?? '')}</dc:title>
|
|
228
|
+
<dc:creator>${esc(meta.author ?? '')}</dc:creator>
|
|
229
|
+
<dcterms:created xsi:type="dcterms:W3CDTF">${meta.created ?? now}</dcterms:created>
|
|
230
|
+
<dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>
|
|
231
|
+
</cp:coreProperties>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function stylesXml(): string {
|
|
235
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
236
|
+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
237
|
+
<w:docDefaults>
|
|
238
|
+
<w:rPrDefault><w:rPr>
|
|
239
|
+
<w:rFonts w:ascii="맑은 고딕" w:eastAsia="맑은 고딕" w:hAnsi="맑은 고딕"/>
|
|
240
|
+
<w:sz w:val="20"/>
|
|
241
|
+
<w:szCs w:val="20"/>
|
|
242
|
+
</w:rPr></w:rPrDefault>
|
|
243
|
+
<w:pPrDefault><w:pPr>
|
|
244
|
+
<w:spacing w:after="0" w:line="384" w:lineRule="auto"/>
|
|
245
|
+
<w:jc w:val="both"/>
|
|
246
|
+
</w:pPr></w:pPrDefault>
|
|
247
|
+
</w:docDefaults>
|
|
248
|
+
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
|
249
|
+
<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
|
+
<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
|
+
<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>
|
|
252
|
+
<w:style w:type="paragraph" w:styleId="Header"><w:name w:val="header"/><w:basedOn w:val="Normal"/></w:style>
|
|
253
|
+
<w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/></w:style>
|
|
254
|
+
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/></w:pPr></w:style>
|
|
255
|
+
<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders></w:tblPr></w:style>
|
|
256
|
+
</w:styles>`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function settingsXml(): string {
|
|
260
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
261
|
+
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
262
|
+
<w:zoom w:percent="100"/>
|
|
263
|
+
<w:bordersDoNotSurroundHeader/>
|
|
264
|
+
<w:bordersDoNotSurroundFooter/>
|
|
265
|
+
<w:defaultTabStop w:val="800"/>
|
|
266
|
+
<w:compat>
|
|
267
|
+
<w:spaceForUL/>
|
|
268
|
+
<w:balanceSingleByteDoubleByteWidth/>
|
|
269
|
+
<w:doNotLeaveBackslashAlone/>
|
|
270
|
+
<w:ulTrailSpace/>
|
|
271
|
+
<w:doNotExpandShiftReturn/>
|
|
272
|
+
<w:adjustLineHeightInTable/>
|
|
273
|
+
<w:useFELayout/>
|
|
274
|
+
</w:compat>
|
|
275
|
+
</w:settings>`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── numbering.xml ──────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function numberingXml(info: NumInfo): string {
|
|
281
|
+
let abstractNums = '';
|
|
282
|
+
let nums = '';
|
|
283
|
+
|
|
284
|
+
// Bullet list: abstractNumId=0, numId=1
|
|
285
|
+
if (info.hasBullet) {
|
|
286
|
+
abstractNums += `<w:abstractNum w:abstractNumId="0">`;
|
|
287
|
+
for (let lvl = 0; lvl < 9; lvl++) {
|
|
288
|
+
const marker = lvl === 0 ? '●' : lvl === 1 ? '○' : '■';
|
|
289
|
+
const indent = (lvl + 1) * 720;
|
|
290
|
+
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
|
+
}
|
|
292
|
+
abstractNums += `</w:abstractNum>`;
|
|
293
|
+
nums += `<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Numbered list: abstractNumId=1, numId=2
|
|
297
|
+
if (info.hasNumbered) {
|
|
298
|
+
abstractNums += `<w:abstractNum w:abstractNumId="1">`;
|
|
299
|
+
for (let lvl = 0; lvl < 9; lvl++) {
|
|
300
|
+
const fmt = lvl % 3 === 0 ? 'decimal' : lvl % 3 === 1 ? 'lowerLetter' : 'lowerRoman';
|
|
301
|
+
const indent = (lvl + 1) * 720;
|
|
302
|
+
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
|
+
}
|
|
304
|
+
abstractNums += `</w:abstractNum>`;
|
|
305
|
+
nums += `<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
309
|
+
<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
310
|
+
${abstractNums}
|
|
311
|
+
${nums}
|
|
312
|
+
</w:numbering>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── header / footer xml ────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
function headerFooterXml(type: 'hdr' | 'ftr', paras: ParaNode[], ctx: EncCtx): string {
|
|
318
|
+
const tag = type === 'hdr' ? 'w:hdr' : 'w:ftr';
|
|
319
|
+
const body = paras.map(p => encodeParaInner(p, ctx)).join('\n');
|
|
320
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
321
|
+
<${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
|
+
${body}
|
|
323
|
+
</${tag}>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── document.xml ───────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function documentXml(kids: ContentNode[], dims: PageDims, ctx: EncCtx, headerRId?: string, footerRId?: string): string {
|
|
329
|
+
const body = kids.map(k => encodeContent(k, ctx, dims)).join('\n');
|
|
330
|
+
|
|
331
|
+
let sectRefs = '';
|
|
332
|
+
if (headerRId) sectRefs += `\n <w:headerReference w:type="default" r:id="${headerRId}"/>`;
|
|
333
|
+
if (footerRId) sectRefs += `\n <w:footerReference w:type="default" r:id="${footerRId}"/>`;
|
|
334
|
+
|
|
335
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
336
|
+
<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
|
+
<w:body>
|
|
338
|
+
${body}
|
|
339
|
+
<w:sectPr>${sectRefs}
|
|
340
|
+
<w:pgSz w:w="${Metric.ptToDxa(dims.wPt)}" w:h="${Metric.ptToDxa(dims.hPt)}" w:orient="${dims.orient ?? 'portrait'}"/>
|
|
341
|
+
<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
|
+
</w:sectPr>
|
|
343
|
+
</w:body>
|
|
344
|
+
</w:document>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function encodeContent(node: ContentNode, ctx: EncCtx, dims?: PageDims): string {
|
|
348
|
+
return node.tag === 'grid' ? encodeGrid(node, ctx, dims) : encodeParaInner(node, ctx);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function encodeParaInner(para: ParaNode, ctx: EncCtx): string {
|
|
352
|
+
const align = para.props.align ?? 'left';
|
|
353
|
+
const headStyle = para.props.heading ? `<w:pStyle w:val="Heading${para.props.heading}"/>` : '';
|
|
354
|
+
|
|
355
|
+
// List numbering
|
|
356
|
+
let numPr = '';
|
|
357
|
+
if (para.props.listOrd !== undefined) {
|
|
358
|
+
const numId = para.props.listOrd ? 2 : 1;
|
|
359
|
+
const ilvl = para.props.listLv ?? 0;
|
|
360
|
+
numPr = `<w:pStyle w:val="ListParagraph"/><w:numPr><w:ilvl w:val="${ilvl}"/><w:numId w:val="${numId}"/></w:numPr>`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Spacing (before / after / line height)
|
|
364
|
+
let spacingXml = '';
|
|
365
|
+
const { spaceBefore, spaceAfter, lineHeight } = para.props;
|
|
366
|
+
if (spaceBefore !== undefined || spaceAfter !== undefined || lineHeight !== undefined) {
|
|
367
|
+
const parts: string[] = [];
|
|
368
|
+
if (spaceBefore !== undefined) parts.push(`w:before="${Metric.ptToDxa(spaceBefore)}"`);
|
|
369
|
+
if (spaceAfter !== undefined) parts.push(`w:after="${Metric.ptToDxa(spaceAfter)}"`);
|
|
370
|
+
if (lineHeight !== undefined) parts.push(`w:line="${Math.round(lineHeight * 240)}" w:lineRule="auto"`);
|
|
371
|
+
spacingXml = `<w:spacing ${parts.join(' ')}/>`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Indentation
|
|
375
|
+
let indentXml = '';
|
|
376
|
+
if (para.props.indentPt !== undefined) {
|
|
377
|
+
indentXml = `<w:ind w:left="${Metric.ptToDxa(para.props.indentPt)}"/>`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const runs = para.kids.map(k => {
|
|
381
|
+
if (k.tag === 'span') return encodeRun(k, ctx);
|
|
382
|
+
if (k.tag === 'img') return encodeImage(k, ctx);
|
|
383
|
+
return '';
|
|
384
|
+
}).join('');
|
|
385
|
+
|
|
386
|
+
return ` <w:p>
|
|
387
|
+
<w:pPr>${headStyle}${numPr}${spacingXml}${indentXml}<w:jc w:val="${align === 'justify' ? 'both' : align}"/></w:pPr>
|
|
388
|
+
${runs}
|
|
389
|
+
</w:p>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function encodeRun(span: SpanNode, _ctx: EncCtx): string {
|
|
393
|
+
const p = span.props;
|
|
394
|
+
const rPr: string[] = [];
|
|
395
|
+
if (p.b) rPr.push('<w:b/>');
|
|
396
|
+
if (p.i) rPr.push('<w:i/>');
|
|
397
|
+
if (p.u) rPr.push('<w:u w:val="single"/>');
|
|
398
|
+
if (p.s) rPr.push('<w:strike/>');
|
|
399
|
+
if (p.sup) rPr.push('<w:vertAlign w:val="superscript"/>');
|
|
400
|
+
if (p.sub) rPr.push('<w:vertAlign w:val="subscript"/>');
|
|
401
|
+
if (p.pt) rPr.push(`<w:sz w:val="${Metric.ptToHalfPt(p.pt)}"/><w:szCs w:val="${Metric.ptToHalfPt(p.pt)}"/>`);
|
|
402
|
+
if (p.color) rPr.push(`<w:color w:val="${p.color}"/>`);
|
|
403
|
+
if (p.font) rPr.push(`<w:rFonts w:ascii="${esc(p.font)}" w:hAnsi="${esc(p.font)}" w:eastAsia="${esc(p.font)}"/>`);
|
|
404
|
+
if (p.bg) rPr.push(`<w:shd w:val="clear" w:color="auto" w:fill="${p.bg}"/>`);
|
|
405
|
+
|
|
406
|
+
// Process kids — text and pagenum
|
|
407
|
+
const parts: string[] = [];
|
|
408
|
+
for (const kid of span.kids) {
|
|
409
|
+
if (kid.tag === 'txt') {
|
|
410
|
+
parts.push(`<w:r><w:rPr>${rPr.join('')}</w:rPr><w:t xml:space="preserve">${esc(kid.content)}</w:t></w:r>`);
|
|
411
|
+
} else if (kid.tag === 'pagenum') {
|
|
412
|
+
parts.push(`<w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:instrText> PAGE </w:instrText></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:t>1</w:t></w:r><w:r><w:rPr>${rPr.join('')}</w:rPr><w:fldChar w:fldCharType="end"/></w:r>`);
|
|
413
|
+
} else if (kid.tag === 'br') {
|
|
414
|
+
parts.push(`<w:r><w:br/></w:r>`);
|
|
415
|
+
} else if (kid.tag === 'pb') {
|
|
416
|
+
parts.push(`<w:r><w:br w:type="page"/></w:r>`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return parts.join('');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function encodeImage(img: ImgNode, ctx: EncCtx): string {
|
|
424
|
+
const rId = ctx.imgMap.get(img);
|
|
425
|
+
if (!rId) return '';
|
|
426
|
+
|
|
427
|
+
const cx = Metric.ptToEmu(img.w);
|
|
428
|
+
const cy = Metric.ptToEmu(img.h);
|
|
429
|
+
const alt = esc(img.alt ?? '');
|
|
430
|
+
const docPrId = ctx.nextId++;
|
|
431
|
+
|
|
432
|
+
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
|
+
|
|
434
|
+
const layout = img.layout;
|
|
435
|
+
const isInline = !layout || layout.wrap === 'inline';
|
|
436
|
+
|
|
437
|
+
if (isInline) {
|
|
438
|
+
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
|
+
}
|
|
440
|
+
|
|
441
|
+
return `<w:r><w:drawing>${encodeAnchor(img, cx, cy, alt, docPrId, graphic, layout)}</w:drawing></w:r>`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function encodeAnchor(
|
|
445
|
+
_img: ImgNode, cx: number, cy: number, alt: string, docPrId: number,
|
|
446
|
+
graphic: string, layout: NonNullable<ImgNode['layout']>,
|
|
447
|
+
): string {
|
|
448
|
+
const distT = Metric.ptToEmu(layout.distT ?? 0);
|
|
449
|
+
const distB = Metric.ptToEmu(layout.distB ?? 0);
|
|
450
|
+
const distL = Metric.ptToEmu(layout.distL ?? 9144); // DOCX 기본 0.18pt
|
|
451
|
+
const distR = Metric.ptToEmu(layout.distR ?? 9144);
|
|
452
|
+
const behindDoc = (layout.behindDoc || layout.wrap === 'behind') ? '1' : '0';
|
|
453
|
+
const relH = layout.zOrder ?? 251658240;
|
|
454
|
+
|
|
455
|
+
// 가로 위치
|
|
456
|
+
const horzRelFrom = HORZ_RELTO_DOCX[layout.horzRelTo ?? 'column'] ?? 'column';
|
|
457
|
+
let posH: string;
|
|
458
|
+
if (layout.xPt != null) {
|
|
459
|
+
posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.xPt)}</wp:posOffset></wp:positionH>`;
|
|
460
|
+
} else {
|
|
461
|
+
const ha = HORZ_ALIGN_DOCX[layout.horzAlign ?? 'left'] ?? 'left';
|
|
462
|
+
posH = `<wp:positionH relativeFrom="${horzRelFrom}"><wp:align>${ha}</wp:align></wp:positionH>`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 세로 위치
|
|
466
|
+
const vertRelFrom = VERT_RELTO_DOCX[layout.vertRelTo ?? 'para'] ?? 'paragraph';
|
|
467
|
+
let posV: string;
|
|
468
|
+
if (layout.yPt != null) {
|
|
469
|
+
posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:posOffset>${Metric.ptToEmu(layout.yPt)}</wp:posOffset></wp:positionV>`;
|
|
470
|
+
} else {
|
|
471
|
+
const va = VERT_ALIGN_DOCX[layout.vertAlign ?? 'top'] ?? 'top';
|
|
472
|
+
posV = `<wp:positionV relativeFrom="${vertRelFrom}"><wp:align>${va}</wp:align></wp:positionV>`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 텍스트 감싸기
|
|
476
|
+
const wrapXml = WRAP_DOCX[layout.wrap] ?? '<wp:wrapSquare wrapText="bothSides"/>';
|
|
477
|
+
|
|
478
|
+
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
|
+
}
|
|
480
|
+
|
|
481
|
+
const HORZ_RELTO_DOCX: Record<string, string> = {
|
|
482
|
+
margin: 'margin', column: 'column', page: 'page', para: 'paragraph',
|
|
483
|
+
};
|
|
484
|
+
const VERT_RELTO_DOCX: Record<string, string> = {
|
|
485
|
+
margin: 'margin', line: 'line', page: 'page', para: 'paragraph',
|
|
486
|
+
};
|
|
487
|
+
const HORZ_ALIGN_DOCX: Record<string, string> = {
|
|
488
|
+
left: 'left', center: 'center', right: 'right',
|
|
489
|
+
};
|
|
490
|
+
const VERT_ALIGN_DOCX: Record<string, string> = {
|
|
491
|
+
top: 'top', center: 'center', bottom: 'bottom',
|
|
492
|
+
};
|
|
493
|
+
const WRAP_DOCX: Record<string, string> = {
|
|
494
|
+
square: '<wp:wrapSquare wrapText="bothSides"/>',
|
|
495
|
+
tight: '<wp:wrapTight><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapTight>',
|
|
496
|
+
through: '<wp:wrapThrough wrapText="bothSides"><wp:wrapPolygon edited="0"><wp:start x="0" y="0"/><wp:lineTo x="0" y="21600"/><wp:lineTo x="21600" y="21600"/><wp:lineTo x="21600" y="0"/><wp:lineTo x="0" y="0"/></wp:wrapPolygon></wp:wrapThrough>',
|
|
497
|
+
none: '<wp:wrapNone/>',
|
|
498
|
+
behind: '<wp:wrapNone/>',
|
|
499
|
+
front: '<wp:wrapNone/>',
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
function encodeGrid(grid: GridNode, ctx: EncCtx, dims?: PageDims): string {
|
|
503
|
+
const gp = grid.props;
|
|
504
|
+
const look = gp.look;
|
|
505
|
+
|
|
506
|
+
// tblLook attributes
|
|
507
|
+
const firstRow = look?.firstRow ? '1' : '0';
|
|
508
|
+
const lastRow = look?.lastRow ? '1' : '0';
|
|
509
|
+
const firstCol = look?.firstCol ? '1' : '0';
|
|
510
|
+
const lastCol = look?.lastCol ? '1' : '0';
|
|
511
|
+
const noHBand = look?.bandedRows ? '0' : '1';
|
|
512
|
+
const noVBand = look?.bandedCols ? '0' : '1';
|
|
513
|
+
|
|
514
|
+
// Determine actual grid column count from colWidths or by scanning all rows
|
|
515
|
+
const d = dims ?? A4;
|
|
516
|
+
const availDxa = Metric.ptToDxa(d.wPt - d.ml - d.mr);
|
|
517
|
+
|
|
518
|
+
// Compute true column count: max total colSpan across all rows
|
|
519
|
+
let colCount = 0;
|
|
520
|
+
for (const row of grid.kids) {
|
|
521
|
+
let rowCols = 0;
|
|
522
|
+
for (const cell of row.kids) rowCols += cell.cs;
|
|
523
|
+
if (rowCols > colCount) colCount = rowCols;
|
|
524
|
+
}
|
|
525
|
+
if (colCount === 0) colCount = grid.kids[0]?.kids.length ?? 1;
|
|
526
|
+
|
|
527
|
+
const defaultColDxa = Math.round(availDxa / colCount);
|
|
528
|
+
|
|
529
|
+
// Use actual column widths if available from source format
|
|
530
|
+
const colWidthsDxa: number[] = [];
|
|
531
|
+
if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
|
|
532
|
+
// Fill zero-width columns by distributing remaining space
|
|
533
|
+
const srcPt = [...grid.props.colWidths];
|
|
534
|
+
const knownTotal = srcPt.filter(w => w > 0).reduce((s, w) => s + w, 0);
|
|
535
|
+
const zeroCount = srcPt.filter(w => w <= 0).length;
|
|
536
|
+
const remaining = Math.max(0, Metric.dxaToPt(availDxa) - knownTotal);
|
|
537
|
+
const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
|
|
538
|
+
for (let i = 0; i < srcPt.length; i++) {
|
|
539
|
+
if (srcPt[i] <= 0) srcPt[i] = zeroFill > 0 ? zeroFill : Metric.dxaToPt(defaultColDxa);
|
|
540
|
+
}
|
|
541
|
+
const srcWidths = srcPt.map(w => Metric.ptToDxa(w));
|
|
542
|
+
const srcTotal = srcWidths.reduce((s, w) => s + w, 0);
|
|
543
|
+
// Normalize to fit available page width if source widths exceed it
|
|
544
|
+
const scale = srcTotal > availDxa ? availDxa / srcTotal : 1;
|
|
545
|
+
for (const w of srcWidths) colWidthsDxa.push(Math.round(w * scale));
|
|
546
|
+
} else {
|
|
547
|
+
for (let c = 0; c < colCount; c++) colWidthsDxa.push(defaultColDxa);
|
|
548
|
+
}
|
|
549
|
+
const totalDxa = colWidthsDxa.reduce((s, w) => s + w, 0);
|
|
550
|
+
|
|
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
|
+
for (let ri = 0; ri < grid.kids.length; ri++) {
|
|
558
|
+
let colIdx = 0;
|
|
559
|
+
for (const cell of grid.kids[ri].kids) {
|
|
560
|
+
if (cell.rs > 1) {
|
|
561
|
+
vMergeMap.set(`${ri},${colIdx}`, 'restart');
|
|
562
|
+
for (let sr = 1; sr < cell.rs; sr++) {
|
|
563
|
+
vMergeMap.set(`${ri + sr},${colIdx}`, 'continue');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
colIdx += cell.cs;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const rows = grid.kids.map((row, ri) => {
|
|
571
|
+
let colIdx = 0;
|
|
572
|
+
|
|
573
|
+
// Build actual cells including continuation cells for vMerge
|
|
574
|
+
const cellXmls: string[] = [];
|
|
575
|
+
let srcCellIdx = 0;
|
|
576
|
+
|
|
577
|
+
// Walk through grid columns, emitting either real cells or vMerge continue cells
|
|
578
|
+
while (srcCellIdx < row.kids.length) {
|
|
579
|
+
const cell = row.kids[srcCellIdx];
|
|
580
|
+
const mergeType = vMergeMap.get(`${ri},${colIdx}`);
|
|
581
|
+
|
|
582
|
+
const cp = cell.props;
|
|
583
|
+
const tcPrParts: string[] = [];
|
|
584
|
+
|
|
585
|
+
// Cell width in DXA (sum of spanned columns)
|
|
586
|
+
let cellW = 0;
|
|
587
|
+
for (let sc = colIdx; sc < colIdx + cell.cs && sc < colWidthsDxa.length; sc++) cellW += colWidthsDxa[sc];
|
|
588
|
+
if (cellW === 0) cellW = defaultColDxa * cell.cs;
|
|
589
|
+
tcPrParts.push(`<w:tcW w:w="${Math.round(cellW)}" w:type="dxa"/>`);
|
|
590
|
+
|
|
591
|
+
if (cell.cs > 1) tcPrParts.push(`<w:gridSpan w:val="${cell.cs}"/>`);
|
|
592
|
+
|
|
593
|
+
// vMerge
|
|
594
|
+
if (cell.rs > 1) {
|
|
595
|
+
tcPrParts.push(`<w:vMerge w:val="restart"/>`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Cell borders
|
|
599
|
+
const borders = encodeCellBorders(cp);
|
|
600
|
+
if (borders) tcPrParts.push(borders);
|
|
601
|
+
|
|
602
|
+
// Cell background
|
|
603
|
+
if (cp.bg) tcPrParts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${cp.bg}"/>`);
|
|
604
|
+
|
|
605
|
+
// Vertical alignment
|
|
606
|
+
if (cp.va) {
|
|
607
|
+
const vaMap: Record<string, string> = { top: 'top', mid: 'center', bot: 'bottom' };
|
|
608
|
+
tcPrParts.push(`<w:vAlign w:val="${vaMap[cp.va] ?? 'top'}"/>`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const tcPr = `<w:tcPr>${tcPrParts.join('')}</w:tcPr>`;
|
|
612
|
+
cellXmls.push(` <w:tc>${tcPr}${cell.kids.map(p => encodeParaInner(p, ctx)).join('')}</w:tc>`);
|
|
613
|
+
colIdx += cell.cs;
|
|
614
|
+
srcCellIdx++;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Now emit vMerge continue cells for rows that are spanned into
|
|
618
|
+
// We need to check if any cells from rows above span into this row
|
|
619
|
+
// Re-walk grid columns looking for continue cells not covered by this row's cells
|
|
620
|
+
const finalCells: string[] = [];
|
|
621
|
+
let finalColIdx = 0;
|
|
622
|
+
let cellIter = 0;
|
|
623
|
+
|
|
624
|
+
// Use the computed colCount (max across all rows)
|
|
625
|
+
const totalGridCols = colCount;
|
|
626
|
+
|
|
627
|
+
// Re-check: we need to interleave vMerge continue cells
|
|
628
|
+
// The current row may have fewer cells because the model already skipped continuation cells
|
|
629
|
+
// So we need to insert continuation cells where vMergeMap says 'continue' for this row
|
|
630
|
+
finalColIdx = 0;
|
|
631
|
+
cellIter = 0;
|
|
632
|
+
for (let gc = 0; gc < totalGridCols; ) {
|
|
633
|
+
const mergeType = vMergeMap.get(`${ri},${gc}`);
|
|
634
|
+
if (mergeType === 'continue') {
|
|
635
|
+
// Find the original cell's cs from the restart row
|
|
636
|
+
let origCs = 1;
|
|
637
|
+
for (let sr = ri - 1; sr >= 0; sr--) {
|
|
638
|
+
const mt = vMergeMap.get(`${sr},${gc}`);
|
|
639
|
+
if (mt === 'restart') {
|
|
640
|
+
// Find the cell at this column in that row
|
|
641
|
+
let col = 0;
|
|
642
|
+
for (const c of grid.kids[sr].kids) {
|
|
643
|
+
if (col === gc) { origCs = c.cs; break; }
|
|
644
|
+
col += c.cs;
|
|
645
|
+
}
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
let cw = 0;
|
|
650
|
+
for (let sc = gc; sc < gc + origCs && sc < colWidthsDxa.length; sc++) cw += colWidthsDxa[sc];
|
|
651
|
+
if (cw === 0) cw = defaultColDxa * origCs;
|
|
652
|
+
let contParts = `<w:tcW w:w="${Math.round(cw)}" w:type="dxa"/>`;
|
|
653
|
+
if (origCs > 1) contParts += `<w:gridSpan w:val="${origCs}"/>`;
|
|
654
|
+
contParts += `<w:vMerge/>`;
|
|
655
|
+
finalCells.push(` <w:tc><w:tcPr>${contParts}</w:tcPr><w:p><w:pPr/></w:p></w:tc>`);
|
|
656
|
+
gc += origCs;
|
|
657
|
+
} else {
|
|
658
|
+
if (cellIter < cellXmls.length) {
|
|
659
|
+
finalCells.push(cellXmls[cellIter]);
|
|
660
|
+
gc += row.kids[cellIter]?.cs ?? 1;
|
|
661
|
+
cellIter++;
|
|
662
|
+
} else {
|
|
663
|
+
gc++;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
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
|
+
|
|
674
|
+
return ` <w:tr>${trPr}\n${finalCells.join('\n')}\n </w:tr>`;
|
|
675
|
+
}).join('\n');
|
|
676
|
+
|
|
677
|
+
// Table borders from defaultStroke
|
|
678
|
+
let tblBorders = '';
|
|
679
|
+
if (gp.defaultStroke) {
|
|
680
|
+
const s = gp.defaultStroke;
|
|
681
|
+
const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
|
|
682
|
+
const val = strokeKindMap[s.kind] ?? 'single';
|
|
683
|
+
const sz = Math.round(s.pt * 8);
|
|
684
|
+
const bdr = `w:val="${val}" w:sz="${sz}" w:space="0" w:color="${s.color}"`;
|
|
685
|
+
tblBorders = `<w:tblBorders><w:top ${bdr}/><w:left ${bdr}/><w:bottom ${bdr}/><w:right ${bdr}/><w:insideH ${bdr}/><w:insideV ${bdr}/></w:tblBorders>`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return ` <w:tbl>
|
|
689
|
+
<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="${Math.round(totalDxa)}" w:type="dxa"/><w:tblLayout w:type="fixed"/><w:tblLook w:val="04A0" w:firstRow="${firstRow}" w:lastRow="${lastRow}" w:firstColumn="${firstCol}" w:lastColumn="${lastCol}" w:noHBand="${noHBand}" w:noVBand="${noVBand}"/>${tblBorders}<w:tblCellMar><w:top w:w="28" w:type="dxa"/><w:left w:w="102" w:type="dxa"/><w:bottom w:w="28" w:type="dxa"/><w:right w:w="102" w:type="dxa"/></w:tblCellMar></w:tblPr>
|
|
690
|
+
<w:tblGrid>${gridCols}</w:tblGrid>
|
|
691
|
+
${rows}
|
|
692
|
+
</w:tbl>`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function encodeCellBorders(cp: CellProps): string {
|
|
696
|
+
if (!cp.top && !cp.bot && !cp.left && !cp.right) return '';
|
|
697
|
+
const strokeKindMap: Record<string, string> = { solid: 'single', dash: 'dashed', dot: 'dotted', double: 'double', none: 'none' };
|
|
698
|
+
|
|
699
|
+
const encode = (s?: { kind: string; pt: number; color: string }, tag?: string) => {
|
|
700
|
+
if (!s || !tag) return '';
|
|
701
|
+
const val = strokeKindMap[s.kind] ?? 'single';
|
|
702
|
+
return `<w:${tag} w:val="${val}" w:sz="${Math.round(s.pt * 8)}" w:space="0" w:color="${s.color}"/>`;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
return `<w:tcBorders>${encode(cp.top, 'top')}${encode(cp.bot, 'bottom')}${encode(cp.left, 'left')}${encode(cp.right, 'right')}</w:tcBorders>`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function esc(s: string): string { return TextKit.escapeXml(s); }
|
|
709
|
+
|
|
710
|
+
registry.registerEncoder(new DocxEncoder());
|