hwpkit-dev 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ .npmignore +4 -2
- package/README.md +39 -2
- package/dist/index.d.mts +41 -14
- package/dist/index.d.ts +41 -14
- package/dist/index.js +3553 -1159
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3553 -1159
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/playground/index.html +346 -0
- package/playground/main.ts +302 -0
- package/playground/vite.config.ts +16 -0
- package/src/contract/decoder.ts +1 -0
- package/src/contract/encoder.ts +6 -1
- package/src/core/BaseDecoder.ts +118 -0
- package/src/core/BaseEncoder.ts +146 -0
- package/src/decoders/docx/DocxDecoder.ts +743 -151
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +325 -157
- package/src/decoders/hwpx/HwpxDecoder.ts +785 -297
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +504 -240
- package/src/encoders/html/HtmlEncoder.ts +17 -19
- package/src/encoders/hwp/HwpEncoder.ts +1466 -859
- package/src/encoders/hwpx/HwpxEncoder.ts +1477 -469
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +20 -15
- package/src/model/builders.ts +4 -4
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +12 -4
- package/src/pipeline/Pipeline.ts +7 -3
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +51 -6
- package/src/toolkit/ArchiveKit.ts +56 -0
- package/src/toolkit/StyleMapper.ts +221 -0
- package/src/toolkit/UnitConverter.ts +138 -0
- package/src/toolkit/XmlKit.ts +0 -5
- package/test-styling.ts +210 -0
- package/hwp-analyze.ts +0 -90
- package/inspect-doc.ts +0 -57
- package/output_test.hwp +0 -0
- package/test-docx-to-hwp.ts +0 -45
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* HwpxEncoder — DocRoot → HWPX (ZIP + XML)
|
|
3
|
+
*
|
|
4
|
+
* ANYTOHWP에서 영감받은 개선 사항:
|
|
5
|
+
* 1. LangFontBank — 7개 언어 그룹 독립 폰트 레지스트리 (HANGUL/LATIN/HANJA/…)
|
|
6
|
+
* 2. BorderFillBank — 정확한 ID 관리 (하드코딩 "1" 제거)
|
|
7
|
+
* 3. readPixelDims — PNG/JPEG 바이너리 헤더에서 실제 픽셀 치수 추출
|
|
8
|
+
* 4. 두 패스 구조 — Pre-scan(등록) → Encode(생성)
|
|
9
|
+
*/
|
|
10
|
+
|
|
2
11
|
import type {
|
|
3
12
|
DocRoot,
|
|
4
13
|
ParaNode,
|
|
@@ -8,9 +17,12 @@ import type {
|
|
|
8
17
|
ImgNode,
|
|
9
18
|
SheetNode,
|
|
10
19
|
CellNode,
|
|
20
|
+
LinkNode,
|
|
11
21
|
} from "../../model/doc-tree";
|
|
12
22
|
import type { Outcome } from "../../contract/result";
|
|
23
|
+
import { BaseEncoder } from "../../core/BaseEncoder";
|
|
13
24
|
import type {
|
|
25
|
+
DocMeta,
|
|
14
26
|
PageDims,
|
|
15
27
|
TextProps,
|
|
16
28
|
ParaProps,
|
|
@@ -23,8 +35,9 @@ import { Metric, safeFontToKr } from "../../safety/StyleBridge";
|
|
|
23
35
|
import { ArchiveKit } from "../../toolkit/ArchiveKit";
|
|
24
36
|
import { TextKit } from "../../toolkit/TextKit";
|
|
25
37
|
import { registry } from "../../pipeline/registry";
|
|
38
|
+
import { HWPX_MIME_TYPE } from "./constants";
|
|
26
39
|
|
|
27
|
-
// ───
|
|
40
|
+
// ─── HWPX 네임스페이스 ──────────────────────────────────────
|
|
28
41
|
const NS = [
|
|
29
42
|
'xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app"',
|
|
30
43
|
'xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"',
|
|
@@ -36,77 +49,360 @@ const NS = [
|
|
|
36
49
|
'xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page"',
|
|
37
50
|
'xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"',
|
|
38
51
|
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
39
|
-
'xmlns:opf="http://www.idpf.org/2007/opf
|
|
52
|
+
'xmlns:opf="http://www.idpf.org/2007/opf"',
|
|
40
53
|
'xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart"',
|
|
41
|
-
'xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar"',
|
|
42
54
|
'xmlns:epub="http://www.idpf.org/2007/ops"',
|
|
43
55
|
'xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"',
|
|
44
56
|
].join(" ");
|
|
45
57
|
|
|
46
|
-
// ───
|
|
58
|
+
// ─── LinesegArray Flags 상수 (HWPX 스펙) ─────────────────
|
|
59
|
+
// 첫 번째 lineseg: 0x160000 = 1441792 (시작 줄, 고정 위치)
|
|
60
|
+
// 이후 lineseg: 0x60000 = 393216 (일반 줄)
|
|
61
|
+
const LINESEG_FLAGS_FIRST = 0x160000; // 1441792 - 첫 줄 (시작, 고정)
|
|
62
|
+
const LINESEG_FLAGS_OTHER = 0x60000; // 393216 - 이후 줄
|
|
63
|
+
|
|
64
|
+
// ─── ANYTOHWP 영감: 언어별 폰트 레지스트리 ─────────────────
|
|
65
|
+
// 7개 언어 그룹을 독립적으로 관리 — charPr fontRef의 정확한 ID 생성
|
|
66
|
+
const LANG_GROUPS = [
|
|
67
|
+
"HANGUL",
|
|
68
|
+
"LATIN",
|
|
69
|
+
"HANJA",
|
|
70
|
+
"JAPANESE",
|
|
71
|
+
"OTHER",
|
|
72
|
+
"SYMBOL",
|
|
73
|
+
"USER",
|
|
74
|
+
] as const;
|
|
75
|
+
type LangGroup = (typeof LANG_GROUPS)[number];
|
|
76
|
+
|
|
77
|
+
class LangFontBank {
|
|
78
|
+
// 언어 그룹별 독립 폰트 맵: face → localId (0-based)
|
|
79
|
+
private maps = new Map<LangGroup, Map<string, number>>(
|
|
80
|
+
LANG_GROUPS.map((g) => [g, new Map<string, number>()]),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
// ANYTOHWP 기본값: 모든 그룹에 한컴 기본 폰트 등록 (id=0)
|
|
85
|
+
this.registerAll("함초롬바탕");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 모든 언어 그룹에 동일 폰트 등록 */
|
|
89
|
+
registerAll(face: string): void {
|
|
90
|
+
for (const g of LANG_GROUPS) {
|
|
91
|
+
const m = this.maps.get(g)!;
|
|
92
|
+
if (!m.has(face)) m.set(face, m.size);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 특정 언어 그룹에 폰트 등록, 이미 있으면 기존 ID 반환 */
|
|
97
|
+
register(lang: LangGroup, face: string): number {
|
|
98
|
+
const m = this.maps.get(lang)!;
|
|
99
|
+
if (m.has(face)) return m.get(face)!;
|
|
100
|
+
const id = m.size;
|
|
101
|
+
m.set(face, id);
|
|
102
|
+
return id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 폰트 이름 → 한글 폰트 여부 판별 (ANYTOHWP 방식) */
|
|
106
|
+
private isKorean(face: string): boolean {
|
|
107
|
+
return (
|
|
108
|
+
/[\uAC00-\uD7A3\u3131-\u318E]/.test(face) ||
|
|
109
|
+
["맑은", "나눔", "굴림", "돋움", "바탕", "함초롬", "한컴", "HY"].some(
|
|
110
|
+
(k) => face.includes(k),
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** TextProps.font 문자열에서 적절한 HANGUL/LATIN 그룹에 등록 */
|
|
116
|
+
registerFont(rawFace: string): { hangulId: number; latinId: number } {
|
|
117
|
+
const face = safeFontToKr(rawFace) || "함초롬바탕";
|
|
118
|
+
const isKor = this.isKorean(face);
|
|
119
|
+
// 한글 폰트: HANGUL/HANJA/JAPANESE/OTHER/SYMBOL/USER에 등록
|
|
120
|
+
// 라틴 폰트: LATIN에 등록, 나머지는 기본값(0) 유지
|
|
121
|
+
const hangulId = this.register("HANGUL", isKor ? face : "함초롬바탕");
|
|
122
|
+
const latinId = this.register("LATIN", isKor ? "함초롬바탕" : face);
|
|
123
|
+
for (const g of [
|
|
124
|
+
"HANJA",
|
|
125
|
+
"JAPANESE",
|
|
126
|
+
"OTHER",
|
|
127
|
+
"SYMBOL",
|
|
128
|
+
"USER",
|
|
129
|
+
] as LangGroup[]) {
|
|
130
|
+
this.register(g, isKor ? face : "함초롬바탕");
|
|
131
|
+
}
|
|
132
|
+
return { hangulId, latinId };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 언어 그룹별 폰트 목록 반환 */
|
|
136
|
+
getFaces(lang: LangGroup): string[] {
|
|
137
|
+
return [...this.maps.get(lang)!.keys()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getId(lang: LangGroup, face: string): number {
|
|
141
|
+
return this.maps.get(lang)!.get(face) ?? 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** hh:fontfaces XML 생성 */
|
|
145
|
+
toXml(): string {
|
|
146
|
+
let xml = `<hh:fontfaces itemCnt="${LANG_GROUPS.length}">`;
|
|
147
|
+
for (const lang of LANG_GROUPS) {
|
|
148
|
+
const faces = this.getFaces(lang);
|
|
149
|
+
xml += `<hh:fontface lang="${lang}" fontCnt="${faces.length}">`;
|
|
150
|
+
faces.forEach((face, i) => {
|
|
151
|
+
xml +=
|
|
152
|
+
`<hh:font id="${i}" face="${esc(face)}" type="TTF" isEmbedded="0">` +
|
|
153
|
+
`<hh:typeInfo familyType="FCAT_UNKNOWN" weight="0" proportion="0" contrast="0" strokeVariation="0" armStyle="0" letterform="0" midline="252" xHeight="255"/>` +
|
|
154
|
+
`</hh:font>`;
|
|
155
|
+
});
|
|
156
|
+
xml += `</hh:fontface>`;
|
|
157
|
+
}
|
|
158
|
+
return xml + `</hh:fontfaces>`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── ANYTOHWP 영감: BorderFill 레지스트리 ───────────────────
|
|
163
|
+
// 하드코딩 "1" 제거 — 모든 셀/표의 실제 테두리를 추적
|
|
164
|
+
|
|
165
|
+
const KIND_MAP: Record<string, string> = {
|
|
166
|
+
solid: "SOLID",
|
|
167
|
+
dash: "DASH",
|
|
168
|
+
dot: "DOT",
|
|
169
|
+
double: "DOUBLE",
|
|
170
|
+
none: "NONE",
|
|
171
|
+
dash_dot: "DASH_DOT",
|
|
172
|
+
dash_dot_dot: "DASH_DOT_DOT",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
class BorderFillBank {
|
|
176
|
+
private fills: { id: number; xml: string }[] = [];
|
|
177
|
+
private keyMap = new Map<string, number>();
|
|
178
|
+
|
|
179
|
+
constructor() {
|
|
180
|
+
// id=1: 기본 (테두리 없음) — ANYTOHWP의 기본 초기화 방식
|
|
181
|
+
this._addXml(
|
|
182
|
+
this._buildXml(undefined, undefined, undefined, undefined, undefined),
|
|
183
|
+
);
|
|
184
|
+
// id=2: 표 기본 테두리 (solid 0.5pt black)
|
|
185
|
+
const defS: Stroke = { kind: "solid", pt: 0.5, color: "000000" };
|
|
186
|
+
this._addXml(this._buildXml(defS, defS, defS, defS, undefined));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private _strokeXml(tag: string, s?: Stroke): string {
|
|
190
|
+
const type =
|
|
191
|
+
s && s.kind !== "none" ? (KIND_MAP[s.kind] ?? "SOLID") : "NONE";
|
|
192
|
+
const w =
|
|
193
|
+
s && s.kind !== "none" ? `${(s.pt * 0.3528).toFixed(2)} mm` : "0.12 mm";
|
|
194
|
+
const c = s
|
|
195
|
+
? s.color.startsWith("#")
|
|
196
|
+
? s.color
|
|
197
|
+
: `#${s.color}`
|
|
198
|
+
: "#000000";
|
|
199
|
+
return `<hh:${tag} type="${type}" width="${w}" color="${c}"/>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private _buildXml(
|
|
203
|
+
top?: Stroke,
|
|
204
|
+
right?: Stroke,
|
|
205
|
+
bottom?: Stroke,
|
|
206
|
+
left?: Stroke,
|
|
207
|
+
bg?: string,
|
|
208
|
+
): string {
|
|
209
|
+
const fill = bg
|
|
210
|
+
? `<hc:fillBrush><hc:winBrush faceColor="${bg.startsWith("#") ? bg : "#" + bg}" hatchColor="none" alpha="0"/></hc:fillBrush>`
|
|
211
|
+
: "";
|
|
212
|
+
return (
|
|
213
|
+
`<hh:borderFill id="__ID__" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">` +
|
|
214
|
+
`<hh:slash type="NONE" Crooked="0" isCounter="0"/>` +
|
|
215
|
+
`<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>` +
|
|
216
|
+
this._strokeXml("leftBorder", left) +
|
|
217
|
+
this._strokeXml("rightBorder", right) +
|
|
218
|
+
this._strokeXml("topBorder", top) +
|
|
219
|
+
this._strokeXml("bottomBorder", bottom) +
|
|
220
|
+
`<hh:diagonal type="NONE" width="0.12 mm" color="#000000"/>` +
|
|
221
|
+
fill +
|
|
222
|
+
`</hh:borderFill>`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private _addXml(xml: string): number {
|
|
227
|
+
const id = this.fills.length + 1;
|
|
228
|
+
this.fills.push({ id, xml: xml.replace("__ID__", String(id)) });
|
|
229
|
+
return id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private _key(
|
|
233
|
+
top?: Stroke,
|
|
234
|
+
right?: Stroke,
|
|
235
|
+
bottom?: Stroke,
|
|
236
|
+
left?: Stroke,
|
|
237
|
+
bg?: string,
|
|
238
|
+
): string {
|
|
239
|
+
const sk = (s?: Stroke) =>
|
|
240
|
+
s ? `${s.kind}:${s.pt.toFixed(2)}:${s.color}` : "none";
|
|
241
|
+
return `${sk(top)}|${sk(right)}|${sk(bottom)}|${sk(left)}|${bg ?? ""}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 균일 테두리 등록 */
|
|
245
|
+
addUniform(s?: Stroke, bg?: string): number {
|
|
246
|
+
const key = this._key(s, s, s, s, bg);
|
|
247
|
+
if (this.keyMap.has(key)) return this.keyMap.get(key)!;
|
|
248
|
+
const id = this._addXml(this._buildXml(s, s, s, s, bg));
|
|
249
|
+
this.keyMap.set(key, id);
|
|
250
|
+
return id;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 방향별 테두리 등록 */
|
|
254
|
+
addPerSide(
|
|
255
|
+
top?: Stroke,
|
|
256
|
+
right?: Stroke,
|
|
257
|
+
bottom?: Stroke,
|
|
258
|
+
left?: Stroke,
|
|
259
|
+
bg?: string,
|
|
260
|
+
): number {
|
|
261
|
+
const key = this._key(top, right, bottom, left, bg);
|
|
262
|
+
if (this.keyMap.has(key)) return this.keyMap.get(key)!;
|
|
263
|
+
const id = this._addXml(this._buildXml(top, right, bottom, left, bg));
|
|
264
|
+
this.keyMap.set(key, id);
|
|
265
|
+
return id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** CellProps에서 적절한 borderFill ID 계산 (하드코딩 "1" 완전 제거) */
|
|
269
|
+
addFromCellProps(cp: CellProps, defStroke?: Stroke): number {
|
|
270
|
+
const d = defStroke ?? DEFAULT_STROKE;
|
|
271
|
+
const top = cp.top ?? d;
|
|
272
|
+
const right = cp.right ?? d;
|
|
273
|
+
const bottom = cp.bot ?? d;
|
|
274
|
+
const left = cp.left ?? d;
|
|
275
|
+
const bg = cp.bg;
|
|
276
|
+
const uniform =
|
|
277
|
+
top.kind === right.kind &&
|
|
278
|
+
top.kind === bottom.kind &&
|
|
279
|
+
top.kind === left.kind &&
|
|
280
|
+
top.pt === right.pt &&
|
|
281
|
+
top.pt === bottom.pt &&
|
|
282
|
+
top.pt === left.pt &&
|
|
283
|
+
top.color === right.color &&
|
|
284
|
+
top.color === bottom.color &&
|
|
285
|
+
top.color === left.color;
|
|
286
|
+
return uniform
|
|
287
|
+
? this.addUniform(top, bg)
|
|
288
|
+
: this.addPerSide(top, right, bottom, left, bg);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
toXml(): string {
|
|
292
|
+
return `<hh:borderFills itemCnt="${this.fills.length}">${this.fills.map((f) => f.xml).join("")}</hh:borderFills>`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── ANYTOHWP 영감: PNG/JPEG 바이너리 헤더에서 픽셀 치수 추출
|
|
297
|
+
function readPixelDims(
|
|
298
|
+
b64: string,
|
|
299
|
+
mime: string,
|
|
300
|
+
): { w: number; h: number } | null {
|
|
301
|
+
try {
|
|
302
|
+
const raw = TextKit.base64Decode(b64);
|
|
303
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
304
|
+
|
|
305
|
+
if (mime.includes("png")) {
|
|
306
|
+
// PNG: 시그니처 8바이트 + IHDR 청크 길이(4) + 타입(4) + 너비(4) + 높이(4)
|
|
307
|
+
if (
|
|
308
|
+
raw.length >= 24 &&
|
|
309
|
+
view.getUint32(0) === 0x89504e47 &&
|
|
310
|
+
view.getUint32(4) === 0x0d0a1a0a
|
|
311
|
+
) {
|
|
312
|
+
return { w: view.getUint32(16), h: view.getUint32(20) };
|
|
313
|
+
}
|
|
314
|
+
} else if (mime.includes("jpeg") || mime.includes("jpg")) {
|
|
315
|
+
// JPEG: SOI(FF D8) 후 SOF0(FF C0) 또는 SOF2(FF C2) 마커 탐색
|
|
316
|
+
let off = 2;
|
|
317
|
+
while (off < raw.length - 4) {
|
|
318
|
+
const marker = view.getUint16(off);
|
|
319
|
+
off += 2;
|
|
320
|
+
if (marker === 0xffc0 || marker === 0xffc2) {
|
|
321
|
+
// SOF: length(2) + precision(1) + height(2) + width(2)
|
|
322
|
+
return { w: view.getUint16(off + 5), h: view.getUint16(off + 3) };
|
|
323
|
+
}
|
|
324
|
+
if ((marker & 0xff00) !== 0xff00) break;
|
|
325
|
+
const segLen = view.getUint16(off);
|
|
326
|
+
off += segLen;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
/* 무시 */
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── charPr / paraPr 레지스트리 ─────────────────────────────
|
|
47
336
|
|
|
48
337
|
interface CharPrDef {
|
|
49
338
|
id: number;
|
|
50
|
-
height: number;
|
|
339
|
+
height: number; // HWPX height 단위 (1000 = 10pt)
|
|
51
340
|
bold: boolean;
|
|
52
341
|
italic: boolean;
|
|
53
|
-
underline: string;
|
|
54
|
-
strikeout: string;
|
|
55
|
-
textColor: string;
|
|
56
|
-
|
|
57
|
-
|
|
342
|
+
underline: string; // "NONE" | "BOTTOM"
|
|
343
|
+
strikeout: string; // "NONE" | "SOLID"
|
|
344
|
+
textColor: string; // "#RRGGBB"
|
|
345
|
+
hangulId: number; // HANGUL 그룹 폰트 ID
|
|
346
|
+
latinId: number; // LATIN 그룹 폰트 ID
|
|
58
347
|
bg?: string;
|
|
59
348
|
}
|
|
60
349
|
|
|
61
350
|
interface ParaPrDef {
|
|
62
351
|
id: number;
|
|
63
352
|
align: string;
|
|
353
|
+
leftHwp: number;
|
|
354
|
+
rightHwp: number;
|
|
355
|
+
intentHwp: number;
|
|
356
|
+
prevHwp: number;
|
|
357
|
+
nextHwp: number;
|
|
358
|
+
lineSpacing: number;
|
|
64
359
|
listType?: string;
|
|
65
360
|
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
361
|
}
|
|
71
362
|
|
|
72
|
-
interface
|
|
73
|
-
id:
|
|
363
|
+
interface StyleEntry {
|
|
364
|
+
id: number;
|
|
74
365
|
name: string;
|
|
75
|
-
|
|
366
|
+
engName: string;
|
|
367
|
+
paraPrIDRef: number;
|
|
368
|
+
charPrIDRef: number;
|
|
76
369
|
}
|
|
77
370
|
|
|
78
|
-
interface
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
nextZOrder: number; // monotonically increasing z-order for images/objects
|
|
371
|
+
interface BinEntry {
|
|
372
|
+
id: string; // "BIN0001"
|
|
373
|
+
name: string; // "BIN0001.png"
|
|
374
|
+
data: Uint8Array;
|
|
92
375
|
}
|
|
93
376
|
|
|
94
377
|
function charPrKey(p: TextProps): string {
|
|
95
378
|
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 ?? ""}`;
|
|
96
379
|
}
|
|
97
380
|
|
|
381
|
+
/**
|
|
382
|
+
* ParaProps 를 해시 키로 변환 (동일 포맷팅 감지용)
|
|
383
|
+
* null/undefined는 0 으로 처리하여 일관성 유지
|
|
384
|
+
*/
|
|
98
385
|
function paraPrKey(p: ParaProps): string {
|
|
99
|
-
return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}`;
|
|
386
|
+
return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.firstLineIndentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}|${p.styleId ?? ""}`;
|
|
100
387
|
}
|
|
101
388
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
389
|
+
// ─── 인코딩 컨텍스트 ─────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
interface HwpxCtx {
|
|
392
|
+
fontBank: LangFontBank;
|
|
393
|
+
borderFillBank: BorderFillBank;
|
|
394
|
+
charPrs: CharPrDef[];
|
|
395
|
+
charPrMap: Map<string, number>;
|
|
396
|
+
paraPrs: ParaPrDef[];
|
|
397
|
+
paraPrMap: Map<string, number>;
|
|
398
|
+
bins: BinEntry[];
|
|
399
|
+
nextBinNum: number;
|
|
400
|
+
nextElementId: number;
|
|
401
|
+
availableWidth: number; // HWPUNIT
|
|
402
|
+
imgMap: WeakMap<ImgNode, string>;
|
|
403
|
+
nextZOrder: number;
|
|
404
|
+
styleIdToHwpxId: Map<string, number>;
|
|
405
|
+
hwpxStyles: StyleEntry[];
|
|
110
406
|
}
|
|
111
407
|
|
|
112
408
|
function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
@@ -114,11 +410,11 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
|
114
410
|
const existing = ctx.charPrMap.get(key);
|
|
115
411
|
if (existing !== undefined) return existing;
|
|
116
412
|
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
413
|
+
const rawFont = props.font ?? "함초롬바탕";
|
|
414
|
+
const { hangulId, latinId } = ctx.fontBank.registerFont(rawFont);
|
|
120
415
|
const id = ctx.charPrs.length;
|
|
121
|
-
|
|
416
|
+
|
|
417
|
+
ctx.charPrs.push({
|
|
122
418
|
id,
|
|
123
419
|
height: Metric.ptToHHeight(props.pt ?? 10),
|
|
124
420
|
bold: !!props.b,
|
|
@@ -126,11 +422,10 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
|
126
422
|
underline: props.u ? "BOTTOM" : "NONE",
|
|
127
423
|
strikeout: props.s ? "SOLID" : "NONE",
|
|
128
424
|
textColor: props.color ? `#${props.color}` : "#000000",
|
|
129
|
-
|
|
130
|
-
|
|
425
|
+
hangulId,
|
|
426
|
+
latinId,
|
|
131
427
|
bg: props.bg,
|
|
132
|
-
};
|
|
133
|
-
ctx.charPrs.push(def);
|
|
428
|
+
});
|
|
134
429
|
ctx.charPrMap.set(key, id);
|
|
135
430
|
return id;
|
|
136
431
|
}
|
|
@@ -144,9 +439,11 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
|
|
|
144
439
|
const def: ParaPrDef = {
|
|
145
440
|
id,
|
|
146
441
|
align: (props.align ?? "left").toUpperCase(),
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
442
|
+
leftHwp: Metric.ptToHwp(props.indentPt ?? 0),
|
|
443
|
+
rightHwp: Metric.ptToHwp(props.indentRightPt ?? 0),
|
|
444
|
+
intentHwp: Metric.ptToHwp(props.firstLineIndentPt ?? 0),
|
|
445
|
+
prevHwp: Metric.ptToHwp(props.spaceBefore ?? 0),
|
|
446
|
+
nextHwp: Metric.ptToHwp(props.spaceAfter ?? 0),
|
|
150
447
|
lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
|
|
151
448
|
};
|
|
152
449
|
if (props.listOrd !== undefined) {
|
|
@@ -158,33 +455,7 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
|
|
|
158
455
|
return id;
|
|
159
456
|
}
|
|
160
457
|
|
|
161
|
-
// ───
|
|
162
|
-
|
|
163
|
-
function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
|
|
164
|
-
for (const kid of kids) {
|
|
165
|
-
if (kid.tag === "para") scanPara(kid, ctx);
|
|
166
|
-
else if (kid.tag === "grid") scanGrid(kid, ctx);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function scanPara(para: ParaNode, ctx: HwpxCtx): void {
|
|
171
|
-
registerParaPr(para.props, ctx);
|
|
172
|
-
for (const kid of para.kids) {
|
|
173
|
-
if (kid.tag === "span") registerCharPr(kid.props, ctx);
|
|
174
|
-
else if (kid.tag === "img") registerImage(kid, ctx);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
|
|
179
|
-
for (const row of grid.kids)
|
|
180
|
-
for (const cell of row.kids) for (const p of cell.kids) scanPara(p, ctx);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function scanParas(paras: ParaNode[], ctx: HwpxCtx): void {
|
|
184
|
-
for (const p of paras) scanPara(p, ctx);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ─── Image handling ─────────────────────────────────────────
|
|
458
|
+
// ─── 이미지 등록 ─────────────────────────────────────────────
|
|
188
459
|
|
|
189
460
|
function mimeToExt(mime: string): string {
|
|
190
461
|
if (mime.includes("jpeg")) return "jpg";
|
|
@@ -204,170 +475,269 @@ function registerImage(img: ImgNode, ctx: HwpxCtx): void {
|
|
|
204
475
|
ctx.imgMap.set(img, id);
|
|
205
476
|
}
|
|
206
477
|
|
|
207
|
-
// ───
|
|
478
|
+
// ─── 스타일 등록 ─────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
const STYLE_NAME_MAP: Record<string, string> = {
|
|
481
|
+
Normal: "바탕글",
|
|
482
|
+
"Heading 1": "개요 1",
|
|
483
|
+
"Heading 2": "개요 2",
|
|
484
|
+
"Heading 3": "개요 3",
|
|
485
|
+
"Heading 4": "개요 4",
|
|
486
|
+
"Heading 5": "개요 5",
|
|
487
|
+
"Heading 6": "개요 6",
|
|
488
|
+
"Body Text": "본문",
|
|
489
|
+
};
|
|
208
490
|
|
|
209
|
-
function
|
|
491
|
+
function registerStyle(
|
|
492
|
+
styleId: string,
|
|
493
|
+
paraPrId: number,
|
|
494
|
+
charPrId: number,
|
|
210
495
|
ctx: HwpxCtx,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const kindMap: Record<string, string> = {
|
|
217
|
-
solid: "SOLID",
|
|
218
|
-
dash: "DASH",
|
|
219
|
-
dot: "DOT",
|
|
220
|
-
double: "DOUBLE",
|
|
221
|
-
none: "NONE",
|
|
222
|
-
};
|
|
223
|
-
const type = kindMap[s.kind] ?? "SOLID";
|
|
224
|
-
const w = `${(s.pt * 0.3528).toFixed(2)} mm`;
|
|
225
|
-
const c = s.color.startsWith("#") ? s.color : `#${s.color}`;
|
|
226
|
-
|
|
227
|
-
let fill = "";
|
|
228
|
-
if (bgColor) {
|
|
229
|
-
const bc = bgColor.startsWith("#") ? bgColor : `#${bgColor}`;
|
|
230
|
-
fill = `<hc:fillBrush><hc:winBrush faceColor="${bc}" hatchColor="none" alpha="0"/></hc:fillBrush>`;
|
|
496
|
+
): void {
|
|
497
|
+
if (!styleId || ctx.styleIdToHwpxId.has(styleId)) return;
|
|
498
|
+
if (styleId === "Normal") {
|
|
499
|
+
ctx.styleIdToHwpxId.set(styleId, 0);
|
|
500
|
+
return;
|
|
231
501
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
ctx.
|
|
235
|
-
|
|
502
|
+
const hwpxId = ctx.hwpxStyles.length;
|
|
503
|
+
ctx.styleIdToHwpxId.set(styleId, hwpxId);
|
|
504
|
+
ctx.hwpxStyles.push({
|
|
505
|
+
id: hwpxId,
|
|
506
|
+
name: STYLE_NAME_MAP[styleId] ?? styleId,
|
|
507
|
+
engName: "",
|
|
508
|
+
paraPrIDRef: paraPrId,
|
|
509
|
+
charPrIDRef: charPrId,
|
|
510
|
+
});
|
|
236
511
|
}
|
|
237
512
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
):
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
513
|
+
// ─── Pre-scan: 콘텐츠 순회하며 모든 ID 사전 등록 ─────────────
|
|
514
|
+
|
|
515
|
+
function scanPara(para: ParaNode, ctx: HwpxCtx): void {
|
|
516
|
+
const paraPrId = registerParaPr(para.props, ctx);
|
|
517
|
+
let firstCharPrId = 0;
|
|
518
|
+
let hasFirstSpan = false;
|
|
519
|
+
|
|
520
|
+
function scanKids(kids: ParaNode["kids"]): void {
|
|
521
|
+
for (const kid of kids) {
|
|
522
|
+
if (kid.tag === "span") {
|
|
523
|
+
const cId = registerCharPr(kid.props, ctx);
|
|
524
|
+
if (!hasFirstSpan) {
|
|
525
|
+
firstCharPrId = cId;
|
|
526
|
+
hasFirstSpan = true;
|
|
527
|
+
}
|
|
528
|
+
} else if (kid.tag === "img") {
|
|
529
|
+
registerImage(kid, ctx);
|
|
530
|
+
} else if (kid.tag === "link") {
|
|
531
|
+
scanKids((kid as LinkNode).kids as ParaNode["kids"]);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
255
534
|
}
|
|
535
|
+
scanKids(para.kids);
|
|
536
|
+
if (para.props.styleId)
|
|
537
|
+
registerStyle(para.props.styleId, paraPrId, firstCharPrId, ctx);
|
|
538
|
+
}
|
|
256
539
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
540
|
+
function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
|
|
541
|
+
const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
|
|
542
|
+
// 표 기본 테두리 사전 등록
|
|
543
|
+
ctx.borderFillBank.addUniform(defStroke);
|
|
544
|
+
for (const row of grid.kids) {
|
|
545
|
+
for (const cell of row.kids) {
|
|
546
|
+
ctx.borderFillBank.addFromCellProps(cell.props, defStroke);
|
|
547
|
+
for (const p of cell.kids) {
|
|
548
|
+
if (p.tag === 'grid') scanGrid(p, ctx);
|
|
549
|
+
else scanPara(p, ctx);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
261
552
|
}
|
|
553
|
+
}
|
|
262
554
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
555
|
+
function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
|
|
556
|
+
for (const kid of kids) {
|
|
557
|
+
if (kid.tag === "para") scanPara(kid, ctx);
|
|
558
|
+
else if (kid.tag === "grid") scanGrid(kid, ctx);
|
|
559
|
+
}
|
|
266
560
|
}
|
|
267
561
|
|
|
268
|
-
// ─── Encoder
|
|
562
|
+
// ─── Encoder 클래스 ──────────────────────────────────────────
|
|
269
563
|
|
|
270
|
-
export class HwpxEncoder
|
|
271
|
-
|
|
564
|
+
export class HwpxEncoder extends BaseEncoder {
|
|
565
|
+
protected getFormat(): string { return "hwpx"; }
|
|
566
|
+
protected getAliases(): string[] { return [HWPX_MIME_TYPE, "application/hwp+zip"]; }
|
|
272
567
|
|
|
273
568
|
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
274
569
|
try {
|
|
275
570
|
const sheet = doc.kids[0];
|
|
276
571
|
const dims = normalizeDims(sheet?.dims ?? A4);
|
|
277
572
|
|
|
278
|
-
|
|
279
|
-
const
|
|
573
|
+
const safeML = dims.ml > 0 ? dims.ml : 70.87;
|
|
574
|
+
const safeMR = dims.mr > 0 ? dims.mr : 70.87;
|
|
575
|
+
const availableWidth = Math.round(
|
|
280
576
|
Metric.ptToHwp(dims.wPt) -
|
|
281
|
-
|
|
282
|
-
|
|
577
|
+
Metric.ptToHwp(safeML) -
|
|
578
|
+
Metric.ptToHwp(safeMR),
|
|
579
|
+
);
|
|
283
580
|
|
|
581
|
+
// 컨텍스트 초기화
|
|
284
582
|
const ctx: HwpxCtx = {
|
|
583
|
+
fontBank: new LangFontBank(), // ANYTOHWP 방식 언어별 폰트
|
|
584
|
+
borderFillBank: new BorderFillBank(), // 하드코딩 없는 테두리 관리
|
|
285
585
|
charPrs: [],
|
|
286
586
|
charPrMap: new Map(),
|
|
287
587
|
paraPrs: [],
|
|
288
588
|
paraPrMap: new Map(),
|
|
289
|
-
borderFills: [],
|
|
290
589
|
bins: [],
|
|
291
590
|
nextBinNum: 1,
|
|
292
591
|
nextElementId: 10000,
|
|
293
592
|
availableWidth,
|
|
294
|
-
fonts: [],
|
|
295
|
-
fontMap: new Map(),
|
|
296
593
|
imgMap: new WeakMap(),
|
|
297
594
|
nextZOrder: 0,
|
|
595
|
+
styleIdToHwpxId: new Map(),
|
|
596
|
+
hwpxStyles: [],
|
|
298
597
|
};
|
|
299
598
|
|
|
300
|
-
//
|
|
301
|
-
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
302
|
-
// Table border borderFill (id=2)
|
|
303
|
-
addBorderFill(ctx, DEFAULT_STROKE);
|
|
304
|
-
// Default no-border for text areas (id=3)
|
|
305
|
-
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
306
|
-
|
|
307
|
-
// Register default charPr (id=0) and paraPr (id=0)
|
|
599
|
+
// id=0 기본 charPr/paraPr 등록
|
|
308
600
|
registerCharPr({}, ctx);
|
|
309
601
|
registerParaPr({}, ctx);
|
|
310
602
|
|
|
311
|
-
//
|
|
603
|
+
// 바탕글(Normal) 스타일 id=0으로 고정
|
|
604
|
+
ctx.hwpxStyles.push({
|
|
605
|
+
id: 0,
|
|
606
|
+
name: "바탕글",
|
|
607
|
+
engName: "Normal",
|
|
608
|
+
paraPrIDRef: 0,
|
|
609
|
+
charPrIDRef: 0,
|
|
610
|
+
});
|
|
611
|
+
ctx.styleIdToHwpxId.set("Normal", 0);
|
|
612
|
+
|
|
613
|
+
// 패스 1: Pre-scan — 모든 charPr/paraPr/이미지/테두리 사전 등록
|
|
312
614
|
scanContent(sheet?.kids ?? [], ctx);
|
|
313
|
-
if (sheet?.
|
|
314
|
-
if (sheet?.
|
|
615
|
+
if (sheet?.headers?.default) for (const p of sheet.headers.default) scanPara(p, ctx);
|
|
616
|
+
if (sheet?.footers?.default) for (const p of sheet.footers.default) scanPara(p, ctx);
|
|
315
617
|
|
|
316
|
-
//
|
|
618
|
+
// 패스 2: Encode — section 먼저 (borderFill 동적 등록 완료 후 header 생성)
|
|
619
|
+
const sectionData = this.stringToBytes(buildSectionXml(sheet, dims, ctx));
|
|
620
|
+
const headerData = this.stringToBytes(buildHeaderXml(dims, doc.meta, ctx));
|
|
317
621
|
const previewText = extractPreviewText(sheet);
|
|
318
622
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
{
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
{
|
|
623
|
+
const entries: { name: string; data: Uint8Array; mime: string; compression?: 'STORE' | 'DEFLATE' }[] = [
|
|
624
|
+
{
|
|
625
|
+
name: "mimetype",
|
|
626
|
+
data: new TextEncoder().encode(HWPX_MIME_TYPE),
|
|
627
|
+
compression: "STORE",
|
|
628
|
+
mime: "",
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "version.xml",
|
|
632
|
+
data: this.stringToBytes(VERSION_XML),
|
|
633
|
+
mime: "application/xml",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
name: "META-INF/container.xml",
|
|
637
|
+
data: this.stringToBytes(CONTAINER_XML),
|
|
638
|
+
mime: "application/xml",
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "META-INF/container.rdf",
|
|
642
|
+
data: this.stringToBytes(CONTAINER_RDF),
|
|
643
|
+
mime: "application/rdf+xml",
|
|
644
|
+
},
|
|
332
645
|
{
|
|
333
646
|
name: "Contents/content.hpf",
|
|
334
|
-
data:
|
|
647
|
+
data: this.stringToBytes(buildContentHpf(ctx, doc.meta)),
|
|
648
|
+
mime: "application/hwpml-package+xml",
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "Contents/header.xml",
|
|
652
|
+
data: headerData,
|
|
653
|
+
mime: "application/xml",
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "Contents/section0.xml",
|
|
657
|
+
data: sectionData,
|
|
658
|
+
mime: "application/xml",
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: "Preview/PrvText.txt",
|
|
662
|
+
data: this.stringToBytes(previewText),
|
|
663
|
+
mime: "text/plain",
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: "settings.xml",
|
|
667
|
+
data: this.stringToBytes(buildSettingsXml()),
|
|
668
|
+
mime: "application/xml",
|
|
335
669
|
},
|
|
336
|
-
{ name: "META-INF/container.xml", data: TextKit.encode(CONTAINER_XML) },
|
|
337
|
-
{ name: "META-INF/manifest.xml", data: TextKit.encode(MANIFEST_XML) },
|
|
338
670
|
];
|
|
339
671
|
|
|
340
672
|
for (const bin of ctx.bins) {
|
|
341
|
-
|
|
673
|
+
const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
|
|
674
|
+
const ct =
|
|
675
|
+
ext === "png"
|
|
676
|
+
? "image/png"
|
|
677
|
+
: ext === "jpg" || ext === "jpeg"
|
|
678
|
+
? "image/jpeg"
|
|
679
|
+
: ext === "gif"
|
|
680
|
+
? "image/gif"
|
|
681
|
+
: "image/bmp";
|
|
682
|
+
entries.push({ name: `BinData/${bin.name}`, data: bin.data, mime: ct });
|
|
342
683
|
}
|
|
343
684
|
|
|
344
|
-
return succeed(await
|
|
685
|
+
return succeed(await this.zip(entries));
|
|
345
686
|
} catch (e: any) {
|
|
346
|
-
return fail(`HWPX
|
|
687
|
+
return fail(`HWPX 인코딩 오류: ${e?.message ?? String(e)}`);
|
|
347
688
|
}
|
|
348
689
|
}
|
|
349
690
|
}
|
|
350
691
|
|
|
351
|
-
// ───
|
|
352
|
-
|
|
353
|
-
const VERSION_XML =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
692
|
+
// ─── 상수 XML ────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
const VERSION_XML =
|
|
695
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
696
|
+
`<hv:HCFVersion xmlns:hv="http://www.owpml.org/owpml/2024/version" ` +
|
|
697
|
+
`targetApplication="WORDPROCESSING" major="5" minor="1" micro="0" buildNumber="1" ` +
|
|
698
|
+
`os="1" xmlVersion="1.4" application="Hancom Office Hangul" appVersion="11, 0, 0, 0"/>`;
|
|
699
|
+
|
|
700
|
+
const CONTAINER_XML =
|
|
701
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
702
|
+
`<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" ` +
|
|
703
|
+
`xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf">` +
|
|
704
|
+
`<ocf:rootfiles>` +
|
|
705
|
+
`<ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/>` +
|
|
706
|
+
`<ocf:rootfile full-path="Preview/PrvText.txt" media-type="text/plain"/>` +
|
|
707
|
+
`<ocf:rootfile full-path="META-INF/container.rdf" media-type="application/rdf+xml"/>` +
|
|
708
|
+
`</ocf:rootfiles></ocf:container>`;
|
|
709
|
+
|
|
710
|
+
const CONTAINER_RDF =
|
|
711
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
712
|
+
`<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">` +
|
|
713
|
+
`<rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/header.xml"/></rdf:Description>` +
|
|
714
|
+
`<rdf:Description rdf:about="Contents/header.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#HeaderFile"/></rdf:Description>` +
|
|
715
|
+
`<rdf:Description rdf:about=""><ns0:hasPart xmlns:ns0="http://www.hancom.co.kr/hwpml/2016/meta/pkg#" rdf:resource="Contents/section0.xml"/></rdf:Description>` +
|
|
716
|
+
`<rdf:Description rdf:about="Contents/section0.xml"><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#SectionFile"/></rdf:Description>` +
|
|
717
|
+
`<rdf:Description rdf:about=""><rdf:type rdf:resource="http://www.hancom.co.kr/hwpml/2016/meta/pkg#Document"/></rdf:Description>` +
|
|
718
|
+
`</rdf:RDF>`;
|
|
719
|
+
|
|
720
|
+
const MANIFEST_XML =
|
|
721
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
722
|
+
`<odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>`;
|
|
723
|
+
|
|
724
|
+
// ─── content.hpf ─────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
function buildContentHpf(ctx: HwpxCtx, meta?: DocMeta): string {
|
|
364
727
|
const title = esc(meta?.title ?? "");
|
|
365
|
-
const
|
|
728
|
+
const creator = esc(meta?.author ?? "text");
|
|
729
|
+
const subject = esc(meta?.subject ?? "text");
|
|
730
|
+
const desc = esc(meta?.desc ?? "text");
|
|
731
|
+
const keyword = esc(meta?.keywords ?? "text");
|
|
732
|
+
const created =
|
|
733
|
+
meta?.created ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
734
|
+
const modified = meta?.modified ?? created;
|
|
366
735
|
|
|
367
736
|
let items =
|
|
368
|
-
`<opf:item id="header"
|
|
737
|
+
`<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>` +
|
|
369
738
|
`<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>` +
|
|
370
|
-
`<opf:item id="settings" href="settings.xml"
|
|
739
|
+
`<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
|
|
740
|
+
|
|
371
741
|
for (const bin of ctx.bins) {
|
|
372
742
|
const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
|
|
373
743
|
const ct =
|
|
@@ -380,352 +750,923 @@ function contentHpf(ctx: HwpxCtx, meta?: any): string {
|
|
|
380
750
|
: "image/bmp";
|
|
381
751
|
items += `<opf:item id="${bin.id}" href="BinData/${bin.name}" media-type="${ct}" isEmbeded="1"/>`;
|
|
382
752
|
}
|
|
383
|
-
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>`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ─── header.xml ─────────────────────────────────────────────
|
|
387
|
-
|
|
388
|
-
function headerXml(dims: PageDims, meta: any, ctx: HwpxCtx): string {
|
|
389
|
-
// Font face definitions — register all unique fonts per language group
|
|
390
|
-
const fontCount = ctx.fonts.length || 1;
|
|
391
|
-
const langs = [
|
|
392
|
-
"HANGUL",
|
|
393
|
-
"LATIN",
|
|
394
|
-
"HANJA",
|
|
395
|
-
"JAPANESE",
|
|
396
|
-
"OTHER",
|
|
397
|
-
"SYMBOL",
|
|
398
|
-
"USER",
|
|
399
|
-
];
|
|
400
|
-
let fontFaces = `<hh:fontfaces itemCnt="${langs.length}">`;
|
|
401
|
-
for (const lang of langs) {
|
|
402
|
-
fontFaces += `<hh:fontface lang="${lang}" fontCnt="${fontCount}">`;
|
|
403
|
-
for (let fi = 0; fi < ctx.fonts.length; fi++) {
|
|
404
|
-
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>`;
|
|
405
|
-
}
|
|
406
|
-
if (ctx.fonts.length === 0) {
|
|
407
|
-
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>`;
|
|
408
|
-
}
|
|
409
|
-
fontFaces += `</hh:fontface>`;
|
|
410
|
-
}
|
|
411
|
-
fontFaces += `</hh:fontfaces>`;
|
|
412
753
|
|
|
413
|
-
|
|
754
|
+
return (
|
|
755
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
756
|
+
`<opf:package ${NS} version="" unique-identifier="" id="">` +
|
|
757
|
+
`<opf:metadata>` +
|
|
758
|
+
`<opf:title>${title}</opf:title><opf:language>ko</opf:language>` +
|
|
759
|
+
`<opf:meta name="creator" content="text">${creator}</opf:meta>` +
|
|
760
|
+
`<opf:meta name="subject" content="text">${subject}</opf:meta>` +
|
|
761
|
+
`<opf:meta name="description" content="text">${desc}</opf:meta>` +
|
|
762
|
+
`<opf:meta name="CreatedDate" content="text">${created}</opf:meta>` +
|
|
763
|
+
`<opf:meta name="ModifiedDate" content="text">${modified}</opf:meta>` +
|
|
764
|
+
`<opf:meta name="keyword" content="text">${keyword}</opf:meta>` +
|
|
765
|
+
`<opf:meta name="trackchageConfig" content="text">0</opf:meta>` +
|
|
766
|
+
`</opf:metadata>` +
|
|
767
|
+
`<opf:manifest>${items}</opf:manifest>` +
|
|
768
|
+
`<opf:spine><opf:itemref idref="header"/><opf:itemref idref="section0"/></opf:spine>` +
|
|
769
|
+
`</opf:package>`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ─── settings.xml ────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
function buildSettingsXml(): string {
|
|
776
|
+
return (
|
|
777
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
778
|
+
`<ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" ` +
|
|
779
|
+
`xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0">` +
|
|
780
|
+
`<ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/>` +
|
|
781
|
+
`<config:config-item-set name="PrintInfo">` +
|
|
782
|
+
`<config:config-item name="PrintAutoFootNote" type="boolean">false</config:config-item>` +
|
|
783
|
+
`<config:config-item name="PrintAutoHeadNote" type="boolean">false</config:config-item>` +
|
|
784
|
+
`<config:config-item name="PrintMethod" type="short">4</config:config-item>` +
|
|
785
|
+
`<config:config-item name="OverlapSize" type="short">0</config:config-item>` +
|
|
786
|
+
`<config:config-item name="PrintCropMark" type="short">0</config:config-item>` +
|
|
787
|
+
`<config:config-item name="BinderHoleType" type="short">0</config:config-item>` +
|
|
788
|
+
`<config:config-item name="ZoomX" type="short">100</config:config-item>` +
|
|
789
|
+
`<config:config-item name="ZoomY" type="short">100</config:config-item>` +
|
|
790
|
+
`</config:config-item-set>` +
|
|
791
|
+
`</ha:HWPApplicationSetting>`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function buildNumberingsXml(): string {
|
|
796
|
+
return (
|
|
797
|
+
`<hh:numberings itemCnt="1">` +
|
|
798
|
+
`<hh:numbering id="1" start="1">` +
|
|
799
|
+
`<hh:paraHead start="1" level="1" align="LEFT" ` +
|
|
800
|
+
`useInstWidth="1" autoIndent="0" widthAdjust="0" ` +
|
|
801
|
+
`textOffsetType="PERCENT" textOffset="50" ` +
|
|
802
|
+
`numFormat="BULLET" charPrIDRef="0" checkable="0"/>` +
|
|
803
|
+
`</hh:numbering></hh:numberings>`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildBulletsXml(): string {
|
|
808
|
+
return (
|
|
809
|
+
`<hh:bullets itemCnt="1">` +
|
|
810
|
+
`<hh:bullet id="1" charPrIDRef="0" start="1" numFormat="BULLET">` +
|
|
811
|
+
`<hh:paraHead level="1" numChar="•"/>` +
|
|
812
|
+
`<hh:paraHead level="2" numChar="•"/>` +
|
|
813
|
+
`<hh:paraHead level="3" numChar="•"/>` +
|
|
814
|
+
`</hh:bullet></hh:bullets>`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ─── header.xml ──────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
function buildHeaderXml(dims: PageDims, meta: DocMeta, ctx: HwpxCtx): string {
|
|
821
|
+
// 언어별 폰트 (LangFontBank → XML)
|
|
822
|
+
const fontFacesXml = ctx.fontBank.toXml();
|
|
823
|
+
|
|
824
|
+
// charPr 목록 — 언어별 폰트 ID를 fontRef에 반영 (ANYTOHWP 핵심 개선)
|
|
414
825
|
let charPrXml = "";
|
|
415
826
|
for (const cp of ctx.charPrs) {
|
|
416
827
|
const bold = cp.bold ? "<hh:bold/>" : "";
|
|
417
828
|
const italic = cp.italic ? "<hh:italic/>" : "";
|
|
418
|
-
const
|
|
419
|
-
|
|
829
|
+
const hid = cp.hangulId;
|
|
830
|
+
const lid = cp.latinId;
|
|
831
|
+
const shadeColor = cp.bg ? (cp.bg.startsWith("#") ? cp.bg : `#${cp.bg}`) : "none";
|
|
832
|
+
charPrXml +=
|
|
833
|
+
`<hh:charPr id="${cp.id}" height="${cp.height}" textColor="${cp.textColor}" ` +
|
|
834
|
+
`shadeColor="${shadeColor}" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">` +
|
|
835
|
+
`<hh:fontRef hangul="${hid}" latin="${lid}" hanja="${hid}" japanese="${hid}" other="${lid}" symbol="${lid}" user="${lid}"/>` +
|
|
836
|
+
`<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
|
|
837
|
+
`<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
|
|
838
|
+
`<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>` +
|
|
839
|
+
`<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>` +
|
|
840
|
+
bold +
|
|
841
|
+
italic +
|
|
842
|
+
`<hh:underline type="${cp.underline}" shape="SOLID" color="#000000"/>` +
|
|
843
|
+
`<hh:strikeout shape="${cp.strikeout}" color="#000000"/>` +
|
|
844
|
+
`<hh:outline type="NONE"/>` +
|
|
845
|
+
`<hh:shadow type="NONE" color="#C0C0C0" offsetX="10" offsetY="10"/>` +
|
|
846
|
+
`</hh:charPr>`;
|
|
420
847
|
}
|
|
421
848
|
|
|
422
|
-
//
|
|
849
|
+
// paraPr 목록
|
|
423
850
|
let paraPrXml = "";
|
|
424
851
|
for (const pp of ctx.paraPrs) {
|
|
425
|
-
|
|
426
|
-
|
|
852
|
+
paraPrXml +=
|
|
853
|
+
`<hh:paraPr id="${pp.id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">` +
|
|
854
|
+
`<hh:align horizontal="${pp.align}" vertical="BASELINE"/>` +
|
|
855
|
+
`<hh:heading type="NONE" idRef="0" level="0"/>` +
|
|
856
|
+
`<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>` +
|
|
857
|
+
`<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>` +
|
|
858
|
+
`<hh:margin>` +
|
|
859
|
+
`<hc:intent value="${pp.intentHwp}" unit="HWPUNIT"/>` +
|
|
860
|
+
`<hc:left value="${pp.leftHwp}" unit="HWPUNIT"/>` +
|
|
861
|
+
`<hc:right value="${pp.rightHwp}" unit="HWPUNIT"/>` +
|
|
862
|
+
`<hc:prev value="${pp.prevHwp}" unit="HWPUNIT"/>` +
|
|
863
|
+
`<hc:next value="${pp.nextHwp}" unit="HWPUNIT"/>` +
|
|
864
|
+
`</hh:margin>` +
|
|
865
|
+
`<hh:lineSpacing type="PERCENT" value="${pp.lineSpacing}" unit="HWPUNIT"/>` +
|
|
866
|
+
`<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>` +
|
|
867
|
+
`</hh:paraPr>`;
|
|
427
868
|
}
|
|
428
869
|
|
|
429
|
-
//
|
|
430
|
-
const borderFillXml = ctx.
|
|
431
|
-
|
|
432
|
-
|
|
870
|
+
// borderFill 목록 (BorderFillBank → XML)
|
|
871
|
+
const borderFillXml = ctx.borderFillBank.toXml();
|
|
872
|
+
|
|
873
|
+
// 스타일 목록
|
|
874
|
+
const stylesXml =
|
|
875
|
+
`<hh:styles itemCnt="${ctx.hwpxStyles.length}">` +
|
|
876
|
+
ctx.hwpxStyles
|
|
877
|
+
.map(
|
|
878
|
+
(s) =>
|
|
879
|
+
`<hh:style id="${s.id}" type="PARA" name="${esc(s.name)}" engName="${esc(s.engName)}" ` +
|
|
880
|
+
`paraPrIDRef="${s.paraPrIDRef}" charPrIDRef="${s.charPrIDRef}" nextStyleIDRef="0" langID="1042" lockForm="0"/>`,
|
|
881
|
+
)
|
|
882
|
+
.join("") +
|
|
883
|
+
`</hh:styles>`;
|
|
884
|
+
|
|
885
|
+
return (
|
|
886
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>` +
|
|
887
|
+
`<hh:head ${NS} version="1.2" secCnt="1">` +
|
|
888
|
+
`<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>` +
|
|
889
|
+
`<hh:refList>` +
|
|
890
|
+
fontFacesXml +
|
|
891
|
+
borderFillXml +
|
|
892
|
+
`<hh:charProperties itemCnt="${ctx.charPrs.length}">${charPrXml}</hh:charProperties>` +
|
|
893
|
+
`<hh:tabProperties itemCnt="1"><hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/></hh:tabProperties>` +
|
|
894
|
+
buildNumberingsXml() +
|
|
895
|
+
buildBulletsXml() +
|
|
896
|
+
`<hh:paraProperties itemCnt="${ctx.paraPrs.length}">${paraPrXml}</hh:paraProperties>` +
|
|
897
|
+
stylesXml +
|
|
898
|
+
`</hh:refList>` +
|
|
899
|
+
`<hh:compatibleDocument targetProgram="MS_WORD">
|
|
900
|
+
<hh:layoutCompatibility>
|
|
901
|
+
<hh:applyFontWeightToBold />
|
|
902
|
+
<hh:useInnerUnderline />
|
|
903
|
+
<hh:useLowercaseStrikeout />
|
|
904
|
+
<hh:extendLineheightToOffset />
|
|
905
|
+
<hh:treatQuotationAsLatin />
|
|
906
|
+
<hh:doNotAlignWhitespaceOnRight />
|
|
907
|
+
<hh:doNotAdjustWordInJustify />
|
|
908
|
+
<hh:baseCharUnitOnEAsian />
|
|
909
|
+
<hh:baseCharUnitOfIndentOnFirstChar />
|
|
910
|
+
<hh:adjustLineheightToFont />
|
|
911
|
+
<hh:adjustBaselineInFixedLinespacing />
|
|
912
|
+
<hh:applyPrevspacingBeneathObject />
|
|
913
|
+
<hh:applyNextspacingOfLastPara />
|
|
914
|
+
<hh:adjustParaBorderfillToSpacing />
|
|
915
|
+
<hh:connectParaBorderfillOfEqualBorder />
|
|
916
|
+
<hh:adjustParaBorderOffsetWithBorder />
|
|
917
|
+
<hh:extendLineheightToParaBorderOffset />
|
|
918
|
+
<hh:applyParaBorderToOutside />
|
|
919
|
+
<hh:applyMinColumnWidthTo1mm />
|
|
920
|
+
<hh:applyTabPosBasedOnSegment />
|
|
921
|
+
<hh:breakTabOverLine />
|
|
922
|
+
<hh:adjustVertPosOfLine />
|
|
923
|
+
<hh:doNotAlignLastForbidden />
|
|
924
|
+
<hh:adjustMarginFromAdjustLineheight />
|
|
925
|
+
<hh:baseLineSpacingOnLineGrid />
|
|
926
|
+
<hh:applyCharSpacingToCharGrid />
|
|
927
|
+
<hh:doNotApplyGridInHeaderFooter />
|
|
928
|
+
<hh:applyExtendHeaderFooterEachSection />
|
|
929
|
+
<hh:doNotApplyLinegridAtNoLinespacing />
|
|
930
|
+
<hh:doNotAdjustEmptyAnchorLine />
|
|
931
|
+
<hh:overlapBothAllowOverlap />
|
|
932
|
+
<hh:extendVertLimitToPageMargins />
|
|
933
|
+
<hh:doNotHoldAnchorOfTable />
|
|
934
|
+
<hh:doNotFormattingAtBeneathAnchor />
|
|
935
|
+
<hh:adjustBaselineOfObjectToBottom />
|
|
936
|
+
</hh:layoutCompatibility>
|
|
937
|
+
</hh:compatibleDocument>` +
|
|
938
|
+
`<hh:docOption><hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/></hh:docOption>` +
|
|
939
|
+
`<hh:trackchageConfig flags="56"/>` +
|
|
940
|
+
`</hh:head>`
|
|
941
|
+
);
|
|
433
942
|
}
|
|
434
943
|
|
|
435
|
-
// ─── section0.xml
|
|
944
|
+
// ─── section0.xml ────────────────────────────────────────────
|
|
436
945
|
|
|
437
|
-
function
|
|
946
|
+
function buildHeaderFooterRunXml(
|
|
947
|
+
sheet: SheetNode,
|
|
948
|
+
dims: PageDims,
|
|
949
|
+
ctx: HwpxCtx,
|
|
950
|
+
): string {
|
|
951
|
+
const headers = sheet.headers || {};
|
|
952
|
+
const footers = sheet.footers || {};
|
|
953
|
+
const hasAny = Object.keys(headers).length > 0 || Object.keys(footers).length > 0;
|
|
954
|
+
if (!hasAny) return "";
|
|
955
|
+
|
|
956
|
+
const availW = ctx.availableWidth;
|
|
957
|
+
const mtHwp = Metric.ptToHwp(dims.mt);
|
|
958
|
+
const mbHwp = Metric.ptToHwp(dims.mb);
|
|
959
|
+
const headerZoneH = dims.headerPt ? Math.max(100, mtHwp - Metric.ptToHwp(dims.headerPt)) : 3600;
|
|
960
|
+
const footerZoneH = dims.footerPt ? Math.max(100, mbHwp - Metric.ptToHwp(dims.footerPt)) : 3600;
|
|
961
|
+
|
|
962
|
+
let inner = "";
|
|
963
|
+
|
|
964
|
+
// 1. 첫 페이지 숨김 설정 (first 헤더/푸터가 있으면 활성화)
|
|
965
|
+
const hideFirst = !!(headers.first || footers.first);
|
|
966
|
+
inner += `<hp:ctrl><hp:pageHiding hideHeader="${hideFirst ? 1 : 0}" hideFooter="${hideFirst ? 1 : 0}" hideMasterPage="0" hideBorder="0" hideFill="0" hidePageNum="0"/></hp:ctrl>`;
|
|
967
|
+
|
|
968
|
+
// 2. 헤더들 생성
|
|
969
|
+
for (const [type, paras] of Object.entries(headers)) {
|
|
970
|
+
const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
|
|
971
|
+
const savedId = ctx.nextElementId;
|
|
972
|
+
ctx.nextElementId = 0;
|
|
973
|
+
const parasXml = paras.map((p) => encodeParaPositioned(p, ctx, 0, "", availW).xml).join("");
|
|
974
|
+
ctx.nextElementId = savedId;
|
|
975
|
+
inner +=
|
|
976
|
+
`<hp:ctrl>` +
|
|
977
|
+
`<hp:header id="1" applyPageType="${applyPageType}">` +
|
|
978
|
+
`<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="TOP" ` +
|
|
979
|
+
`linkListIDRef="0" linkListNextIDRef="0" textWidth="${availW}" textHeight="${headerZoneH}" ` +
|
|
980
|
+
`hasTextRef="0" hasNumRef="0">` +
|
|
981
|
+
parasXml +
|
|
982
|
+
`</hp:subList>` +
|
|
983
|
+
`</hp:header>` +
|
|
984
|
+
`</hp:ctrl>`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 3. 푸터들 생성
|
|
988
|
+
for (const [type, paras] of Object.entries(footers)) {
|
|
989
|
+
const applyPageType = type === "even" ? "EVEN" : (type === "default" || type === "first" ? "BOTH" : "ODD");
|
|
990
|
+
const savedId = ctx.nextElementId;
|
|
991
|
+
ctx.nextElementId = 0;
|
|
992
|
+
const parasXml = paras.map((p) => encodeParaPositioned(p, ctx, 0, "", availW).xml).join("");
|
|
993
|
+
ctx.nextElementId = savedId;
|
|
994
|
+
inner +=
|
|
995
|
+
`<hp:ctrl>` +
|
|
996
|
+
`<hp:footer id="2" applyPageType="${applyPageType}">` +
|
|
997
|
+
`<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="BOTTOM" ` +
|
|
998
|
+
`linkListIDRef="0" linkListNextIDRef="0" textWidth="${availW}" textHeight="${footerZoneH}" ` +
|
|
999
|
+
`hasTextRef="0" hasNumRef="0">` +
|
|
1000
|
+
parasXml +
|
|
1001
|
+
`</hp:subList>` +
|
|
1002
|
+
`</hp:footer>` +
|
|
1003
|
+
`</hp:ctrl>`;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return `<hp:run charPrIDRef="0" charTcId="0">${inner}</hp:run>`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function buildSectionXml(
|
|
438
1010
|
sheet: SheetNode | undefined,
|
|
439
1011
|
dims: PageDims,
|
|
440
1012
|
ctx: HwpxCtx,
|
|
441
1013
|
): string {
|
|
1014
|
+
const secPrXml = buildSecPrXml(dims);
|
|
442
1015
|
const kids = sheet?.kids ?? [];
|
|
1016
|
+
const hfRunXml = sheet ? buildHeaderFooterRunXml(sheet, dims, ctx) : "";
|
|
443
1017
|
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
1018
|
+
// 가용 너비 계산 (HWPUNIT)
|
|
1019
|
+
const availWidth = Math.max(
|
|
1020
|
+
1000,
|
|
1021
|
+
Metric.ptToHwp(dims.wPt) - Metric.ptToHwp(dims.ml) - Metric.ptToHwp(dims.mr),
|
|
1022
|
+
);
|
|
1023
|
+
ctx.availableWidth = availWidth;
|
|
447
1024
|
|
|
448
1025
|
let contentXml = "";
|
|
449
|
-
let
|
|
1026
|
+
let vertPos = 0;
|
|
450
1027
|
|
|
451
|
-
|
|
1028
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1029
|
+
const kid = kids[i];
|
|
1030
|
+
const isFirst = i === 0;
|
|
1031
|
+
const curSecPr = isFirst ? secPrXml : "";
|
|
1032
|
+
const curHfRun = isFirst ? hfRunXml : "";
|
|
452
1033
|
|
|
453
|
-
for (const kid of kids) {
|
|
454
1034
|
if (kid.tag === "para") {
|
|
455
|
-
|
|
456
|
-
|
|
1035
|
+
const { xml, nextVertPos } = encodeParaPositioned(
|
|
1036
|
+
kid,
|
|
1037
|
+
ctx,
|
|
1038
|
+
vertPos,
|
|
1039
|
+
curSecPr,
|
|
1040
|
+
availWidth,
|
|
1041
|
+
curHfRun,
|
|
1042
|
+
);
|
|
1043
|
+
contentXml += xml;
|
|
1044
|
+
vertPos = nextVertPos;
|
|
457
1045
|
} else if (kid.tag === "grid") {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
contentXml +=
|
|
466
|
-
|
|
1046
|
+
const { xml, nextVertPos } = encodeGridPositioned(
|
|
1047
|
+
kid,
|
|
1048
|
+
ctx,
|
|
1049
|
+
vertPos,
|
|
1050
|
+
curSecPr,
|
|
1051
|
+
curHfRun,
|
|
1052
|
+
);
|
|
1053
|
+
contentXml += xml;
|
|
1054
|
+
vertPos = nextVertPos;
|
|
467
1055
|
}
|
|
468
1056
|
}
|
|
469
1057
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
1058
|
+
if (kids.length === 0) {
|
|
1059
|
+
// 빈 문서 — 최소 단락 1개 필수
|
|
1060
|
+
const fs = 1000;
|
|
1061
|
+
const vs = 1600;
|
|
1062
|
+
const { xml: linesegXml } = buildLinesegarray(" ", 0, fs, vs / (fs / 100), availWidth);
|
|
1063
|
+
contentXml =
|
|
1064
|
+
`<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
|
|
1065
|
+
secPrXml +
|
|
1066
|
+
hfRunXml +
|
|
1067
|
+
`<hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run>` +
|
|
1068
|
+
linesegXml +
|
|
1069
|
+
`</hp:p>`;
|
|
473
1070
|
}
|
|
474
1071
|
|
|
475
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS}>${contentXml}</hs:sec>`;
|
|
1072
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec ${NS} xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">${contentXml}</hs:sec>`;
|
|
476
1073
|
}
|
|
477
1074
|
|
|
478
|
-
function
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1075
|
+
function buildSecPrXml(dims: PageDims): string {
|
|
1076
|
+
const wHwp = Metric.ptToHwp(dims.wPt);
|
|
1077
|
+
const hHwp = Metric.ptToHwp(dims.hPt);
|
|
1078
|
+
const ml = Metric.ptToHwp(dims.ml);
|
|
1079
|
+
const mr = Metric.ptToHwp(dims.mr);
|
|
1080
|
+
const mt = Metric.ptToHwp(dims.mt);
|
|
1081
|
+
const mb = Metric.ptToHwp(dims.mb);
|
|
1082
|
+
// HWPX margin header/footer = header/footer ZONE HEIGHT (not distance from paper edge)
|
|
1083
|
+
// = top_hwp - header_from_top_hwp (and bottom_hwp - footer_from_bottom_hwp)
|
|
1084
|
+
const headerZone = dims.headerPt
|
|
1085
|
+
? Math.max(0, mt - Metric.ptToHwp(dims.headerPt))
|
|
1086
|
+
: 0;
|
|
1087
|
+
const footerZone = dims.footerPt
|
|
1088
|
+
? Math.max(0, mb - Metric.ptToHwp(dims.footerPt))
|
|
1089
|
+
: 0;
|
|
1090
|
+
|
|
1091
|
+
const pageBorderFill =
|
|
1092
|
+
`<hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
|
|
1093
|
+
`<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
|
|
1094
|
+
`</hp:pageBorderFill>` +
|
|
1095
|
+
`<hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
|
|
1096
|
+
`<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
|
|
1097
|
+
`</hp:pageBorderFill>` +
|
|
1098
|
+
`<hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER">` +
|
|
1099
|
+
`<hp:offset left="1417" right="1417" top="1417" bottom="1417"/>` +
|
|
1100
|
+
`</hp:pageBorderFill>`;
|
|
1101
|
+
|
|
1102
|
+
return (
|
|
1103
|
+
`<hp:secPr id="0" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">` +
|
|
1104
|
+
`<hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/>` +
|
|
1105
|
+
`<hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
|
|
1106
|
+
`<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
|
|
1107
|
+
`<hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>` +
|
|
1108
|
+
`<hp:pagePr landscape="${dims.wPt >= dims.hPt ? 'WIDELY' : 'NARROWLY'}" width="${wHwp}" height="${hHwp}" gutterType="LEFT_ONLY">` +
|
|
1109
|
+
`<hp:margin header="${headerZone}" footer="${footerZone}" gutter="0" left="${ml}" right="${mr}" top="${mt}" bottom="${mb}"/>` +
|
|
1110
|
+
`</hp:pagePr>` +
|
|
1111
|
+
`<hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/>` +
|
|
1112
|
+
`<hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
|
|
1113
|
+
`<hp:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
|
|
1114
|
+
`<hp:noteSpacing betweenNotes="283" belowLine="0" aboveLine="1000"/>` +
|
|
1115
|
+
`<hp:numbering type="CONTINUOUS" newNum="1"/>` +
|
|
1116
|
+
`<hp:placement place="EACH_COLUMN" beneathText="0"/>` +
|
|
1117
|
+
`</hp:footNotePr>` +
|
|
1118
|
+
`<hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar="" supscript="1"/>` +
|
|
1119
|
+
`<hp:noteLine length="-1" type="SOLID" width="0.25 mm" color="#000000"/>` +
|
|
1120
|
+
`<hp:noteSpacing betweenNotes="0" belowLine="0" aboveLine="1000"/>` +
|
|
1121
|
+
`<hp:numbering type="CONTINUOUS" newNum="1"/>` +
|
|
1122
|
+
`<hp:placement place="END_OF_DOCUMENT" beneathText="0"/>` +
|
|
1123
|
+
`</hp:endNotePr>` +
|
|
1124
|
+
pageBorderFill +
|
|
1125
|
+
`</hp:secPr>`
|
|
507
1126
|
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ─── 줄 정보 XML (linesegarray) ──────────────────────────────
|
|
1130
|
+
// 가이드 준수: 실제 시각적 줄 단위로 lineseg 생성
|
|
1131
|
+
|
|
1132
|
+
function buildLinesegarray(
|
|
1133
|
+
text: string,
|
|
1134
|
+
vertPosStart: number,
|
|
1135
|
+
fontSize: number,
|
|
1136
|
+
lineSpacingPct: number,
|
|
1137
|
+
horzSize: number,
|
|
1138
|
+
): { xml: string; totalHeight: number } {
|
|
1139
|
+
const vertsizeLine = Math.round((fontSize * lineSpacingPct) / 100);
|
|
1140
|
+
const spacing = vertsizeLine - fontSize;
|
|
1141
|
+
const baseline = Math.round(fontSize * 0.83);
|
|
1142
|
+
|
|
1143
|
+
// 글자 너비 추정 (영문 0.47, 한글 1.0)
|
|
1144
|
+
const isKorean = /[\uAC00-\uD7A3\u3131-\u318E]/.test(text);
|
|
1145
|
+
const charW = isKorean ? fontSize : Math.round(fontSize * 0.47);
|
|
1146
|
+
const charsPerLine = Math.max(1, Math.floor(horzSize / charW));
|
|
1147
|
+
const lineCount = text.length === 0 ? 1 : Math.ceil(text.length / charsPerLine);
|
|
1148
|
+
|
|
1149
|
+
// 성능 최적화: 문자열 병합 대신 배열 수집 후 join 사용 (O(n²) → O(n))
|
|
1150
|
+
const linesegParts: string[] = [];
|
|
1151
|
+
for (let i = 0; i < lineCount; i++) {
|
|
1152
|
+
const flags = i === 0 ? LINESEG_FLAGS_FIRST : LINESEG_FLAGS_OTHER;
|
|
1153
|
+
linesegParts.push(
|
|
1154
|
+
`<hp:lineseg textpos="${i * charsPerLine}" ` +
|
|
1155
|
+
`vertpos="${vertPosStart + i * vertsizeLine}" ` +
|
|
1156
|
+
`vertsize="${vertsizeLine}" textheight="${fontSize}" ` +
|
|
1157
|
+
`baseline="${baseline}" spacing="${spacing}" ` +
|
|
1158
|
+
`horzpos="0" horzsize="${horzSize}" ` +
|
|
1159
|
+
`flags="${flags}"/>`,
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
xml: `<hp:linesegarray>${linesegParts.join("")}</hp:linesegarray>`,
|
|
1165
|
+
totalHeight: lineCount * vertsizeLine,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
508
1168
|
|
|
509
|
-
|
|
1169
|
+
/** 단락에서 순수 텍스트 추출 (줄바꿈 계산용) */
|
|
1170
|
+
function extractParaText(para: ParaNode): string {
|
|
1171
|
+
let text = "";
|
|
1172
|
+
const walk = (kids: any[]) => {
|
|
1173
|
+
for (const k of kids) {
|
|
1174
|
+
if (k.tag === "span") {
|
|
1175
|
+
for (const c of k.kids) if (c.tag === "txt") text += c.content;
|
|
1176
|
+
} else if (k.tag === "link") {
|
|
1177
|
+
walk(k.kids);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
walk(para.kids);
|
|
1182
|
+
return text;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function fontSizeForPara(para: ParaNode, ctx: HwpxCtx): number {
|
|
510
1186
|
for (const kid of para.kids) {
|
|
511
1187
|
if (kid.tag === "span") {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
runs += encodeImage(kid, ctx);
|
|
1188
|
+
const id = ctx.charPrMap.get(charPrKey(kid.props));
|
|
1189
|
+
if (id !== undefined && ctx.charPrs[id]) return ctx.charPrs[id].height;
|
|
515
1190
|
}
|
|
516
1191
|
}
|
|
1192
|
+
return 1000; // 기본 10pt
|
|
1193
|
+
}
|
|
517
1194
|
|
|
518
|
-
|
|
519
|
-
if (runs === "") {
|
|
520
|
-
runs = `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
|
|
521
|
-
}
|
|
1195
|
+
// ─── 단락 인코딩 ─────────────────────────────────────────────
|
|
522
1196
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1197
|
+
function encodeParaPositioned(
|
|
1198
|
+
para: ParaNode,
|
|
1199
|
+
ctx: HwpxCtx,
|
|
1200
|
+
vertPos: number,
|
|
1201
|
+
secPr = "",
|
|
1202
|
+
availWidth?: number,
|
|
1203
|
+
hfRun = "",
|
|
1204
|
+
): { xml: string; nextVertPos: number } {
|
|
1205
|
+
// ✅ 표(Grid)를 포함하는 단락인지 확인
|
|
1206
|
+
const gridKid = para.kids.find((k): k is GridNode => k.tag === "grid");
|
|
1207
|
+
if (gridKid) {
|
|
1208
|
+
return encodeTablePara(para, gridKid, ctx, vertPos, secPr, hfRun);
|
|
533
1209
|
}
|
|
534
1210
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1211
|
+
const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props)) ?? 0;
|
|
1212
|
+
const styleIDRef = para.props.styleId
|
|
1213
|
+
? (ctx.styleIdToHwpxId.get(para.props.styleId) ?? 0)
|
|
1214
|
+
: 0;
|
|
1215
|
+
const fontSize = fontSizeForPara(para, ctx);
|
|
539
1216
|
const paraPr = ctx.paraPrs[paraPrId];
|
|
540
1217
|
const lineSpacing = paraPr?.lineSpacing ?? 160;
|
|
541
|
-
const vertsize = fontSize;
|
|
542
|
-
const textheight = fontSize;
|
|
543
|
-
const baseline = Math.round(fontSize * 0.85);
|
|
544
1218
|
const spacing = Math.max(0, Math.round(fontSize * (lineSpacing / 100 - 1)));
|
|
545
|
-
|
|
546
|
-
const
|
|
1219
|
+
let vertSize = fontSize + spacing;
|
|
1220
|
+
const horzSize = availWidth ?? ctx.availableWidth;
|
|
1221
|
+
|
|
1222
|
+
// 코드 블록 감지 (Courier 폰트 또는 styleId "code")
|
|
1223
|
+
const isCourierFont = (kids: ParaNode["kids"]): boolean =>
|
|
1224
|
+
kids.some(
|
|
1225
|
+
(k) =>
|
|
1226
|
+
(k.tag === "span" && k.props.font?.toLowerCase().includes("courier")) ||
|
|
1227
|
+
(k.tag === "link" &&
|
|
1228
|
+
isCourierFont((k as LinkNode).kids as ParaNode["kids"])),
|
|
1229
|
+
);
|
|
1230
|
+
const isCode =
|
|
1231
|
+
availWidth === undefined &&
|
|
1232
|
+
(para.props.styleId?.toLowerCase().includes("code") ||
|
|
1233
|
+
isCourierFont(para.kids));
|
|
1234
|
+
|
|
1235
|
+
if (isCode)
|
|
1236
|
+
return encodeCodeBlockPositioned(
|
|
1237
|
+
para,
|
|
1238
|
+
ctx,
|
|
1239
|
+
vertPos,
|
|
1240
|
+
secPr,
|
|
1241
|
+
fontSize,
|
|
1242
|
+
spacing,
|
|
1243
|
+
vertSize,
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
let runsXml = encodeParaKids(para.kids, ctx);
|
|
1247
|
+
if (!runsXml) runsXml = `<hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run>`;
|
|
1248
|
+
|
|
1249
|
+
const paraText = extractParaText(para);
|
|
1250
|
+
const { xml: linesegXml, totalHeight } = buildLinesegarray(
|
|
1251
|
+
paraText,
|
|
1252
|
+
vertPos,
|
|
1253
|
+
fontSize,
|
|
1254
|
+
lineSpacing,
|
|
1255
|
+
horzSize,
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
const hasPageBreak = para.kids.some(
|
|
1259
|
+
(k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1262
|
+
const xml =
|
|
1263
|
+
`<hp:p id="${ctx.nextElementId++}" paraPrIDRef="${paraPrId}" styleIDRef="${styleIDRef}" ` +
|
|
1264
|
+
`pageBreak="${hasPageBreak ? 1 : 0}" columnBreak="0" merged="0" paraTcId="0">` +
|
|
1265
|
+
secPr +
|
|
1266
|
+
hfRun +
|
|
1267
|
+
runsXml +
|
|
1268
|
+
linesegXml +
|
|
1269
|
+
`</hp:p>`;
|
|
547
1270
|
|
|
548
|
-
return
|
|
1271
|
+
return { xml, nextVertPos: vertPos + totalHeight };
|
|
549
1272
|
}
|
|
550
1273
|
|
|
551
|
-
/**
|
|
552
|
-
function
|
|
553
|
-
|
|
1274
|
+
/** ✅ 가이드 준수: 표를 포함하는 단락 인코딩 */
|
|
1275
|
+
function encodeTablePara(
|
|
1276
|
+
para: ParaNode,
|
|
1277
|
+
grid: GridNode,
|
|
1278
|
+
ctx: HwpxCtx,
|
|
1279
|
+
vertPos: number,
|
|
1280
|
+
secPr: string,
|
|
1281
|
+
hfRun: string,
|
|
1282
|
+
): { xml: string; nextVertPos: number } {
|
|
1283
|
+
const paraPrId = ctx.paraPrMap.get(paraPrKey(para.props)) ?? 0;
|
|
1284
|
+
|
|
1285
|
+
// 표 알맹이 생성 (기존 로직 재사용)
|
|
1286
|
+
const { xml: gridXml, height: tblHeight } = buildGridXml(grid, ctx);
|
|
1287
|
+
|
|
1288
|
+
// 가이드: 표 단락의 lineseg는 1개여야 하고, vertsize는 표 전체 높이여야 함
|
|
1289
|
+
const fontSize = 1000;
|
|
1290
|
+
const totalHeight = Math.max(1600, tblHeight);
|
|
1291
|
+
const baseline = 850;
|
|
1292
|
+
const spacing = Math.max(0, totalHeight - fontSize);
|
|
1293
|
+
|
|
1294
|
+
const linesegXml =
|
|
1295
|
+
`<hp:linesegarray>` +
|
|
1296
|
+
`<hp:lineseg textpos="0" vertpos="${vertPos}" vertsize="${totalHeight}" ` +
|
|
1297
|
+
`textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
|
|
1298
|
+
`horzpos="0" horzsize="${ctx.availableWidth}" flags="1441792"/>` +
|
|
1299
|
+
`</hp:linesegarray>`;
|
|
1300
|
+
|
|
1301
|
+
const xml =
|
|
1302
|
+
`<hp:p id="${ctx.nextElementId++}" paraPrIDRef="${paraPrId}" styleIDRef="0" ` +
|
|
1303
|
+
`pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
|
|
1304
|
+
secPr +
|
|
1305
|
+
gridXml +
|
|
1306
|
+
hfRun +
|
|
1307
|
+
linesegXml +
|
|
1308
|
+
`</hp:p>`;
|
|
1309
|
+
|
|
1310
|
+
return { xml, nextVertPos: vertPos + totalHeight };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function encodeCodeBlockPositioned(
|
|
1314
|
+
para: ParaNode,
|
|
1315
|
+
ctx: HwpxCtx,
|
|
1316
|
+
vertPos: number,
|
|
1317
|
+
secPr: string,
|
|
1318
|
+
fontSize: number,
|
|
1319
|
+
spacing: number,
|
|
1320
|
+
vertSize: number,
|
|
1321
|
+
): { xml: string; nextVertPos: number } {
|
|
1322
|
+
const codeBfId = ctx.borderFillBank.addUniform(
|
|
1323
|
+
{ kind: "solid", pt: 0.5, color: "aaaaaa" },
|
|
1324
|
+
"f4f4f4",
|
|
1325
|
+
);
|
|
1326
|
+
const cellW = ctx.availableWidth;
|
|
1327
|
+
const innerW = Math.max(cellW - 510, 100);
|
|
1328
|
+
const subListId = ctx.nextElementId++;
|
|
1329
|
+
const { xml: innerXml } = encodeParaPositioned(para, ctx, 0, "", innerW);
|
|
1330
|
+
|
|
1331
|
+
const paraText = extractParaText(para);
|
|
1332
|
+
const { xml: linesegXml, totalHeight } = buildLinesegarray(
|
|
1333
|
+
paraText,
|
|
1334
|
+
vertPos,
|
|
1335
|
+
fontSize,
|
|
1336
|
+
160, // 코드 블록 기본 줄간격 160%
|
|
1337
|
+
ctx.availableWidth,
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const xml =
|
|
1341
|
+
`<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" paraTcId="0">` +
|
|
1342
|
+
secPr +
|
|
1343
|
+
`<hp:run charPrIDRef="0" charTcId="0">` +
|
|
1344
|
+
`<hp:tbl id="${ctx.nextElementId++}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="NONE" rowCnt="1" colCnt="1" cellSpacing="0" borderFillIDRef="${codeBfId}" noAdjust="0">` +
|
|
1345
|
+
`<hp:sz width="${cellW}" widthRelTo="ABSOLUTE" height="0" heightRelTo="ABSOLUTE" protect="0"/>` +
|
|
1346
|
+
`<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
|
|
1347
|
+
`<hp:outMargin left="138" right="138" top="138" bottom="138"/>` +
|
|
1348
|
+
`<hp:inMargin left="138" right="138" top="138" bottom="138"/>` +
|
|
1349
|
+
`<hp:tr><hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${codeBfId}">` +
|
|
1350
|
+
`<hp:subList id="${subListId}" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">` +
|
|
1351
|
+
innerXml +
|
|
1352
|
+
`</hp:subList>` +
|
|
1353
|
+
`<hp:cellAddr colAddr="0" rowAddr="0"/>` +
|
|
1354
|
+
`<hp:cellSpan colSpan="1" rowSpan="1"/>` +
|
|
1355
|
+
`<hp:cellSz width="${cellW}" height="0"/>` +
|
|
1356
|
+
`<hp:cellMargin left="283" right="283" top="141" bottom="141"/>` +
|
|
1357
|
+
`</hp:tc></hp:tr></hp:tbl><hp:t xml:space="preserve"> </hp:t></hp:run>` +
|
|
1358
|
+
linesegXml +
|
|
1359
|
+
`</hp:p>`;
|
|
1360
|
+
|
|
1361
|
+
return { xml, nextVertPos: vertPos + totalHeight };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function encodeParaKids(kids: ParaNode["kids"], ctx: HwpxCtx): string {
|
|
1365
|
+
let xml = "";
|
|
1366
|
+
let currentRunCharPrId: number | null = null;
|
|
1367
|
+
let currentRunContent = "";
|
|
1368
|
+
|
|
1369
|
+
const flushRun = () => {
|
|
1370
|
+
if (currentRunCharPrId !== null) {
|
|
1371
|
+
// 내용이 없더라도 빈 hp:t를 생성하여 '텍스트 없음' 오류 방지
|
|
1372
|
+
const content = currentRunContent || `<hp:t xml:space="preserve"> </hp:t>`;
|
|
1373
|
+
xml += `<hp:run charPrIDRef="${currentRunCharPrId}" charTcId="0">${content}</hp:run>`;
|
|
1374
|
+
}
|
|
1375
|
+
currentRunCharPrId = null;
|
|
1376
|
+
currentRunContent = "";
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
for (const kid of kids) {
|
|
554
1380
|
if (kid.tag === "span") {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
1381
|
+
const span = kid as SpanNode;
|
|
1382
|
+
const charPrId = ctx.charPrMap.get(charPrKey(span.props)) ?? 0;
|
|
1383
|
+
|
|
1384
|
+
if (currentRunCharPrId !== null && currentRunCharPrId !== charPrId) {
|
|
1385
|
+
flushRun();
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
currentRunCharPrId = charPrId;
|
|
1389
|
+
currentRunContent += encodeRunInner(span);
|
|
1390
|
+
}
|
|
1391
|
+
else if (kid.tag === "link") {
|
|
1392
|
+
const link = kid as LinkNode;
|
|
1393
|
+
// 링크의 첫 번째 span 스타일을 기준으로 함
|
|
1394
|
+
let charPrId = 0;
|
|
1395
|
+
if (link.kids.length > 0 && link.kids[0].tag === "span") {
|
|
1396
|
+
charPrId = ctx.charPrMap.get(charPrKey((link.kids[0] as SpanNode).props)) ?? 0;
|
|
558
1397
|
}
|
|
1398
|
+
|
|
1399
|
+
if (currentRunCharPrId !== null && currentRunCharPrId !== charPrId) {
|
|
1400
|
+
flushRun();
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
currentRunCharPrId = charPrId;
|
|
1404
|
+
currentRunContent += encodeLinkInner(link, ctx);
|
|
1405
|
+
}
|
|
1406
|
+
else if (kid.tag === "img") {
|
|
1407
|
+
flushRun();
|
|
1408
|
+
xml += encodeImgWrapped(kid, ctx);
|
|
559
1409
|
}
|
|
560
1410
|
}
|
|
561
|
-
return 1000; // default 10pt
|
|
562
|
-
}
|
|
563
1411
|
|
|
564
|
-
|
|
565
|
-
|
|
1412
|
+
flushRun();
|
|
1413
|
+
return xml;
|
|
1414
|
+
}
|
|
566
1415
|
|
|
567
|
-
|
|
1416
|
+
/** hp:run 내부의 태그들만 생성 (span용) */
|
|
1417
|
+
function encodeRunInner(span: SpanNode): string {
|
|
1418
|
+
let xml = "";
|
|
568
1419
|
for (const kid of span.kids) {
|
|
569
1420
|
if (kid.tag === "txt") {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
} else {
|
|
573
|
-
parts.push(`<hp:t></hp:t>`);
|
|
574
|
-
}
|
|
575
|
-
} else if (kid.tag === "pagenum") {
|
|
576
|
-
const fmt =
|
|
577
|
-
kid.format === "roman"
|
|
578
|
-
? "ROMAN_LOWER"
|
|
579
|
-
: kid.format === "romanCaps"
|
|
580
|
-
? "ROMAN_UPPER"
|
|
581
|
-
: "DIGIT";
|
|
582
|
-
parts.push(`<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`);
|
|
1421
|
+
const content = esc(kid.content);
|
|
1422
|
+
if (content) xml += `<hp:t xml:space="preserve">${content}</hp:t>`;
|
|
583
1423
|
} else if (kid.tag === "br") {
|
|
584
|
-
|
|
1424
|
+
xml += `<hp:t xml:space="preserve">\n</hp:t>`;
|
|
1425
|
+
} else if (kid.tag === "pagenum") {
|
|
1426
|
+
const fmt = (kid as any).format === "roman" ? "ROMAN_LOWER"
|
|
1427
|
+
: (kid as any).format === "romanCaps" ? "ROMAN_UPPER" : "DIGIT";
|
|
1428
|
+
xml += `<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`;
|
|
585
1429
|
}
|
|
586
|
-
// pb is handled at paragraph level (pageBreak attribute), skip here
|
|
587
1430
|
}
|
|
1431
|
+
return xml;
|
|
1432
|
+
}
|
|
588
1433
|
|
|
589
|
-
|
|
1434
|
+
/** hp:run 내부의 태그들만 생성 (link용) */
|
|
1435
|
+
function encodeLinkInner(link: LinkNode, ctx: HwpxCtx): string {
|
|
1436
|
+
const fieldId = 600000000 + (ctx.nextElementId++ % 100000000);
|
|
1437
|
+
const instanceId = 2100000000 + (ctx.nextElementId++ % 100000000);
|
|
1438
|
+
const url = link.href;
|
|
1439
|
+
|
|
1440
|
+
let xml = `<hp:ctrl>` +
|
|
1441
|
+
`<hp:fieldBegin id="${instanceId}" type="HYPERLINK" name="" editable="0" dirty="1" zorder="-1" fieldid="${fieldId}">` +
|
|
1442
|
+
`<hp:parameters cnt="6" name="">` +
|
|
1443
|
+
`<hp:integerParam name="Prop">0</hp:integerParam>` +
|
|
1444
|
+
`<hp:stringParam name="Command">${esc(url.replace(/:/g, "\\:"))};1;5;-1;</hp:stringParam>` +
|
|
1445
|
+
`<hp:stringParam name="Path">${esc(url)}</hp:stringParam>` +
|
|
1446
|
+
`<hp:stringParam name="Category">HWPHYPERLINK_TYPE_URL</hp:stringParam>` +
|
|
1447
|
+
`<hp:stringParam name="TargetType">HWPHYPERLINK_TARGET_HYPERLINK</hp:stringParam>` +
|
|
1448
|
+
`<hp:stringParam name="DocOpenType">HWPHYPERLINK_JUMP_DONTCARE</hp:stringParam>` +
|
|
1449
|
+
`</hp:parameters>` +
|
|
1450
|
+
`</hp:fieldBegin>` +
|
|
1451
|
+
`</hp:ctrl>`;
|
|
1452
|
+
|
|
1453
|
+
for (const kid of link.kids) {
|
|
1454
|
+
if (kid.tag === "span") {
|
|
1455
|
+
xml += encodeRunInner(kid as SpanNode);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
xml += `<hp:ctrl><hp:fieldEnd beginIDRef="${instanceId}"/></hp:ctrl>`;
|
|
1460
|
+
return xml;
|
|
590
1461
|
}
|
|
591
1462
|
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
// textFlow 매핑 (wrap 타입별)
|
|
603
|
-
const TEXT_FLOW_HWPX: Record<string, string> = {
|
|
604
|
-
inline: 'BOTH_SIDES',
|
|
605
|
-
square: 'LARGEST_ONLY',
|
|
606
|
-
tight: 'BOTH_SIDES',
|
|
607
|
-
through: 'BOTH_SIDES',
|
|
608
|
-
none: 'BOTH_SIDES',
|
|
609
|
-
behind: 'BOTH_SIDES',
|
|
610
|
-
front: 'BOTH_SIDES',
|
|
611
|
-
};
|
|
612
|
-
const HORZ_RELTO_HWPX: Record<string, string> = {
|
|
613
|
-
para: 'PARA', margin: 'MARGIN', page: 'PAPER', column: 'COLUMN',
|
|
1463
|
+
// ─── 이미지 인코딩 (ANYTOHWP 영감: 픽셀 치수 추출) ──────────
|
|
1464
|
+
|
|
1465
|
+
const WRAP_MAP: Record<string, string> = {
|
|
1466
|
+
inline: "TOP_AND_BOTTOM",
|
|
1467
|
+
square: "SQUARE",
|
|
1468
|
+
tight: "BOTH_SIDES",
|
|
1469
|
+
through: "BOTH_SIDES",
|
|
1470
|
+
none: "FRONT_TEXT",
|
|
1471
|
+
behind: "BEHIND_TEXT",
|
|
1472
|
+
front: "FRONT_TEXT",
|
|
614
1473
|
};
|
|
615
|
-
const
|
|
616
|
-
|
|
1474
|
+
const FLOW_MAP: Record<string, string> = {
|
|
1475
|
+
inline: "BOTH_SIDES",
|
|
1476
|
+
square: "LARGEST_ONLY",
|
|
1477
|
+
tight: "BOTH_SIDES",
|
|
1478
|
+
through: "BOTH_SIDES",
|
|
1479
|
+
none: "BOTH_SIDES",
|
|
1480
|
+
behind: "BOTH_SIDES",
|
|
1481
|
+
front: "BOTH_SIDES",
|
|
617
1482
|
};
|
|
618
1483
|
|
|
619
1484
|
function encodeImage(img: ImgNode, ctx: HwpxCtx): string {
|
|
1485
|
+
// 0. 플레이스홀더 처리 (차트 등 b64가 없는 경우)
|
|
1486
|
+
if (!img.b64) {
|
|
1487
|
+
return `<hp:t xml:space="preserve">${esc(img.alt || "[개체]")}</hp:t>`;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
620
1490
|
const binId = ctx.imgMap.get(img);
|
|
621
1491
|
if (!binId) return "";
|
|
622
1492
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
1493
|
+
// ANYTOHWP 영감: PNG/JPEG 바이너리 헤더에서 실제 픽셀 치수 추출
|
|
1494
|
+
// img.w / img.h는 pt 단위이지만, 이미지 실제 픽셀을 HWPUNIT으로 변환하면 더 정확
|
|
1495
|
+
const pixelDims = readPixelDims(img.b64, img.mime);
|
|
1496
|
+
let wHwp: number, hHwp: number;
|
|
1497
|
+
|
|
1498
|
+
if (pixelDims && pixelDims.w > 0 && pixelDims.h > 0) {
|
|
1499
|
+
// 픽셀 → pt (96dpi 기준) → HWPUNIT
|
|
1500
|
+
wHwp = Metric.ptToHwp((pixelDims.w * 72) / 96);
|
|
1501
|
+
hHwp = Metric.ptToHwp((pixelDims.h * 72) / 96);
|
|
1502
|
+
} else {
|
|
1503
|
+
wHwp = Metric.ptToHwp(img.w);
|
|
1504
|
+
hHwp = Metric.ptToHwp(img.h);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// 가용 너비 초과 방지 (비율 유지)
|
|
1508
|
+
if (wHwp > ctx.availableWidth) {
|
|
1509
|
+
hHwp = Math.round((hHwp * ctx.availableWidth) / wHwp);
|
|
1510
|
+
wHwp = ctx.availableWidth;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 회전 중심점 (rotation center) 계산: 이미지 중앙을 기준으로 회전
|
|
1514
|
+
const rotationCenterX = Math.round(wHwp / 2);
|
|
1515
|
+
const rotationCenterY = Math.round(hHwp / 2);
|
|
628
1516
|
|
|
629
1517
|
const layout = img.layout;
|
|
630
|
-
const isInline = !layout || layout.wrap ===
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const flowWithText = '1';
|
|
636
|
-
// behind/front/inline 이미지는 다른 객체와 겹침 허용 불필요; square/tight는 허용
|
|
637
|
-
const allowOverlap = (!isInline && layout?.wrap !== 'behind' && layout?.wrap !== 'front') ? '1' : '0';
|
|
638
|
-
|
|
639
|
-
const horzRelTo = layout?.horzRelTo ? (HORZ_RELTO_HWPX[layout.horzRelTo] ?? 'PARA') : 'PARA';
|
|
640
|
-
const vertRelTo = layout?.vertRelTo ? (VERT_RELTO_HWPX[layout.vertRelTo] ?? 'PARA') : 'PARA';
|
|
641
|
-
|
|
642
|
-
const ALIGN_H: Record<string, string> = { left: 'LEFT', center: 'CENTER', right: 'RIGHT' };
|
|
643
|
-
const ALIGN_V: Record<string, string> = { top: 'TOP', center: 'CENTER', bottom: 'BOTTOM' };
|
|
644
|
-
const horzAlign = layout?.horzAlign ? (ALIGN_H[layout.horzAlign] ?? 'LEFT') : 'LEFT';
|
|
645
|
-
const vertAlign = layout?.vertAlign ? (ALIGN_V[layout.vertAlign] ?? 'TOP') : 'TOP';
|
|
646
|
-
const horzOffset = layout?.xPt != null ? Metric.ptToHwp(layout.xPt) : 0;
|
|
647
|
-
const vertOffset = layout?.yPt != null ? Metric.ptToHwp(layout.yPt) : 0;
|
|
648
|
-
|
|
649
|
-
// hp:pic children must follow the exact HWPX spec order.
|
|
1518
|
+
const isInline = !layout || layout.wrap === "inline";
|
|
1519
|
+
const textWrap = layout ? (WRAP_MAP[layout.wrap] ?? "SQUARE") : "SQUARE";
|
|
1520
|
+
const textFlow = layout
|
|
1521
|
+
? (FLOW_MAP[layout.wrap] ?? "BOTH_SIDES")
|
|
1522
|
+
: "BOTH_SIDES";
|
|
650
1523
|
const zOrder = ctx.nextZOrder++;
|
|
651
|
-
|
|
1524
|
+
|
|
1525
|
+
return (
|
|
1526
|
+
`<hp:pic id="${ctx.nextElementId++}" zOrder="${zOrder}" numberingType="PICTURE" ` +
|
|
1527
|
+
`textWrap="${textWrap}" textFlow="${textFlow}" lock="0" dropcapstyle="None" href="" groupLevel="0" instid="0" reverse="0">` +
|
|
1528
|
+
`<hp:offset x="0" y="0"/>` +
|
|
1529
|
+
`<hp:orgSz width="${wHwp}" height="${hHwp}"/>` +
|
|
1530
|
+
`<hp:curSz width="${wHwp}" height="${hHwp}"/>` +
|
|
1531
|
+
`<hp:flip horizontal="0" vertical="0"/>` +
|
|
1532
|
+
`<hp:rotationInfo angle="0" centerX="${rotationCenterX}" centerY="${rotationCenterY}" rotateimage="1"/>` +
|
|
1533
|
+
`<hp:renderingInfo>` +
|
|
1534
|
+
`<hc:transMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
|
|
1535
|
+
`<hc:scaMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
|
|
1536
|
+
`<hc:rotMatrix e1="1" e2="0" e3="0" e4="0" e5="1" e6="0"/>` +
|
|
1537
|
+
`</hp:renderingInfo>` +
|
|
1538
|
+
`<hp:imgRect>` +
|
|
1539
|
+
`<hc:pt0 x="0" y="0"/><hc:pt1 x="${wHwp}" y="0"/>` +
|
|
1540
|
+
`<hc:pt2 x="${wHwp}" y="${hHwp}"/><hc:pt3 x="0" y="${hHwp}"/>` +
|
|
1541
|
+
`</hp:imgRect>` +
|
|
1542
|
+
`<hp:imgClip left="0" right="0" top="0" bottom="0"/>` +
|
|
1543
|
+
`<hp:inMargin left="0" right="0" top="0" bottom="0"/>` +
|
|
1544
|
+
`<hp:imgDim dimwidth="${wHwp}" dimheight="${hHwp}"/>` +
|
|
1545
|
+
`<hc:img binaryItemIDRef="${binId}" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>` +
|
|
1546
|
+
`<hp:effects/>` +
|
|
1547
|
+
`<hp:sz width="${wHwp}" widthRelTo="ABSOLUTE" height="${hHwp}" heightRelTo="ABSOLUTE" protect="0"/>` +
|
|
1548
|
+
`<hp:pos treatAsChar="${isInline ? 1 : 0}" affectLSpacing="0" flowWithText="1" ` +
|
|
1549
|
+
`allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" ` +
|
|
1550
|
+
`vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
|
|
1551
|
+
`<hp:outMargin left="0" right="0" top="0" bottom="0"/>` +
|
|
1552
|
+
`</hp:pic>`
|
|
1553
|
+
);
|
|
652
1554
|
}
|
|
653
1555
|
|
|
654
|
-
function
|
|
1556
|
+
function encodeImgWrapped(img: ImgNode, ctx: HwpxCtx): string {
|
|
1557
|
+
const content = encodeImage(img, ctx);
|
|
1558
|
+
if (!img.b64) {
|
|
1559
|
+
return `<hp:run charPrIDRef="0" charTcId="0">${content}</hp:run>`;
|
|
1560
|
+
}
|
|
1561
|
+
return `<hp:run charPrIDRef="0" charTcId="0">${content}<hp:t xml:space="preserve"> </hp:t></hp:run>`;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// ─── 표(Grid) 인코딩 ─────────────────────────────────────────
|
|
1565
|
+
|
|
1566
|
+
function encodeGridPositioned(
|
|
1567
|
+
grid: GridNode,
|
|
1568
|
+
ctx: HwpxCtx,
|
|
1569
|
+
vertPos: number,
|
|
1570
|
+
secPr = "",
|
|
1571
|
+
hfRun = "",
|
|
1572
|
+
): { xml: string; nextVertPos: number } {
|
|
1573
|
+
const { xml: gridXml, height: tblHeight } = buildGridXml(grid, ctx);
|
|
1574
|
+
const totalHeight = Math.max(1600, tblHeight);
|
|
1575
|
+
const fontSize = 1000;
|
|
1576
|
+
const baseline = Math.round(fontSize * 0.83);
|
|
1577
|
+
const spacing = Math.max(0, totalHeight - fontSize);
|
|
1578
|
+
|
|
1579
|
+
const linesegXml =
|
|
1580
|
+
`<hp:linesegarray>` +
|
|
1581
|
+
`<hp:lineseg textpos="0" vertpos="${vertPos}" vertsize="${totalHeight}" ` +
|
|
1582
|
+
`textheight="${fontSize}" baseline="${baseline}" spacing="${spacing}" ` +
|
|
1583
|
+
`horzpos="0" horzsize="${ctx.availableWidth}" flags="${LINESEG_FLAGS_FIRST}"/>` +
|
|
1584
|
+
`</hp:linesegarray>`;
|
|
1585
|
+
|
|
1586
|
+
const xml =
|
|
1587
|
+
`<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0" paraTcId="0">` +
|
|
1588
|
+
secPr +
|
|
1589
|
+
hfRun +
|
|
1590
|
+
gridXml +
|
|
1591
|
+
linesegXml +
|
|
1592
|
+
`</hp:p>`;
|
|
1593
|
+
|
|
1594
|
+
return { xml, nextVertPos: vertPos + totalHeight };
|
|
1595
|
+
}
|
|
1596
|
+
function buildGridXml(
|
|
1597
|
+
grid: GridNode,
|
|
1598
|
+
ctx: HwpxCtx,
|
|
1599
|
+
): { xml: string; height: number } {
|
|
655
1600
|
const rowCount = grid.kids.length;
|
|
1601
|
+
// ... (기존 tableMap 생성 로직 동일)
|
|
656
1602
|
|
|
657
|
-
//
|
|
658
|
-
interface
|
|
659
|
-
type:
|
|
1603
|
+
// 가상 2D 맵 — 병합 셀 처리
|
|
1604
|
+
interface CellEntry {
|
|
1605
|
+
type: "real" | "absorbed";
|
|
660
1606
|
cell?: CellNode;
|
|
661
1607
|
}
|
|
662
|
-
const tableMap:
|
|
1608
|
+
const tableMap: CellEntry[][] = Array.from({ length: rowCount }, () => []);
|
|
663
1609
|
|
|
664
1610
|
for (let ri = 0; ri < rowCount; ri++) {
|
|
665
1611
|
let ci = 0;
|
|
666
1612
|
for (const cell of grid.kids[ri].kids) {
|
|
667
|
-
while (tableMap[ri][ci]) ci++;
|
|
668
|
-
|
|
669
|
-
tableMap[ri][ci] = { type: 'real', cell };
|
|
670
|
-
|
|
671
|
-
// 병합 영역 예약
|
|
1613
|
+
while (tableMap[ri][ci]) ci++;
|
|
1614
|
+
tableMap[ri][ci] = { type: "real", cell };
|
|
672
1615
|
for (let rr = 0; rr < cell.rs; rr++) {
|
|
673
|
-
const
|
|
674
|
-
if (
|
|
675
|
-
if (!tableMap[targetRi]) tableMap[targetRi] = [];
|
|
1616
|
+
const tri = ri + rr;
|
|
1617
|
+
if (tri >= rowCount) break;
|
|
676
1618
|
for (let cc = 0; cc < cell.cs; cc++) {
|
|
677
1619
|
if (rr === 0 && cc === 0) continue;
|
|
678
|
-
tableMap[
|
|
1620
|
+
tableMap[tri][ci + cc] = { type: "absorbed" };
|
|
679
1621
|
}
|
|
680
1622
|
}
|
|
681
1623
|
ci += cell.cs;
|
|
682
1624
|
}
|
|
683
1625
|
}
|
|
684
1626
|
|
|
685
|
-
// 정확한 전체 열 개수 계산
|
|
686
1627
|
let colCount = 0;
|
|
687
|
-
for (let ri = 0; ri < rowCount; ri++)
|
|
1628
|
+
for (let ri = 0; ri < rowCount; ri++)
|
|
688
1629
|
colCount = Math.max(colCount, tableMap[ri].length);
|
|
689
|
-
}
|
|
690
1630
|
if (colCount === 0) colCount = 1;
|
|
691
1631
|
|
|
692
|
-
//
|
|
693
|
-
const
|
|
694
|
-
const defaultColW = Math.round(totalWidth / colCount);
|
|
1632
|
+
// 컬럼 너비 계산 (Bug 6: 균등 배분 금지, 원본 보존)
|
|
1633
|
+
const totalW = ctx.availableWidth;
|
|
695
1634
|
const colWidths: number[] = [];
|
|
1635
|
+
|
|
696
1636
|
if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const availPt = Metric.hwpToPt(totalWidth);
|
|
701
|
-
const remaining = Math.max(0, availPt - knownTotal);
|
|
702
|
-
const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
|
|
703
|
-
for (let i = 0; i < srcPt.length; i++) {
|
|
704
|
-
if (srcPt[i] <= 0) srcPt[i] = zeroFill > 0 ? zeroFill : Metric.hwpToPt(defaultColW);
|
|
1637
|
+
// pt -> HWPUNIT 변환하여 원본 값 보존
|
|
1638
|
+
for (const wPt of grid.props.colWidths) {
|
|
1639
|
+
colWidths.push(Metric.ptToHwp(wPt));
|
|
705
1640
|
}
|
|
706
|
-
for (const wPt of srcPt) colWidths.push(Metric.ptToHwp(wPt));
|
|
707
1641
|
} else {
|
|
708
|
-
|
|
1642
|
+
// 너비 정보가 없을 때만 균등 배분
|
|
1643
|
+
const defW = Math.round(totalW / colCount);
|
|
1644
|
+
for (let c = 0; c < colCount; c++) colWidths.push(defW);
|
|
709
1645
|
}
|
|
710
1646
|
|
|
1647
|
+
// 본문 너비 초과 시에만 비율 축소 보정
|
|
711
1648
|
const rawTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
712
|
-
if (rawTotal >
|
|
713
|
-
const scale =
|
|
714
|
-
for (let i = 0; i < colWidths.length; i++)
|
|
1649
|
+
if (rawTotal > totalW && rawTotal > 0) {
|
|
1650
|
+
const scale = totalW / rawTotal;
|
|
1651
|
+
for (let i = 0; i < colWidths.length; i++) {
|
|
1652
|
+
colWidths[i] = Math.max(100, Math.round(colWidths[i] * scale));
|
|
1653
|
+
}
|
|
715
1654
|
}
|
|
716
1655
|
const actualTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
717
1656
|
|
|
718
|
-
//
|
|
1657
|
+
// 행 높이 계산
|
|
719
1658
|
const rowHeights: number[] = [];
|
|
720
1659
|
for (let ri = 0; ri < rowCount; ri++) {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1660
|
+
if (
|
|
1661
|
+
grid.kids[ri].heightPt != null &&
|
|
1662
|
+
(grid.kids[ri].heightPt as number) > 0
|
|
1663
|
+
) {
|
|
1664
|
+
rowHeights.push(Metric.ptToHwp(grid.kids[ri].heightPt as number));
|
|
724
1665
|
} else {
|
|
725
1666
|
let maxH = 0;
|
|
726
1667
|
for (let ci = 0; ci < colCount; ci++) {
|
|
727
1668
|
const entry = tableMap[ri][ci];
|
|
728
|
-
if (entry?.type ===
|
|
1669
|
+
if (entry?.type === "real") {
|
|
729
1670
|
const h = estimateCellHeight(entry.cell!, ctx);
|
|
730
1671
|
if (h > maxH) maxH = h;
|
|
731
1672
|
}
|
|
@@ -733,77 +1674,142 @@ function encodeGrid(grid: GridNode, ctx: HwpxCtx): string {
|
|
|
733
1674
|
rowHeights.push(maxH || Math.round(1000 * 1.6));
|
|
734
1675
|
}
|
|
735
1676
|
}
|
|
736
|
-
const
|
|
1677
|
+
const totalH = rowHeights.reduce((s, h) => s + h, 0);
|
|
737
1678
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1679
|
+
const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
|
|
1680
|
+
// 표 기본 테두리 — BorderFillBank에서 실제 ID 조회
|
|
1681
|
+
const tblBfId = ctx.borderFillBank.addUniform(defStroke);
|
|
741
1682
|
|
|
1683
|
+
let rowsXml = "";
|
|
742
1684
|
for (let ri = 0; ri < rowCount; ri++) {
|
|
743
1685
|
let cellsXml = "";
|
|
744
1686
|
for (let ci = 0; ci < colCount; ci++) {
|
|
745
1687
|
const entry = tableMap[ri][ci];
|
|
746
|
-
if (!entry || entry.type ===
|
|
747
|
-
|
|
1688
|
+
if (!entry || entry.type === "absorbed") continue;
|
|
748
1689
|
const cell = entry.cell!;
|
|
749
1690
|
const cp = cell.props;
|
|
750
|
-
let cellBfId = tblBfId;
|
|
751
|
-
|
|
752
|
-
const hasPerSideBorder = cp.top || cp.bot || cp.left || cp.right;
|
|
753
|
-
if (hasPerSideBorder || cp.bg) {
|
|
754
|
-
const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
|
|
755
|
-
cellBfId = hasPerSideBorder
|
|
756
|
-
? addBorderFillPerSide(ctx, cp.top ?? defStroke, cp.right ?? defStroke, cp.bot ?? defStroke, cp.left ?? defStroke, cp.bg)
|
|
757
|
-
: addBorderFill(ctx, defStroke, cp.bg);
|
|
758
|
-
}
|
|
759
1691
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (cellW === 0) cellW = defaultColW * cell.cs;
|
|
1692
|
+
// 셀 테두리 — BorderFillBank에서 실제 ID 조회 (하드코딩 제거)
|
|
1693
|
+
const cellBfId = ctx.borderFillBank.addFromCellProps(cp, defStroke);
|
|
763
1694
|
|
|
764
|
-
|
|
765
|
-
|
|
1695
|
+
let cellW = 0;
|
|
1696
|
+
for (let sc = ci; sc < ci + cell.cs && sc < colWidths.length; sc++)
|
|
1697
|
+
cellW += colWidths[sc];
|
|
1698
|
+
if (!cellW) cellW = Math.round(totalW / colCount) * cell.cs;
|
|
1699
|
+
|
|
1700
|
+
const subListId = ctx.nextElementId++;
|
|
1701
|
+
|
|
1702
|
+
// 셀 여백 (기본 141)
|
|
1703
|
+
const padL = cp.padL !== undefined ? Metric.ptToHwp(cp.padL) : 141;
|
|
1704
|
+
const padR = cp.padR !== undefined ? Metric.ptToHwp(cp.padR) : 141;
|
|
1705
|
+
const padT = cp.padT !== undefined ? Metric.ptToHwp(cp.padT) : 141;
|
|
1706
|
+
const padB = cp.padB !== undefined ? Metric.ptToHwp(cp.padB) : 141;
|
|
1707
|
+
|
|
1708
|
+
const innerW = Math.max(cellW - padL - padR, 100);
|
|
1709
|
+
let parasXml = '';
|
|
1710
|
+
if (cell.kids.length > 0) {
|
|
1711
|
+
for (const kid of cell.kids) {
|
|
1712
|
+
if (kid.tag === 'grid') {
|
|
1713
|
+
// 중첩 표: <hp:p><hp:run><hp:tbl>...</hp:tbl></hp:run></hp:p> 형식으로 감싸기
|
|
1714
|
+
const { xml: tblXml } = buildGridXml(kid, ctx);
|
|
1715
|
+
const pid = ctx.nextElementId++;
|
|
1716
|
+
const rid = ctx.nextElementId++;
|
|
1717
|
+
parasXml += `<hp:p id="${pid}" paraPrIDRef="0" styleIDRef="0" paraTcId="0"><hp:run id="${rid}" charPrIDRef="0" charTcId="0">${tblXml}</hp:run></hp:p>`;
|
|
1718
|
+
} else {
|
|
1719
|
+
parasXml += encodeParaPositioned(kid, ctx, 0, '', innerW).xml;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
} else {
|
|
1723
|
+
parasXml = `<hp:p id="${ctx.nextElementId++}" paraPrIDRef="0" styleIDRef="0" paraTcId="0"><hp:run charPrIDRef="0" charTcId="0"><hp:t xml:space="preserve"> </hp:t></hp:run></hp:p>`;
|
|
1724
|
+
}
|
|
766
1725
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1726
|
+
const vAlign =
|
|
1727
|
+
cp.va === "mid" ? "CENTER" : cp.va === "bot" ? "BOTTOM" : "TOP";
|
|
1728
|
+
|
|
1729
|
+
cellsXml +=
|
|
1730
|
+
`<hp:tc name="" header="0" hasMargin="1" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBfId}">` +
|
|
1731
|
+
`<hp:cellAddr colAddr="${ci}" rowAddr="${ri}"/>` +
|
|
1732
|
+
`<hp:cellSpan colSpan="${cell.cs}" rowSpan="${cell.rs}"/>` +
|
|
1733
|
+
`<hp:cellSz width="${cellW}" height="${rowHeights[ri]}"/>` +
|
|
1734
|
+
`<hp:cellMargin left="${padL}" right="${padR}" top="${padT}" bottom="${padB}"/>` +
|
|
1735
|
+
`<hp:subList id="${subListId}" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="${vAlign}" ` +
|
|
1736
|
+
`linkListIDRef="0" linkListNextIDRef="0" textWidth="${innerW}" textHeight="${Math.max(100, rowHeights[ri] - padT - padB)}" hasTextRef="0" hasNumRef="0">` +
|
|
1737
|
+
parasXml +
|
|
1738
|
+
`</hp:subList>` +
|
|
1739
|
+
`</hp:tc>`;
|
|
770
1740
|
}
|
|
771
1741
|
rowsXml += `<hp:tr>${cellsXml}</hp:tr>`;
|
|
772
1742
|
}
|
|
773
1743
|
|
|
1744
|
+
// 표 정렬 처리
|
|
1745
|
+
const alignMap: Record<string, string> = {
|
|
1746
|
+
left: 'LEFT', right: 'RIGHT', center: 'CENTER', justify: 'JUSTIFY',
|
|
1747
|
+
};
|
|
1748
|
+
const horzAlign = alignMap[grid.props.align ?? 'left'] ?? 'LEFT';
|
|
1749
|
+
|
|
774
1750
|
const headerRow = grid.props.headerRow ? ' repeatHeader="1"' : "";
|
|
775
|
-
|
|
1751
|
+
const xml =
|
|
1752
|
+
`<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">` +
|
|
1753
|
+
`<hp:sz width="${actualTotal}" widthRelTo="ABSOLUTE" height="${totalH}" heightRelTo="ABSOLUTE" protect="0"/>` +
|
|
1754
|
+
`<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="${horzAlign}" vertOffset="0" horzOffset="0"/>` +
|
|
1755
|
+
`<hp:outMargin left="138" right="138" top="138" bottom="138"/>` +
|
|
1756
|
+
`<hp:inMargin left="0" right="0" top="0" bottom="0"/>` +
|
|
1757
|
+
rowsXml +
|
|
1758
|
+
`</hp:tbl>`;
|
|
1759
|
+
|
|
1760
|
+
return { xml, height: totalH };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function estimateCellHeight(cell: CellNode, ctx: HwpxCtx): number {
|
|
1764
|
+
const topPad = 141;
|
|
1765
|
+
const botPad = 141;
|
|
1766
|
+
let h = 0;
|
|
1767
|
+
for (const kid of cell.kids) {
|
|
1768
|
+
if (kid.tag === 'grid') {
|
|
1769
|
+
// 중첩 표 높이는 최소값으로 처리
|
|
1770
|
+
h += 1600;
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
const para = kid;
|
|
1774
|
+
const fs = fontSizeForPara(para, ctx);
|
|
1775
|
+
const ppId = ctx.paraPrMap.get(paraPrKey(para.props));
|
|
1776
|
+
const pp = ppId !== undefined ? ctx.paraPrs[ppId] : null;
|
|
1777
|
+
const ls = pp?.lineSpacing ?? 160;
|
|
1778
|
+
const before = pp?.prevHwp ?? 0;
|
|
1779
|
+
const after = pp?.nextHwp ?? 0;
|
|
1780
|
+
h += Math.round((fs * ls) / 100) + before + after;
|
|
1781
|
+
}
|
|
1782
|
+
if (!h) h = Math.round(1000 * 1.6);
|
|
1783
|
+
return h + topPad + botPad;
|
|
776
1784
|
}
|
|
777
1785
|
|
|
1786
|
+
// ─── 미리보기 텍스트 추출 ────────────────────────────────────
|
|
1787
|
+
|
|
778
1788
|
function extractPreviewText(sheet?: SheetNode): string {
|
|
779
1789
|
if (!sheet) return "";
|
|
780
1790
|
const lines: string[] = [];
|
|
781
1791
|
for (const kid of sheet.kids) {
|
|
782
1792
|
if (kid.tag === "para") {
|
|
783
1793
|
const text = kid.kids
|
|
784
|
-
.
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return "";
|
|
790
|
-
})
|
|
1794
|
+
.flatMap((k) =>
|
|
1795
|
+
k.tag === "span"
|
|
1796
|
+
? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
|
|
1797
|
+
: [],
|
|
1798
|
+
)
|
|
791
1799
|
.join("");
|
|
792
1800
|
if (text) lines.push(text);
|
|
793
1801
|
} else if (kid.tag === "grid") {
|
|
794
1802
|
for (const row of kid.kids) {
|
|
795
1803
|
const cells = row.kids.map((cell) =>
|
|
796
1804
|
cell.kids
|
|
797
|
-
.
|
|
798
|
-
p.
|
|
799
|
-
.
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
})
|
|
806
|
-
.join(""),
|
|
1805
|
+
.flatMap((p) =>
|
|
1806
|
+
p.tag === 'para'
|
|
1807
|
+
? p.kids.flatMap((k) =>
|
|
1808
|
+
k.tag === "span"
|
|
1809
|
+
? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
|
|
1810
|
+
: [],
|
|
1811
|
+
)
|
|
1812
|
+
: [],
|
|
807
1813
|
)
|
|
808
1814
|
.join(""),
|
|
809
1815
|
);
|
|
@@ -814,16 +1820,18 @@ function extractPreviewText(sheet?: SheetNode): string {
|
|
|
814
1820
|
return lines.join("\r\n");
|
|
815
1821
|
}
|
|
816
1822
|
|
|
1823
|
+
// ─── XML 이스케이프 ──────────────────────────────────────────
|
|
1824
|
+
|
|
817
1825
|
function esc(s: string): string {
|
|
818
1826
|
if (!s) return "";
|
|
819
|
-
|
|
820
|
-
s = s.replace(/
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
s = s.replace(
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1827
|
+
s = s.replace(/__EXT_\d+(?:_W\d+_H\d+)?__/g, "");
|
|
1828
|
+
s = s.replace(/湰灧/g, "").replace(/\uFEFF/g, "");
|
|
1829
|
+
// XML 1.0 비허용 제어문자 제거
|
|
1830
|
+
// eslint-disable-next-line no-control-regex
|
|
1831
|
+
s = s.replace(
|
|
1832
|
+
/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu,
|
|
1833
|
+
"",
|
|
1834
|
+
);
|
|
827
1835
|
return TextKit.escapeXml(s);
|
|
828
1836
|
}
|
|
829
1837
|
|