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,770 @@
|
|
|
1
|
+
import type { Encoder } from "../../contract/encoder";
|
|
2
|
+
import type {
|
|
3
|
+
DocRoot,
|
|
4
|
+
ParaNode,
|
|
5
|
+
SpanNode,
|
|
6
|
+
GridNode,
|
|
7
|
+
ContentNode,
|
|
8
|
+
ImgNode,
|
|
9
|
+
SheetNode,
|
|
10
|
+
CellNode,
|
|
11
|
+
} from "../../model/doc-tree";
|
|
12
|
+
import type { Outcome } from "../../contract/result";
|
|
13
|
+
import type {
|
|
14
|
+
PageDims,
|
|
15
|
+
TextProps,
|
|
16
|
+
ParaProps,
|
|
17
|
+
CellProps,
|
|
18
|
+
Stroke,
|
|
19
|
+
} from "../../model/doc-props";
|
|
20
|
+
import { A4, DEFAULT_STROKE, normalizeDims } from "../../model/doc-props";
|
|
21
|
+
import { succeed, fail } from "../../contract/result";
|
|
22
|
+
import { Metric, safeFontToKr } from "../../safety/StyleBridge";
|
|
23
|
+
import { ArchiveKit } from "../../toolkit/ArchiveKit";
|
|
24
|
+
import { TextKit } from "../../toolkit/TextKit";
|
|
25
|
+
import { registry } from "../../pipeline/registry";
|
|
26
|
+
|
|
27
|
+
// ─── All HWPX namespaces ────────────────────────────────────
|
|
28
|
+
const NS = [
|
|
29
|
+
'xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app"',
|
|
30
|
+
'xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"',
|
|
31
|
+
'xmlns:hp10="http://www.hancom.co.kr/hwpml/2016/paragraph"',
|
|
32
|
+
'xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"',
|
|
33
|
+
'xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"',
|
|
34
|
+
'xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"',
|
|
35
|
+
'xmlns:hhs="http://www.hancom.co.kr/hwpml/2011/history"',
|
|
36
|
+
'xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page"',
|
|
37
|
+
'xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"',
|
|
38
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
39
|
+
'xmlns:opf="http://www.idpf.org/2007/opf/"',
|
|
40
|
+
'xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart"',
|
|
41
|
+
'xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar"',
|
|
42
|
+
'xmlns:epub="http://www.idpf.org/2007/ops"',
|
|
43
|
+
'xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"',
|
|
44
|
+
].join(" ");
|
|
45
|
+
|
|
46
|
+
// ─── Registries for IDRef system ────────────────────────────
|
|
47
|
+
|
|
48
|
+
interface CharPrDef {
|
|
49
|
+
id: number;
|
|
50
|
+
height: number;
|
|
51
|
+
bold: boolean;
|
|
52
|
+
italic: boolean;
|
|
53
|
+
underline: string;
|
|
54
|
+
strikeout: string;
|
|
55
|
+
textColor: string;
|
|
56
|
+
fontName: string;
|
|
57
|
+
fontId: number;
|
|
58
|
+
bg?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ParaPrDef {
|
|
62
|
+
id: number;
|
|
63
|
+
align: string;
|
|
64
|
+
listType?: string;
|
|
65
|
+
listLevel?: number;
|
|
66
|
+
intentHwp: number; // first-line indent in HWPUNIT
|
|
67
|
+
prevHwp: number; // space before paragraph in HWPUNIT
|
|
68
|
+
nextHwp: number; // space after paragraph in HWPUNIT
|
|
69
|
+
lineSpacing: number; // line spacing percentage (e.g., 160 = 160%)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface BinEntry {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
data: Uint8Array;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HwpxCtx {
|
|
79
|
+
charPrs: CharPrDef[];
|
|
80
|
+
charPrMap: Map<string, number>;
|
|
81
|
+
paraPrs: ParaPrDef[];
|
|
82
|
+
paraPrMap: Map<string, number>;
|
|
83
|
+
borderFills: { id: number; xml: string }[];
|
|
84
|
+
bins: BinEntry[];
|
|
85
|
+
nextBinNum: number;
|
|
86
|
+
nextElementId: number;
|
|
87
|
+
availableWidth: number; // HWPUNIT — page width minus margins
|
|
88
|
+
fonts: string[];
|
|
89
|
+
fontMap: Map<string, number>;
|
|
90
|
+
imgMap: WeakMap<ImgNode, string>; // ImgNode → binId (no mutation)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function charPrKey(p: TextProps): string {
|
|
94
|
+
return `${p.b ? 1 : 0}|${p.i ? 1 : 0}|${p.u ? 1 : 0}|${p.s ? 1 : 0}|${p.pt ?? 10}|${p.color ?? "000000"}|${p.font ?? ""}|${p.bg ?? ""}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function paraPrKey(p: ParaProps): string {
|
|
98
|
+
return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function registerFont(name: string, ctx: HwpxCtx): number {
|
|
102
|
+
const n = name || "굴림체";
|
|
103
|
+
const existing = ctx.fontMap.get(n);
|
|
104
|
+
if (existing !== undefined) return existing;
|
|
105
|
+
const id = ctx.fonts.length;
|
|
106
|
+
ctx.fonts.push(n);
|
|
107
|
+
ctx.fontMap.set(n, id);
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
112
|
+
const key = charPrKey(props);
|
|
113
|
+
const existing = ctx.charPrMap.get(key);
|
|
114
|
+
if (existing !== undefined) return existing;
|
|
115
|
+
|
|
116
|
+
const fontName = safeFontToKr(props.font) || "굴림체";
|
|
117
|
+
const fontId = registerFont(fontName, ctx);
|
|
118
|
+
|
|
119
|
+
const id = ctx.charPrs.length;
|
|
120
|
+
const def: CharPrDef = {
|
|
121
|
+
id,
|
|
122
|
+
height: Metric.ptToHHeight(props.pt ?? 10),
|
|
123
|
+
bold: !!props.b,
|
|
124
|
+
italic: !!props.i,
|
|
125
|
+
underline: props.u ? "BOTTOM" : "NONE",
|
|
126
|
+
strikeout: props.s ? "SOLID" : "NONE",
|
|
127
|
+
textColor: props.color ? `#${props.color}` : "#000000",
|
|
128
|
+
fontName,
|
|
129
|
+
fontId,
|
|
130
|
+
bg: props.bg,
|
|
131
|
+
};
|
|
132
|
+
ctx.charPrs.push(def);
|
|
133
|
+
ctx.charPrMap.set(key, id);
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
|
|
138
|
+
const key = paraPrKey(props);
|
|
139
|
+
const existing = ctx.paraPrMap.get(key);
|
|
140
|
+
if (existing !== undefined) return existing;
|
|
141
|
+
|
|
142
|
+
const id = ctx.paraPrs.length;
|
|
143
|
+
const def: ParaPrDef = {
|
|
144
|
+
id,
|
|
145
|
+
align: (props.align ?? "left").toUpperCase(),
|
|
146
|
+
intentHwp: props.indentPt ? Metric.ptToHwp(props.indentPt) : 0,
|
|
147
|
+
prevHwp: props.spaceBefore ? Metric.ptToHwp(props.spaceBefore) : 0,
|
|
148
|
+
nextHwp: props.spaceAfter ? Metric.ptToHwp(props.spaceAfter) : 0,
|
|
149
|
+
lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
|
|
150
|
+
};
|
|
151
|
+
if (props.listOrd !== undefined) {
|
|
152
|
+
def.listType = props.listOrd ? "DIGIT" : "BULLET";
|
|
153
|
+
def.listLevel = props.listLv ?? 0;
|
|
154
|
+
}
|
|
155
|
+
ctx.paraPrs.push(def);
|
|
156
|
+
ctx.paraPrMap.set(key, id);
|
|
157
|
+
return id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Pre-scan: collect all charPr/paraPr used ───────────────
|
|
161
|
+
|
|
162
|
+
function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
|
|
163
|
+
for (const kid of kids) {
|
|
164
|
+
if (kid.tag === "para") scanPara(kid, ctx);
|
|
165
|
+
else if (kid.tag === "grid") scanGrid(kid, ctx);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scanPara(para: ParaNode, ctx: HwpxCtx): void {
|
|
170
|
+
registerParaPr(para.props, ctx);
|
|
171
|
+
for (const kid of para.kids) {
|
|
172
|
+
if (kid.tag === "span") registerCharPr(kid.props, ctx);
|
|
173
|
+
else if (kid.tag === "img") registerImage(kid, ctx);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
|
|
178
|
+
for (const row of grid.kids)
|
|
179
|
+
for (const cell of row.kids) for (const p of cell.kids) scanPara(p, ctx);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanParas(paras: ParaNode[], ctx: HwpxCtx): void {
|
|
183
|
+
for (const p of paras) scanPara(p, ctx);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Image handling ─────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function mimeToExt(mime: string): string {
|
|
189
|
+
if (mime.includes("jpeg")) return "jpg";
|
|
190
|
+
if (mime.includes("gif")) return "gif";
|
|
191
|
+
if (mime.includes("bmp")) return "bmp";
|
|
192
|
+
return "png";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function registerImage(img: ImgNode, ctx: HwpxCtx): void {
|
|
196
|
+
if (ctx.imgMap.has(img)) return;
|
|
197
|
+
const ext = mimeToExt(img.mime);
|
|
198
|
+
const id = `BIN${String(ctx.nextBinNum).padStart(4, "0")}`;
|
|
199
|
+
const name = `${id}.${ext}`;
|
|
200
|
+
ctx.nextBinNum++;
|
|
201
|
+
const data = TextKit.base64Decode(img.b64);
|
|
202
|
+
ctx.bins.push({ id, name, data });
|
|
203
|
+
ctx.imgMap.set(img, id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── BorderFill ─────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function addBorderFill(
|
|
209
|
+
ctx: HwpxCtx,
|
|
210
|
+
stroke?: Stroke,
|
|
211
|
+
bgColor?: string,
|
|
212
|
+
): number {
|
|
213
|
+
const id = ctx.borderFills.length + 1;
|
|
214
|
+
const s = stroke ?? DEFAULT_STROKE;
|
|
215
|
+
const kindMap: Record<string, string> = {
|
|
216
|
+
solid: "SOLID",
|
|
217
|
+
dash: "DASH",
|
|
218
|
+
dot: "DOT",
|
|
219
|
+
double: "DOUBLE",
|
|
220
|
+
none: "NONE",
|
|
221
|
+
};
|
|
222
|
+
const type = kindMap[s.kind] ?? "SOLID";
|
|
223
|
+
const w = `${(s.pt * 0.3528).toFixed(2)} mm`;
|
|
224
|
+
const c = s.color.startsWith("#") ? s.color : `#${s.color}`;
|
|
225
|
+
|
|
226
|
+
let fill = "";
|
|
227
|
+
if (bgColor) {
|
|
228
|
+
const bc = bgColor.startsWith("#") ? bgColor : `#${bgColor}`;
|
|
229
|
+
fill = `<hc:fillBrush><hc:winBrush faceColor="${bc}" hatchColor="none" alpha="0"/></hc:fillBrush>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const xml = `<hh:borderFill id="${id}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/><hh:leftBorder type="${type}" width="${w}" color="${c}"/><hh:rightBorder type="${type}" width="${w}" color="${c}"/><hh:topBorder type="${type}" width="${w}" color="${c}"/><hh:bottomBorder type="${type}" width="${w}" color="${c}"/><hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>${fill}</hh:borderFill>`;
|
|
233
|
+
ctx.borderFills.push({ id, xml });
|
|
234
|
+
return id;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Encoder class ──────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
export class HwpxEncoder implements Encoder {
|
|
240
|
+
readonly format = "hwpx";
|
|
241
|
+
|
|
242
|
+
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
243
|
+
try {
|
|
244
|
+
const sheet = doc.kids[0];
|
|
245
|
+
const dims = normalizeDims(sheet?.dims ?? A4);
|
|
246
|
+
|
|
247
|
+
// Available width = page width - left margin - right margin (in HWPUNIT)
|
|
248
|
+
const availableWidth =
|
|
249
|
+
Metric.ptToHwp(dims.wPt) -
|
|
250
|
+
Metric.ptToHwp(dims.ml) -
|
|
251
|
+
Metric.ptToHwp(dims.mr);
|
|
252
|
+
|
|
253
|
+
const ctx: HwpxCtx = {
|
|
254
|
+
charPrs: [],
|
|
255
|
+
charPrMap: new Map(),
|
|
256
|
+
paraPrs: [],
|
|
257
|
+
paraPrMap: new Map(),
|
|
258
|
+
borderFills: [],
|
|
259
|
+
bins: [],
|
|
260
|
+
nextBinNum: 1,
|
|
261
|
+
nextElementId: 10000,
|
|
262
|
+
availableWidth,
|
|
263
|
+
fonts: [],
|
|
264
|
+
fontMap: new Map(),
|
|
265
|
+
imgMap: new WeakMap(),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Default borderFill (id=1, no border)
|
|
269
|
+
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
270
|
+
// Table border borderFill (id=2)
|
|
271
|
+
addBorderFill(ctx, DEFAULT_STROKE);
|
|
272
|
+
// Default no-border for text areas (id=3)
|
|
273
|
+
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
274
|
+
|
|
275
|
+
// Register default charPr (id=0) and paraPr (id=0)
|
|
276
|
+
registerCharPr({}, ctx);
|
|
277
|
+
registerParaPr({}, ctx);
|
|
278
|
+
|
|
279
|
+
// Pre-scan all content to collect charPr/paraPr/images
|
|
280
|
+
scanContent(sheet?.kids ?? [], ctx);
|
|
281
|
+
if (sheet?.header) scanParas(sheet.header, ctx);
|
|
282
|
+
if (sheet?.footer) scanParas(sheet.footer, ctx);
|
|
283
|
+
|
|
284
|
+
// Extract plain text preview from document
|
|
285
|
+
const previewText = extractPreviewText(sheet);
|
|
286
|
+
|
|
287
|
+
// IMPORTANT: Generate section XML FIRST so that borderFills created
|
|
288
|
+
// during table encoding are registered in ctx before headerXml runs.
|
|
289
|
+
const sectionData = TextKit.encode(sectionXml(sheet, dims, ctx));
|
|
290
|
+
const headerData = TextKit.encode(headerXml(dims, doc.meta, ctx));
|
|
291
|
+
|
|
292
|
+
const entries: { name: string; data: Uint8Array }[] = [
|
|
293
|
+
{ name: "mimetype", data: TextKit.encode("application/hwp+zip") },
|
|
294
|
+
{ name: "version.xml", data: TextKit.encode(VERSION_XML) },
|
|
295
|
+
{ name: "Contents/header.xml", data: headerData },
|
|
296
|
+
{ name: "Contents/section0.xml", data: sectionData },
|
|
297
|
+
{ name: "Preview/PrvText.txt", data: TextKit.encode(previewText) },
|
|
298
|
+
{ name: "settings.xml", data: TextKit.encode(SETTINGS_XML) },
|
|
299
|
+
{ name: "META-INF/container.rdf", data: TextKit.encode(CONTAINER_RDF) },
|
|
300
|
+
{
|
|
301
|
+
name: "Contents/content.hpf",
|
|
302
|
+
data: TextKit.encode(contentHpf(ctx, doc.meta)),
|
|
303
|
+
},
|
|
304
|
+
{ name: "META-INF/container.xml", data: TextKit.encode(CONTAINER_XML) },
|
|
305
|
+
{ name: "META-INF/manifest.xml", data: TextKit.encode(MANIFEST_XML) },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
for (const bin of ctx.bins) {
|
|
309
|
+
entries.push({ name: `BinData/${bin.name}`, data: bin.data });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return succeed(await ArchiveKit.zip(entries));
|
|
313
|
+
} catch (e: any) {
|
|
314
|
+
return fail(`HWPX encode error: ${e?.message ?? String(e)}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
const VERSION_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="0" buildNumber="1" os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="11, 0, 0, 0"/>`;
|
|
322
|
+
|
|
323
|
+
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"><ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/><ocf:rootfile full-path="Preview/PrvText.txt" media-type="text/plain"/><ocf:rootfile full-path="META-INF/container.rdf" media-type="application/rdf+xml"/></ocf:rootfiles></ocf:container>`;
|
|
324
|
+
|
|
325
|
+
const CONTAINER_RDF = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/header.xml"/></rdf:Description><rdf:Description rdf:about="Contents/header.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#HeaderFile"/></rdf:Description><rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/section0.xml"/></rdf:Description><rdf:Description rdf:about="Contents/section0.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#SectionFile"/></rdf:Description><rdf:Description rdf:about=""><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#Document"/></rdf:Description></rdf:RDF>`;
|
|
326
|
+
|
|
327
|
+
const MANIFEST_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>`;
|
|
328
|
+
|
|
329
|
+
const SETTINGS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/><config:config-item-set name="PrintInfo"><config:config-item name="PrintMethod" type="short">0</config:config-item><config:config-item name="ZoomX" type="short">100</config:config-item><config:config-item name="ZoomY" type="short">100</config:config-item></config:config-item-set></ha:HWPApplicationSetting>`;
|
|
330
|
+
|
|
331
|
+
function contentHpf(ctx: HwpxCtx, meta?: any): string {
|
|
332
|
+
const title = esc(meta?.title ?? "");
|
|
333
|
+
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
334
|
+
|
|
335
|
+
let items =
|
|
336
|
+
`<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>` +
|
|
337
|
+
`<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>` +
|
|
338
|
+
`<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
|
|
339
|
+
for (const bin of ctx.bins) {
|
|
340
|
+
const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
|
|
341
|
+
const ct =
|
|
342
|
+
ext === "png"
|
|
343
|
+
? "image/png"
|
|
344
|
+
: ext === "jpg" || ext === "jpeg"
|
|
345
|
+
? "image/jpeg"
|
|
346
|
+
: ext === "gif"
|
|
347
|
+
? "image/gif"
|
|
348
|
+
: "image/bmp";
|
|
349
|
+
items += `<opf:item id="${bin.id}" href="BinData/${bin.name}" media-type="${ct}" isEmbeded="1"/>`;
|
|
350
|
+
}
|
|
351
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><opf:package ${NS} version="" unique-identifier="" id=""><opf:metadata><opf:title>${title}</opf:title><opf:language>ko</opf:language><opf:meta name="creator" content="text"/><opf:meta name="subject" content="text"/><opf:meta name="description" content="text"/><opf:meta name="CreatedDate" content="text">${now}</opf:meta><opf:meta name="ModifiedDate" content="text">${now}</opf:meta><opf:meta name="keyword" content="text"/></opf:metadata><opf:manifest>${items}</opf:manifest><opf:spine><opf:itemref idref="header" linear="yes"/><opf:itemref idref="section0" linear="yes"/></opf:spine></opf:package>`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─── header.xml ─────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function headerXml(dims: PageDims, meta: any, ctx: HwpxCtx): string {
|
|
357
|
+
// Font face definitions — register all unique fonts per language group
|
|
358
|
+
const fontCount = ctx.fonts.length || 1;
|
|
359
|
+
const langs = [
|
|
360
|
+
"HANGUL",
|
|
361
|
+
"LATIN",
|
|
362
|
+
"HANJA",
|
|
363
|
+
"JAPANESE",
|
|
364
|
+
"OTHER",
|
|
365
|
+
"SYMBOL",
|
|
366
|
+
"USER",
|
|
367
|
+
];
|
|
368
|
+
let fontFaces = `<hh:fontfaces itemCnt="${langs.length}">`;
|
|
369
|
+
for (const lang of langs) {
|
|
370
|
+
fontFaces += `<hh:fontface lang="${lang}" fontCnt="${fontCount}">`;
|
|
371
|
+
for (let fi = 0; fi < ctx.fonts.length; fi++) {
|
|
372
|
+
fontFaces += `<hh:font id="${fi}" face="${esc(ctx.fonts[fi])}" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
|
|
373
|
+
}
|
|
374
|
+
if (ctx.fonts.length === 0) {
|
|
375
|
+
fontFaces += `<hh:font id="0" face="굴림체" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
|
|
376
|
+
}
|
|
377
|
+
fontFaces += `</hh:fontface>`;
|
|
378
|
+
}
|
|
379
|
+
fontFaces += `</hh:fontfaces>`;
|
|
380
|
+
|
|
381
|
+
// CharPr definitions
|
|
382
|
+
let charPrXml = "";
|
|
383
|
+
for (const cp of ctx.charPrs) {
|
|
384
|
+
const bold = cp.bold ? "<hh:bold/>" : "";
|
|
385
|
+
const italic = cp.italic ? "<hh:italic/>" : "";
|
|
386
|
+
const fid = cp.fontId ?? 0;
|
|
387
|
+
charPrXml += `<hh:charPr id="${cp.id}" height="${cp.height}" textColor="${cp.textColor}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="3"><hh:fontRef hangul="${fid}" latin="${fid}" hanja="${fid}" japanese="${fid}" other="${fid}" symbol="${fid}" user="${fid}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>${bold}${italic}<hh:underline type="${cp.underline}" shape="SOLID" color="#000000"/><hh:strikeout shape="${cp.strikeout}" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/></hh:charPr>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ParaPr definitions
|
|
391
|
+
let paraPrXml = "";
|
|
392
|
+
for (const pp of ctx.paraPrs) {
|
|
393
|
+
const marginSwitch = `<hp:switch><hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar"><hh:margin><hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/><hc:next value="${pp.nextHwp}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/></hp:case><hp:default><hh:margin><hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/><hc:next value="${pp.nextHwp}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/></hp:default></hp:switch>`;
|
|
394
|
+
paraPrXml += `<hh:paraPr id="${pp.id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="${pp.align}" vertical="BASELINE"/><hh:heading type="NONE" idRef="0" level="0"/><hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/>${marginSwitch}<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// BorderFill definitions
|
|
398
|
+
const borderFillXml = ctx.borderFills.map((bf) => bf.xml).join("");
|
|
399
|
+
|
|
400
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hh:head ${NS} version="1.4" secCnt="1"><hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/><hh:refList>${fontFaces}<hh:borderFills itemCnt="${ctx.borderFills.length}">${borderFillXml}</hh:borderFills><hh:charProperties itemCnt="${ctx.charPrs.length}">${charPrXml}</hh:charProperties><hh:paraProperties itemCnt="${ctx.paraPrs.length}">${paraPrXml}</hh:paraProperties><hh:tabProperties itemCnt="1"><hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/></hh:tabProperties><hh:styles itemCnt="1"><hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/></hh:styles></hh:refList><hh:compatibleDocument targetProgram="HWP201X"><hh:layoutCompatibility/></hh:compatibleDocument><hh:docOption><hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/></hh:docOption><hh:trackchageConfig flags="56"/></hh:head>`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── section0.xml ───────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
function sectionXml(
|
|
406
|
+
sheet: SheetNode | undefined,
|
|
407
|
+
dims: PageDims,
|
|
408
|
+
ctx: HwpxCtx,
|
|
409
|
+
): string {
|
|
410
|
+
const kids = sheet?.kids ?? [];
|
|
411
|
+
|
|
412
|
+
// First paragraph includes secPr
|
|
413
|
+
// WIDELY = portrait (standard), NARROWLY = landscape
|
|
414
|
+
const secPr = `<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0"><hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/><hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/><hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/><hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/><hp:pagePr landscape="${dims.orient === "landscape" ? "NARROWLY" : "WIDELY"}" width="${Metric.ptToHwp(dims.wPt)}" height="${Metric.ptToHwp(dims.hPt)}" gutterType="LEFT_ONLY"><hp:margin header="2834" footer="2834" gutter="0" left="${Metric.ptToHwp(dims.ml)}" right="${Metric.ptToHwp(dims.mr)}" top="${Metric.ptToHwp(dims.mt)}" bottom="${Metric.ptToHwp(dims.mb)}"/></hp:pagePr><hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="-1" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="284" belowLine="568" aboveLine="852"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="EACH_COLUMN" beneathText="0"/></hp:footNotePr><hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="0" type="NONE" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="0" belowLine="576" aboveLine="864"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="END_OF_DOCUMENT" beneathText="0"/></hp:endNotePr><hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill><hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill><hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill></hp:secPr><hp:ctrl><hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/></hp:ctrl>`;
|
|
415
|
+
|
|
416
|
+
let contentXml = "";
|
|
417
|
+
let isFirst = true;
|
|
418
|
+
|
|
419
|
+
const defaultLineseg = `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="${ctx.availableWidth}" flags="393216"/></hp:linesegarray>`;
|
|
420
|
+
|
|
421
|
+
for (const kid of kids) {
|
|
422
|
+
if (kid.tag === "para") {
|
|
423
|
+
contentXml += encodePara(kid, ctx, isFirst ? secPr : "");
|
|
424
|
+
isFirst = false;
|
|
425
|
+
} else if (kid.tag === "grid") {
|
|
426
|
+
// Grid is embedded inside a paragraph's run.
|
|
427
|
+
// When secPr is present, put it in a separate run (matching real HWPX structure).
|
|
428
|
+
const gridXml = encodeGrid(kid, ctx);
|
|
429
|
+
const prefix = isFirst ? secPr : "";
|
|
430
|
+
const runsXml = prefix
|
|
431
|
+
? `<hp:run charPrIDRef="0">${prefix}</hp:run><hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`
|
|
432
|
+
: `<hp:run charPrIDRef="0">${gridXml}<hp:t></hp:t></hp:run>`;
|
|
433
|
+
contentXml += `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">${runsXml}${defaultLineseg}</hp:p>`;
|
|
434
|
+
isFirst = false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// If empty, add one empty paragraph with secPr
|
|
439
|
+
if (contentXml === "") {
|
|
440
|
+
contentXml = `<hp:p id="0" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0">${secPr}<hp:t></hp:t></hp:run>${defaultLineseg}</hp:p>`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS}>${contentXml}</hs:sec>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function estimateCellHeight(cell: CellNode, ctx: HwpxCtx): number {
|
|
447
|
+
const topPad = 141;
|
|
448
|
+
const botPad = 141;
|
|
449
|
+
let contentHeight = 0;
|
|
450
|
+
for (const para of cell.kids) {
|
|
451
|
+
const fontSize = getFontSizeForPara(para, ctx);
|
|
452
|
+
const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props));
|
|
453
|
+
const paraPr = paraPrId !== undefined ? ctx.paraPrs[paraPrId] : null;
|
|
454
|
+
const lineSpacing = paraPr ? paraPr.lineSpacing : 160;
|
|
455
|
+
const spaceBefore = paraPr ? paraPr.prevHwp : 0;
|
|
456
|
+
const spaceAfter = paraPr ? paraPr.nextHwp : 0;
|
|
457
|
+
const lineHeight = Math.round((fontSize * lineSpacing) / 100);
|
|
458
|
+
contentHeight += lineHeight + spaceBefore + spaceAfter;
|
|
459
|
+
}
|
|
460
|
+
if (contentHeight === 0) contentHeight = Math.round(1000 * 1.6); // 10pt @ 160%
|
|
461
|
+
return contentHeight + topPad + botPad;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function encodePara(
|
|
465
|
+
para: ParaNode,
|
|
466
|
+
ctx: HwpxCtx,
|
|
467
|
+
secPr: string = "",
|
|
468
|
+
availWidth?: number,
|
|
469
|
+
): string {
|
|
470
|
+
const paraPrId = registerParaPr(para.props, ctx);
|
|
471
|
+
|
|
472
|
+
// Detect page break: paragraph starts on new page if any span contains a pb node
|
|
473
|
+
const hasPageBreak = para.kids.some(
|
|
474
|
+
(k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
let runs = "";
|
|
478
|
+
for (const kid of para.kids) {
|
|
479
|
+
if (kid.tag === "span") {
|
|
480
|
+
runs += encodeRun(kid, ctx);
|
|
481
|
+
} else if (kid.tag === "img") {
|
|
482
|
+
runs += encodeImage(kid, ctx);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// If no runs, add default empty run
|
|
487
|
+
if (runs === "") {
|
|
488
|
+
runs = `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Inject secPr into first run
|
|
492
|
+
if (secPr) {
|
|
493
|
+
// Insert secPr after the first charPrIDRef attribute close
|
|
494
|
+
const firstRunEnd = runs.indexOf(">");
|
|
495
|
+
if (firstRunEnd > -1) {
|
|
496
|
+
runs =
|
|
497
|
+
runs.substring(0, firstRunEnd + 1) +
|
|
498
|
+
secPr +
|
|
499
|
+
runs.substring(firstRunEnd + 1);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Compute lineseg values based on font size and actual line spacing.
|
|
504
|
+
// In HWPX, vertsize = font height and spacing = extra gap = fontSize * (lineSpacing/100 - 1).
|
|
505
|
+
// Total rendered line height = vertsize + spacing.
|
|
506
|
+
const fontSize = getFontSizeForPara(para, ctx);
|
|
507
|
+
const paraPr = ctx.paraPrs[paraPrId];
|
|
508
|
+
const lineSpacing = paraPr?.lineSpacing ?? 160;
|
|
509
|
+
const vertsize = fontSize;
|
|
510
|
+
const textheight = fontSize;
|
|
511
|
+
const baseline = Math.round(fontSize * 0.85);
|
|
512
|
+
const spacing = Math.max(0, Math.round(fontSize * (lineSpacing / 100 - 1)));
|
|
513
|
+
const horzsize = availWidth ?? ctx.availableWidth;
|
|
514
|
+
const linesegarray = `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="${vertsize}" textheight="${textheight}" baseline="${baseline}" spacing="${spacing}" horzpos="0" horzsize="${horzsize}" flags="393216"/></hp:linesegarray>`;
|
|
515
|
+
|
|
516
|
+
return `<hp:p id="0" paraPrIDRef="${paraPrId}" styleIDRef="0" pageBreak="${hasPageBreak ? "1" : "0"}" columnBreak="0" merged="0">${runs}${linesegarray}</hp:p>`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Get the font size (in HWPX height units, 1000=10pt) for lineseg computation */
|
|
520
|
+
function getFontSizeForPara(para: ParaNode, ctx: HwpxCtx): number {
|
|
521
|
+
for (const kid of para.kids) {
|
|
522
|
+
if (kid.tag === "span") {
|
|
523
|
+
const charPrId = ctx.charPrMap.get(charPrKey(kid.props));
|
|
524
|
+
if (charPrId !== undefined && ctx.charPrs[charPrId]) {
|
|
525
|
+
return ctx.charPrs[charPrId].height;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return 1000; // default 10pt
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function encodeRun(span: SpanNode, ctx: HwpxCtx): string {
|
|
533
|
+
const charPrId = registerCharPr(span.props, ctx);
|
|
534
|
+
|
|
535
|
+
const parts: string[] = [];
|
|
536
|
+
for (const kid of span.kids) {
|
|
537
|
+
if (kid.tag === "txt") {
|
|
538
|
+
if (kid.content) {
|
|
539
|
+
parts.push(`<hp:t>${esc(kid.content)}</hp:t>`);
|
|
540
|
+
} else {
|
|
541
|
+
parts.push(`<hp:t></hp:t>`);
|
|
542
|
+
}
|
|
543
|
+
} else if (kid.tag === "pagenum") {
|
|
544
|
+
const fmt =
|
|
545
|
+
kid.format === "roman"
|
|
546
|
+
? "ROMAN_LOWER"
|
|
547
|
+
: kid.format === "romanCaps"
|
|
548
|
+
? "ROMAN_UPPER"
|
|
549
|
+
: "DIGIT";
|
|
550
|
+
parts.push(`<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`);
|
|
551
|
+
} else if (kid.tag === "br") {
|
|
552
|
+
parts.push(`<hp:t>\n</hp:t>`);
|
|
553
|
+
}
|
|
554
|
+
// pb is handled at paragraph level (pageBreak attribute), skip here
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return `<hp:run charPrIDRef="${charPrId}">${parts.join("")}</hp:run>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// HWPX textWrap 매핑
|
|
561
|
+
const WRAP_HWPX: Record<string, string> = {
|
|
562
|
+
inline: 'TOP_AND_BOTTOM',
|
|
563
|
+
square: 'SQUARE',
|
|
564
|
+
tight: 'BOTH_SIDES',
|
|
565
|
+
through: 'BOTH_SIDES',
|
|
566
|
+
none: 'FRONT_TEXT',
|
|
567
|
+
behind: 'BEHIND_TEXT',
|
|
568
|
+
front: 'FRONT_TEXT',
|
|
569
|
+
};
|
|
570
|
+
// textFlow 매핑 (wrap 타입별)
|
|
571
|
+
const TEXT_FLOW_HWPX: Record<string, string> = {
|
|
572
|
+
inline: 'BOTH_SIDES',
|
|
573
|
+
square: 'LARGEST_ONLY',
|
|
574
|
+
tight: 'BOTH_SIDES',
|
|
575
|
+
through: 'BOTH_SIDES',
|
|
576
|
+
none: 'BOTH_SIDES',
|
|
577
|
+
behind: 'BOTH_SIDES',
|
|
578
|
+
front: 'BOTH_SIDES',
|
|
579
|
+
};
|
|
580
|
+
const HORZ_RELTO_HWPX: Record<string, string> = {
|
|
581
|
+
para: 'PARA', margin: 'MARGIN', page: 'PAPER', column: 'COLUMN',
|
|
582
|
+
};
|
|
583
|
+
const VERT_RELTO_HWPX: Record<string, string> = {
|
|
584
|
+
para: 'PARA', margin: 'MARGIN', page: 'PAPER', line: 'LINE',
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
function encodeImage(img: ImgNode, ctx: HwpxCtx): string {
|
|
588
|
+
const binId = ctx.imgMap.get(img);
|
|
589
|
+
if (!binId) return "";
|
|
590
|
+
|
|
591
|
+
const charPrId = registerCharPr({}, ctx);
|
|
592
|
+
const w = Metric.ptToHwp(img.w);
|
|
593
|
+
const h = Metric.ptToHwp(img.h);
|
|
594
|
+
const cx = Math.round(w / 2);
|
|
595
|
+
const cy = Math.round(h / 2);
|
|
596
|
+
|
|
597
|
+
const layout = img.layout;
|
|
598
|
+
const isInline = !layout || layout.wrap === 'inline';
|
|
599
|
+
|
|
600
|
+
const textWrap = layout ? (WRAP_HWPX[layout.wrap] ?? 'TOP_AND_BOTTOM') : 'TOP_AND_BOTTOM';
|
|
601
|
+
const textFlow = layout ? (TEXT_FLOW_HWPX[layout.wrap] ?? 'BOTH_SIDES') : 'BOTH_SIDES';
|
|
602
|
+
const treatAsChar = isInline ? '1' : '0';
|
|
603
|
+
const flowWithText = '1';
|
|
604
|
+
// behind/front/inline 이미지는 다른 객체와 겹침 허용 불필요; square/tight는 허용
|
|
605
|
+
const allowOverlap = (!isInline && layout?.wrap !== 'behind' && layout?.wrap !== 'front') ? '1' : '0';
|
|
606
|
+
|
|
607
|
+
const horzRelTo = layout?.horzRelTo ? (HORZ_RELTO_HWPX[layout.horzRelTo] ?? 'PARA') : 'PARA';
|
|
608
|
+
const vertRelTo = layout?.vertRelTo ? (VERT_RELTO_HWPX[layout.vertRelTo] ?? 'PARA') : 'PARA';
|
|
609
|
+
|
|
610
|
+
const ALIGN_H: Record<string, string> = { left: 'LEFT', center: 'CENTER', right: 'RIGHT' };
|
|
611
|
+
const ALIGN_V: Record<string, string> = { top: 'TOP', center: 'CENTER', bottom: 'BOTTOM' };
|
|
612
|
+
const horzAlign = layout?.horzAlign ? (ALIGN_H[layout.horzAlign] ?? 'LEFT') : 'LEFT';
|
|
613
|
+
const vertAlign = layout?.vertAlign ? (ALIGN_V[layout.vertAlign] ?? 'TOP') : 'TOP';
|
|
614
|
+
const horzOffset = layout?.xPt != null ? Metric.ptToHwp(layout.xPt) : 0;
|
|
615
|
+
const vertOffset = layout?.yPt != null ? Metric.ptToHwp(layout.yPt) : 0;
|
|
616
|
+
|
|
617
|
+
// hp:pic children must follow the exact HWPX spec order.
|
|
618
|
+
return `<hp:run charPrIDRef="${charPrId}"><hp:pic id="${ctx.nextElementId++}" zOrder="0" numberingType="PICTURE" textWrap="${textWrap}" textFlow="${textFlow}" lock="0" dropcapstyle="None" href="" groupLevel="0" instid="0" reverse="0"><hp:offset x="0" y="0"/><hp:orgSz width="${w}" height="${h}"/><hp:curSz width="${w}" height="${h}"/><hp:flip horizontal="0" vertical="0"/><hp:rotationInfo angle="0" centerX="${cx}" centerY="${cy}" rotateimage="1"/><hp:renderingInfo><hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/><hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/><hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/></hp:renderingInfo><hp:imgRect><hc:pt0 x="0" y="0"/><hc:pt1 x="${w}" y="0"/><hc:pt2 x="${w}" y="${h}"/><hc:pt3 x="0" y="${h}"/></hp:imgRect><hp:imgClip left="0" right="0" top="0" bottom="0"/><hp:inMargin left="0" right="0" top="0" bottom="0"/><hp:imgDim dimwidth="${w}" dimheight="${h}"/><hc:img binaryItemIDRef="${binId}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/><hp:effects/><hp:sz width="${w}" widthRelTo="ABSOLUTE" height="${h}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="${treatAsChar}" affectLSpacing="0" flowWithText="${flowWithText}" allowOverlap="${allowOverlap}" holdAnchorAndSO="0" vertRelTo="${vertRelTo}" horzRelTo="${horzRelTo}" vertAlign="${vertAlign}" horzAlign="${horzAlign}" vertOffset="${vertOffset}" horzOffset="${horzOffset}"/><hp:outMargin left="0" right="0" top="0" bottom="0"/></hp:pic><hp:t></hp:t></hp:run>`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function encodeGrid(grid: GridNode, ctx: HwpxCtx): string {
|
|
622
|
+
const rowCount = grid.kids.length;
|
|
623
|
+
|
|
624
|
+
// Compute true column count: max total colSpan across all rows
|
|
625
|
+
let colCount = 0;
|
|
626
|
+
for (const row of grid.kids) {
|
|
627
|
+
let rowCols = 0;
|
|
628
|
+
for (const cell of row.kids) rowCols += cell.cs;
|
|
629
|
+
if (rowCols > colCount) colCount = rowCols;
|
|
630
|
+
}
|
|
631
|
+
if (colCount === 0) colCount = grid.kids[0]?.kids.length ?? 1;
|
|
632
|
+
|
|
633
|
+
// Calculate column widths in HWPUNIT
|
|
634
|
+
const totalWidth = ctx.availableWidth;
|
|
635
|
+
const defaultColW = Math.round(totalWidth / (colCount || 1));
|
|
636
|
+
const colWidths: number[] = [];
|
|
637
|
+
if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
|
|
638
|
+
// Fill zero-width columns by distributing remaining space
|
|
639
|
+
const srcPt = [...grid.props.colWidths];
|
|
640
|
+
const knownTotal = srcPt.filter((w) => w > 0).reduce((s, w) => s + w, 0);
|
|
641
|
+
const zeroCount = srcPt.filter((w) => w <= 0).length;
|
|
642
|
+
const remaining = Math.max(0, Metric.hwpToPt(totalWidth) - knownTotal);
|
|
643
|
+
const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
|
|
644
|
+
for (let i = 0; i < srcPt.length; i++) {
|
|
645
|
+
if (srcPt[i] <= 0)
|
|
646
|
+
srcPt[i] = zeroFill > 0 ? zeroFill : Metric.hwpToPt(defaultColW);
|
|
647
|
+
}
|
|
648
|
+
for (const wPt of srcPt) colWidths.push(Metric.ptToHwp(wPt));
|
|
649
|
+
} else {
|
|
650
|
+
for (let c = 0; c < colCount; c++) colWidths.push(defaultColW);
|
|
651
|
+
}
|
|
652
|
+
// Scale to fit available width
|
|
653
|
+
const rawTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
654
|
+
if (rawTotal > totalWidth * 1.05) {
|
|
655
|
+
const scale = totalWidth / rawTotal;
|
|
656
|
+
for (let i = 0; i < colWidths.length; i++)
|
|
657
|
+
colWidths[i] = Math.round(colWidths[i] * scale);
|
|
658
|
+
}
|
|
659
|
+
const actualTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
660
|
+
|
|
661
|
+
// Table borderFillIDRef
|
|
662
|
+
const tblBfId = grid.props.defaultStroke
|
|
663
|
+
? addBorderFill(ctx, grid.props.defaultStroke)
|
|
664
|
+
: 2; // default table border
|
|
665
|
+
|
|
666
|
+
// Pre-calculate row heights (max cell height per row)
|
|
667
|
+
const rowHeights: number[] = [];
|
|
668
|
+
for (const row of grid.kids) {
|
|
669
|
+
let maxH = 0;
|
|
670
|
+
for (const cell of row.kids) {
|
|
671
|
+
const h = estimateCellHeight(cell, ctx);
|
|
672
|
+
if (h > maxH) maxH = h;
|
|
673
|
+
}
|
|
674
|
+
rowHeights.push(maxH);
|
|
675
|
+
}
|
|
676
|
+
const totalTableHeight = rowHeights.reduce((s, h) => s + h, 0);
|
|
677
|
+
|
|
678
|
+
// Rows
|
|
679
|
+
let rowsXml = "";
|
|
680
|
+
for (let ri = 0; ri < grid.kids.length; ri++) {
|
|
681
|
+
const row = grid.kids[ri];
|
|
682
|
+
const rowH = rowHeights[ri];
|
|
683
|
+
let cellsXml = "";
|
|
684
|
+
let colIdx = 0;
|
|
685
|
+
for (let ci = 0; ci < row.kids.length; ci++) {
|
|
686
|
+
const cell = row.kids[ci];
|
|
687
|
+
|
|
688
|
+
// Cell borderFill
|
|
689
|
+
let cellBfId = tblBfId;
|
|
690
|
+
if (cell.props.bg) {
|
|
691
|
+
cellBfId = addBorderFill(
|
|
692
|
+
ctx,
|
|
693
|
+
grid.props.defaultStroke ?? DEFAULT_STROKE,
|
|
694
|
+
cell.props.bg,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Calculate cell width from column widths
|
|
699
|
+
let cellW = 0;
|
|
700
|
+
for (
|
|
701
|
+
let sc = colIdx;
|
|
702
|
+
sc < colIdx + cell.cs && sc < colWidths.length;
|
|
703
|
+
sc++
|
|
704
|
+
)
|
|
705
|
+
cellW += colWidths[sc];
|
|
706
|
+
if (cellW === 0) cellW = defaultColW * cell.cs;
|
|
707
|
+
|
|
708
|
+
// Cell inner width for lineseg (subtract left + right cell margins)
|
|
709
|
+
const cellInnerW = Math.max(cellW - 282, 100);
|
|
710
|
+
|
|
711
|
+
// Encode cell paragraphs with correct inner width
|
|
712
|
+
const parasXml = cell.kids
|
|
713
|
+
.map((p) => encodePara(p, ctx, "", cellInnerW))
|
|
714
|
+
.join("");
|
|
715
|
+
|
|
716
|
+
cellsXml += `<hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBfId}"><hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="${cell.props.va === "mid" ? "CENTER" : cell.props.va === "bot" ? "BOTTOM" : "TOP"}" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">${parasXml}</hp:subList><hp:cellAddr colAddr="${colIdx}" rowAddr="${ri}"/><hp:cellSpan colSpan="${cell.cs}" rowSpan="${cell.rs}"/><hp:cellSz width="${cellW}" height="${rowH}"/><hp:cellMargin left="141" right="141" top="141" bottom="141"/></hp:tc>`;
|
|
717
|
+
colIdx += cell.cs;
|
|
718
|
+
}
|
|
719
|
+
rowsXml += `<hp:tr>${cellsXml}</hp:tr>`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const headerRow = grid.props.headerRow ? ' repeatHeader="1"' : "";
|
|
723
|
+
|
|
724
|
+
return `<hp:tbl id="${ctx.nextElementId++}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE"${headerRow} rowCnt="${rowCount}" colCnt="${colCount}" cellSpacing="0" borderFillIDRef="${tblBfId}" noAdjust="0"><hp:sz width="${actualTotal}" widthRelTo="ABSOLUTE" height="${totalTableHeight}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/><hp:outMargin left="138" right="138" top="138" bottom="138"/><hp:inMargin left="138" right="138" top="138" bottom="138"/>${rowsXml}</hp:tbl>`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function extractPreviewText(sheet?: SheetNode): string {
|
|
728
|
+
if (!sheet) return "";
|
|
729
|
+
const lines: string[] = [];
|
|
730
|
+
for (const kid of sheet.kids) {
|
|
731
|
+
if (kid.tag === "para") {
|
|
732
|
+
const text = kid.kids
|
|
733
|
+
.map((k) => {
|
|
734
|
+
if (k.tag === "span")
|
|
735
|
+
return k.kids
|
|
736
|
+
.map((c) => (c.tag === "txt" ? c.content : ""))
|
|
737
|
+
.join("");
|
|
738
|
+
return "";
|
|
739
|
+
})
|
|
740
|
+
.join("");
|
|
741
|
+
if (text) lines.push(text);
|
|
742
|
+
} else if (kid.tag === "grid") {
|
|
743
|
+
for (const row of kid.kids) {
|
|
744
|
+
const cells = row.kids.map((cell) =>
|
|
745
|
+
cell.kids
|
|
746
|
+
.map((p) =>
|
|
747
|
+
p.kids
|
|
748
|
+
.map((k) => {
|
|
749
|
+
if (k.tag === "span")
|
|
750
|
+
return k.kids
|
|
751
|
+
.map((c) => (c.tag === "txt" ? c.content : ""))
|
|
752
|
+
.join("");
|
|
753
|
+
return "";
|
|
754
|
+
})
|
|
755
|
+
.join(""),
|
|
756
|
+
)
|
|
757
|
+
.join(""),
|
|
758
|
+
);
|
|
759
|
+
lines.push(cells.join("\t"));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return lines.join("\r\n");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function esc(s: string): string {
|
|
767
|
+
return TextKit.escapeXml(s);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
registry.registerEncoder(new HwpxEncoder());
|