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.
@@ -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());