hwpkit-dev 0.0.1 → 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 -1
- package/README.md +39 -2
- package/dist/index.d.mts +74 -16
- package/dist/index.d.ts +70 -16
- package/dist/index.js +4985 -698
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4981 -698
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -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 +867 -150
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +477 -88
- package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +600 -295
- package/src/encoders/html/HtmlEncoder.ts +203 -0
- package/src/encoders/hwp/HwpEncoder.ts +1647 -398
- package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +117 -30
- package/src/index.ts +1 -0
- package/src/model/builders.ts +8 -6
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +13 -5
- package/src/pipeline/Pipeline.ts +21 -4
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +52 -7
- 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
|
@@ -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,76 +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)
|
|
371
|
+
interface BinEntry {
|
|
372
|
+
id: string; // "BIN0001"
|
|
373
|
+
name: string; // "BIN0001.png"
|
|
374
|
+
data: Uint8Array;
|
|
91
375
|
}
|
|
92
376
|
|
|
93
377
|
function charPrKey(p: TextProps): string {
|
|
94
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 ?? ""}`;
|
|
95
379
|
}
|
|
96
380
|
|
|
381
|
+
/**
|
|
382
|
+
* ParaProps 를 해시 키로 변환 (동일 포맷팅 감지용)
|
|
383
|
+
* null/undefined는 0 으로 처리하여 일관성 유지
|
|
384
|
+
*/
|
|
97
385
|
function paraPrKey(p: ParaProps): string {
|
|
98
|
-
return `${p.align ?? "left"}|${p.listOrd ?? ""}|${p.listLv ?? 0}|${p.indentPt ?? 0}|${p.spaceBefore ?? 0}|${p.spaceAfter ?? 0}|${p.lineHeight ?? 0}`;
|
|
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 ?? ""}`;
|
|
99
387
|
}
|
|
100
388
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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[];
|
|
109
406
|
}
|
|
110
407
|
|
|
111
408
|
function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
@@ -113,11 +410,11 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
|
113
410
|
const existing = ctx.charPrMap.get(key);
|
|
114
411
|
if (existing !== undefined) return existing;
|
|
115
412
|
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
413
|
+
const rawFont = props.font ?? "함초롬바탕";
|
|
414
|
+
const { hangulId, latinId } = ctx.fontBank.registerFont(rawFont);
|
|
119
415
|
const id = ctx.charPrs.length;
|
|
120
|
-
|
|
416
|
+
|
|
417
|
+
ctx.charPrs.push({
|
|
121
418
|
id,
|
|
122
419
|
height: Metric.ptToHHeight(props.pt ?? 10),
|
|
123
420
|
bold: !!props.b,
|
|
@@ -125,11 +422,10 @@ function registerCharPr(props: TextProps, ctx: HwpxCtx): number {
|
|
|
125
422
|
underline: props.u ? "BOTTOM" : "NONE",
|
|
126
423
|
strikeout: props.s ? "SOLID" : "NONE",
|
|
127
424
|
textColor: props.color ? `#${props.color}` : "#000000",
|
|
128
|
-
|
|
129
|
-
|
|
425
|
+
hangulId,
|
|
426
|
+
latinId,
|
|
130
427
|
bg: props.bg,
|
|
131
|
-
};
|
|
132
|
-
ctx.charPrs.push(def);
|
|
428
|
+
});
|
|
133
429
|
ctx.charPrMap.set(key, id);
|
|
134
430
|
return id;
|
|
135
431
|
}
|
|
@@ -143,9 +439,11 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
|
|
|
143
439
|
const def: ParaPrDef = {
|
|
144
440
|
id,
|
|
145
441
|
align: (props.align ?? "left").toUpperCase(),
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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),
|
|
149
447
|
lineSpacing: props.lineHeight ? Math.round(props.lineHeight * 100) : 160,
|
|
150
448
|
};
|
|
151
449
|
if (props.listOrd !== undefined) {
|
|
@@ -157,33 +455,7 @@ function registerParaPr(props: ParaProps, ctx: HwpxCtx): number {
|
|
|
157
455
|
return id;
|
|
158
456
|
}
|
|
159
457
|
|
|
160
|
-
// ───
|
|
161
|
-
|
|
162
|
-
function scanContent(kids: ContentNode[], ctx: HwpxCtx): void {
|
|
163
|
-
for (const kid of kids) {
|
|
164
|
-
if (kid.tag === "para") scanPara(kid, ctx);
|
|
165
|
-
else if (kid.tag === "grid") scanGrid(kid, ctx);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function scanPara(para: ParaNode, ctx: HwpxCtx): void {
|
|
170
|
-
registerParaPr(para.props, ctx);
|
|
171
|
-
for (const kid of para.kids) {
|
|
172
|
-
if (kid.tag === "span") registerCharPr(kid.props, ctx);
|
|
173
|
-
else if (kid.tag === "img") registerImage(kid, ctx);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function scanGrid(grid: GridNode, ctx: HwpxCtx): void {
|
|
178
|
-
for (const row of grid.kids)
|
|
179
|
-
for (const cell of row.kids) for (const p of cell.kids) scanPara(p, ctx);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function scanParas(paras: ParaNode[], ctx: HwpxCtx): void {
|
|
183
|
-
for (const p of paras) scanPara(p, ctx);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ─── Image handling ─────────────────────────────────────────
|
|
458
|
+
// ─── 이미지 등록 ─────────────────────────────────────────────
|
|
187
459
|
|
|
188
460
|
function mimeToExt(mime: string): string {
|
|
189
461
|
if (mime.includes("jpeg")) return "jpg";
|
|
@@ -203,139 +475,269 @@ function registerImage(img: ImgNode, ctx: HwpxCtx): void {
|
|
|
203
475
|
ctx.imgMap.set(img, id);
|
|
204
476
|
}
|
|
205
477
|
|
|
206
|
-
// ───
|
|
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
|
+
};
|
|
207
490
|
|
|
208
|
-
function
|
|
491
|
+
function registerStyle(
|
|
492
|
+
styleId: string,
|
|
493
|
+
paraPrId: number,
|
|
494
|
+
charPrId: number,
|
|
209
495
|
ctx: HwpxCtx,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
496
|
+
): void {
|
|
497
|
+
if (!styleId || ctx.styleIdToHwpxId.has(styleId)) return;
|
|
498
|
+
if (styleId === "Normal") {
|
|
499
|
+
ctx.styleIdToHwpxId.set(styleId, 0);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
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
|
+
});
|
|
511
|
+
}
|
|
225
512
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|
|
230
534
|
}
|
|
535
|
+
scanKids(para.kids);
|
|
536
|
+
if (para.props.styleId)
|
|
537
|
+
registerStyle(para.props.styleId, paraPrId, firstCharPrId, ctx);
|
|
538
|
+
}
|
|
231
539
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
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
|
+
}
|
|
235
560
|
}
|
|
236
561
|
|
|
237
|
-
// ─── Encoder
|
|
562
|
+
// ─── Encoder 클래스 ──────────────────────────────────────────
|
|
238
563
|
|
|
239
|
-
export class HwpxEncoder
|
|
240
|
-
|
|
564
|
+
export class HwpxEncoder extends BaseEncoder {
|
|
565
|
+
protected getFormat(): string { return "hwpx"; }
|
|
566
|
+
protected getAliases(): string[] { return [HWPX_MIME_TYPE, "application/hwp+zip"]; }
|
|
241
567
|
|
|
242
568
|
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
243
569
|
try {
|
|
244
570
|
const sheet = doc.kids[0];
|
|
245
571
|
const dims = normalizeDims(sheet?.dims ?? A4);
|
|
246
572
|
|
|
247
|
-
|
|
248
|
-
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(
|
|
249
576
|
Metric.ptToHwp(dims.wPt) -
|
|
250
|
-
|
|
251
|
-
|
|
577
|
+
Metric.ptToHwp(safeML) -
|
|
578
|
+
Metric.ptToHwp(safeMR),
|
|
579
|
+
);
|
|
252
580
|
|
|
581
|
+
// 컨텍스트 초기화
|
|
253
582
|
const ctx: HwpxCtx = {
|
|
583
|
+
fontBank: new LangFontBank(), // ANYTOHWP 방식 언어별 폰트
|
|
584
|
+
borderFillBank: new BorderFillBank(), // 하드코딩 없는 테두리 관리
|
|
254
585
|
charPrs: [],
|
|
255
586
|
charPrMap: new Map(),
|
|
256
587
|
paraPrs: [],
|
|
257
588
|
paraPrMap: new Map(),
|
|
258
|
-
borderFills: [],
|
|
259
589
|
bins: [],
|
|
260
590
|
nextBinNum: 1,
|
|
261
591
|
nextElementId: 10000,
|
|
262
592
|
availableWidth,
|
|
263
|
-
fonts: [],
|
|
264
|
-
fontMap: new Map(),
|
|
265
593
|
imgMap: new WeakMap(),
|
|
594
|
+
nextZOrder: 0,
|
|
595
|
+
styleIdToHwpxId: new Map(),
|
|
596
|
+
hwpxStyles: [],
|
|
266
597
|
};
|
|
267
598
|
|
|
268
|
-
//
|
|
269
|
-
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
270
|
-
// Table border borderFill (id=2)
|
|
271
|
-
addBorderFill(ctx, DEFAULT_STROKE);
|
|
272
|
-
// Default no-border for text areas (id=3)
|
|
273
|
-
addBorderFill(ctx, { kind: "none", pt: 0.1, color: "000000" });
|
|
274
|
-
|
|
275
|
-
// Register default charPr (id=0) and paraPr (id=0)
|
|
599
|
+
// id=0 기본 charPr/paraPr 등록
|
|
276
600
|
registerCharPr({}, ctx);
|
|
277
601
|
registerParaPr({}, ctx);
|
|
278
602
|
|
|
279
|
-
//
|
|
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/이미지/테두리 사전 등록
|
|
280
614
|
scanContent(sheet?.kids ?? [], ctx);
|
|
281
|
-
if (sheet?.
|
|
282
|
-
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);
|
|
283
617
|
|
|
284
|
-
//
|
|
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));
|
|
285
621
|
const previewText = extractPreviewText(sheet);
|
|
286
622
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
{
|
|
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
|
+
},
|
|
300
645
|
{
|
|
301
646
|
name: "Contents/content.hpf",
|
|
302
|
-
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",
|
|
303
669
|
},
|
|
304
|
-
{ name: "META-INF/container.xml", data: TextKit.encode(CONTAINER_XML) },
|
|
305
|
-
{ name: "META-INF/manifest.xml", data: TextKit.encode(MANIFEST_XML) },
|
|
306
670
|
];
|
|
307
671
|
|
|
308
672
|
for (const bin of ctx.bins) {
|
|
309
|
-
|
|
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 });
|
|
310
683
|
}
|
|
311
684
|
|
|
312
|
-
return succeed(await
|
|
685
|
+
return succeed(await this.zip(entries));
|
|
313
686
|
} catch (e: any) {
|
|
314
|
-
return fail(`HWPX
|
|
687
|
+
return fail(`HWPX 인코딩 오류: ${e?.message ?? String(e)}`);
|
|
315
688
|
}
|
|
316
689
|
}
|
|
317
690
|
}
|
|
318
691
|
|
|
319
|
-
// ───
|
|
320
|
-
|
|
321
|
-
const VERSION_XML =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 {
|
|
332
727
|
const title = esc(meta?.title ?? "");
|
|
333
|
-
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;
|
|
334
735
|
|
|
335
736
|
let items =
|
|
336
|
-
`<opf:item id="header"
|
|
737
|
+
`<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>` +
|
|
337
738
|
`<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>` +
|
|
338
|
-
`<opf:item id="settings" href="settings.xml"
|
|
739
|
+
`<opf:item id="settings" href="settings.xml" media-type="application/xml"/>`;
|
|
740
|
+
|
|
339
741
|
for (const bin of ctx.bins) {
|
|
340
742
|
const ext = bin.name.split(".").pop()?.toLowerCase() ?? "png";
|
|
341
743
|
const ct =
|
|
@@ -348,411 +750,1066 @@ function contentHpf(ctx: HwpxCtx, meta?: any): string {
|
|
|
348
750
|
: "image/bmp";
|
|
349
751
|
items += `<opf:item id="${bin.id}" href="BinData/${bin.name}" media-type="${ct}" isEmbeded="1"/>`;
|
|
350
752
|
}
|
|
351
|
-
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><opf:package ${NS} version="" unique-identifier="" id=""><opf:metadata><opf:title>${title}</opf:title><opf:language>ko</opf:language><opf:meta name="creator" content="text"/><opf:meta name="subject" content="text"/><opf:meta name="description" content="text"/><opf:meta name="CreatedDate" content="text">${now}</opf:meta><opf:meta name="ModifiedDate" content="text">${now}</opf:meta><opf:meta name="keyword" content="text"/></opf:metadata><opf:manifest>${items}</opf:manifest><opf:spine><opf:itemref idref="header" linear="yes"/><opf:itemref idref="section0" linear="yes"/></opf:spine></opf:package>`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ─── header.xml ─────────────────────────────────────────────
|
|
355
|
-
|
|
356
|
-
function headerXml(dims: PageDims, meta: any, ctx: HwpxCtx): string {
|
|
357
|
-
// Font face definitions — register all unique fonts per language group
|
|
358
|
-
const fontCount = ctx.fonts.length || 1;
|
|
359
|
-
const langs = [
|
|
360
|
-
"HANGUL",
|
|
361
|
-
"LATIN",
|
|
362
|
-
"HANJA",
|
|
363
|
-
"JAPANESE",
|
|
364
|
-
"OTHER",
|
|
365
|
-
"SYMBOL",
|
|
366
|
-
"USER",
|
|
367
|
-
];
|
|
368
|
-
let fontFaces = `<hh:fontfaces itemCnt="${langs.length}">`;
|
|
369
|
-
for (const lang of langs) {
|
|
370
|
-
fontFaces += `<hh:fontface lang="${lang}" fontCnt="${fontCount}">`;
|
|
371
|
-
for (let fi = 0; fi < ctx.fonts.length; fi++) {
|
|
372
|
-
fontFaces += `<hh:font id="${fi}" face="${esc(ctx.fonts[fi])}" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
|
|
373
|
-
}
|
|
374
|
-
if (ctx.fonts.length === 0) {
|
|
375
|
-
fontFaces += `<hh:font id="0" face="굴림체" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="0" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/></hh:font>`;
|
|
376
|
-
}
|
|
377
|
-
fontFaces += `</hh:fontface>`;
|
|
378
|
-
}
|
|
379
|
-
fontFaces += `</hh:fontfaces>`;
|
|
380
753
|
|
|
381
|
-
|
|
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 핵심 개선)
|
|
382
825
|
let charPrXml = "";
|
|
383
826
|
for (const cp of ctx.charPrs) {
|
|
384
827
|
const bold = cp.bold ? "<hh:bold/>" : "";
|
|
385
828
|
const italic = cp.italic ? "<hh:italic/>" : "";
|
|
386
|
-
const
|
|
387
|
-
|
|
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>`;
|
|
388
847
|
}
|
|
389
848
|
|
|
390
|
-
//
|
|
849
|
+
// paraPr 목록
|
|
391
850
|
let paraPrXml = "";
|
|
392
851
|
for (const pp of ctx.paraPrs) {
|
|
393
|
-
|
|
394
|
-
|
|
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>`;
|
|
395
868
|
}
|
|
396
869
|
|
|
397
|
-
//
|
|
398
|
-
const borderFillXml = ctx.
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
);
|
|
401
942
|
}
|
|
402
943
|
|
|
403
|
-
// ─── section0.xml
|
|
944
|
+
// ─── section0.xml ────────────────────────────────────────────
|
|
404
945
|
|
|
405
|
-
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(
|
|
406
1010
|
sheet: SheetNode | undefined,
|
|
407
1011
|
dims: PageDims,
|
|
408
1012
|
ctx: HwpxCtx,
|
|
409
1013
|
): string {
|
|
1014
|
+
const secPrXml = buildSecPrXml(dims);
|
|
410
1015
|
const kids = sheet?.kids ?? [];
|
|
1016
|
+
const hfRunXml = sheet ? buildHeaderFooterRunXml(sheet, dims, ctx) : "";
|
|
411
1017
|
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
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;
|
|
415
1024
|
|
|
416
1025
|
let contentXml = "";
|
|
417
|
-
let
|
|
1026
|
+
let vertPos = 0;
|
|
418
1027
|
|
|
419
|
-
|
|
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 : "";
|
|
420
1033
|
|
|
421
|
-
for (const kid of kids) {
|
|
422
1034
|
if (kid.tag === "para") {
|
|
423
|
-
|
|
424
|
-
|
|
1035
|
+
const { xml, nextVertPos } = encodeParaPositioned(
|
|
1036
|
+
kid,
|
|
1037
|
+
ctx,
|
|
1038
|
+
vertPos,
|
|
1039
|
+
curSecPr,
|
|
1040
|
+
availWidth,
|
|
1041
|
+
curHfRun,
|
|
1042
|
+
);
|
|
1043
|
+
contentXml += xml;
|
|
1044
|
+
vertPos = nextVertPos;
|
|
425
1045
|
} else if (kid.tag === "grid") {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
contentXml +=
|
|
434
|
-
|
|
1046
|
+
const { xml, nextVertPos } = encodeGridPositioned(
|
|
1047
|
+
kid,
|
|
1048
|
+
ctx,
|
|
1049
|
+
vertPos,
|
|
1050
|
+
curSecPr,
|
|
1051
|
+
curHfRun,
|
|
1052
|
+
);
|
|
1053
|
+
contentXml += xml;
|
|
1054
|
+
vertPos = nextVertPos;
|
|
435
1055
|
}
|
|
436
1056
|
}
|
|
437
1057
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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>`;
|
|
441
1070
|
}
|
|
442
1071
|
|
|
443
|
-
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>`;
|
|
444
1073
|
}
|
|
445
1074
|
|
|
446
|
-
function
|
|
447
|
-
const
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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>`
|
|
475
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
|
+
}
|
|
476
1162
|
|
|
477
|
-
|
|
1163
|
+
return {
|
|
1164
|
+
xml: `<hp:linesegarray>${linesegParts.join("")}</hp:linesegarray>`,
|
|
1165
|
+
totalHeight: lineCount * vertsizeLine,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
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 {
|
|
478
1186
|
for (const kid of para.kids) {
|
|
479
1187
|
if (kid.tag === "span") {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
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;
|
|
483
1190
|
}
|
|
484
1191
|
}
|
|
1192
|
+
return 1000; // 기본 10pt
|
|
1193
|
+
}
|
|
485
1194
|
|
|
486
|
-
|
|
487
|
-
if (runs === "") {
|
|
488
|
-
runs = `<hp:run charPrIDRef="0"><hp:t></hp:t></hp:run>`;
|
|
489
|
-
}
|
|
1195
|
+
// ─── 단락 인코딩 ─────────────────────────────────────────────
|
|
490
1196
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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);
|
|
501
1209
|
}
|
|
502
1210
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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);
|
|
507
1216
|
const paraPr = ctx.paraPrs[paraPrId];
|
|
508
1217
|
const lineSpacing = paraPr?.lineSpacing ?? 160;
|
|
509
|
-
const vertsize = fontSize;
|
|
510
|
-
const textheight = fontSize;
|
|
511
|
-
const baseline = Math.round(fontSize * 0.85);
|
|
512
1218
|
const spacing = Math.max(0, Math.round(fontSize * (lineSpacing / 100 - 1)));
|
|
513
|
-
|
|
514
|
-
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
|
+
);
|
|
515
1261
|
|
|
516
|
-
|
|
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>`;
|
|
1270
|
+
|
|
1271
|
+
return { xml, nextVertPos: vertPos + totalHeight };
|
|
517
1272
|
}
|
|
518
1273
|
|
|
519
|
-
/**
|
|
520
|
-
function
|
|
521
|
-
|
|
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) {
|
|
522
1380
|
if (kid.tag === "span") {
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
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();
|
|
526
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;
|
|
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);
|
|
527
1409
|
}
|
|
528
1410
|
}
|
|
529
|
-
return 1000; // default 10pt
|
|
530
|
-
}
|
|
531
1411
|
|
|
532
|
-
|
|
533
|
-
|
|
1412
|
+
flushRun();
|
|
1413
|
+
return xml;
|
|
1414
|
+
}
|
|
534
1415
|
|
|
535
|
-
|
|
1416
|
+
/** hp:run 내부의 태그들만 생성 (span용) */
|
|
1417
|
+
function encodeRunInner(span: SpanNode): string {
|
|
1418
|
+
let xml = "";
|
|
536
1419
|
for (const kid of span.kids) {
|
|
537
1420
|
if (kid.tag === "txt") {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
} else {
|
|
541
|
-
parts.push(`<hp:t></hp:t>`);
|
|
542
|
-
}
|
|
543
|
-
} else if (kid.tag === "pagenum") {
|
|
544
|
-
const fmt =
|
|
545
|
-
kid.format === "roman"
|
|
546
|
-
? "ROMAN_LOWER"
|
|
547
|
-
: kid.format === "romanCaps"
|
|
548
|
-
? "ROMAN_UPPER"
|
|
549
|
-
: "DIGIT";
|
|
550
|
-
parts.push(`<hp:pageNum pageStartsOn="BOTH" formatType="${fmt}"/>`);
|
|
1421
|
+
const content = esc(kid.content);
|
|
1422
|
+
if (content) xml += `<hp:t xml:space="preserve">${content}</hp:t>`;
|
|
551
1423
|
} else if (kid.tag === "br") {
|
|
552
|
-
|
|
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}"/>`;
|
|
553
1429
|
}
|
|
554
|
-
// pb is handled at paragraph level (pageBreak attribute), skip here
|
|
555
1430
|
}
|
|
1431
|
+
return xml;
|
|
1432
|
+
}
|
|
556
1433
|
|
|
557
|
-
|
|
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;
|
|
558
1461
|
}
|
|
559
1462
|
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
// textFlow 매핑 (wrap 타입별)
|
|
571
|
-
const TEXT_FLOW_HWPX: Record<string, string> = {
|
|
572
|
-
inline: 'BOTH_SIDES',
|
|
573
|
-
square: 'LARGEST_ONLY',
|
|
574
|
-
tight: 'BOTH_SIDES',
|
|
575
|
-
through: 'BOTH_SIDES',
|
|
576
|
-
none: 'BOTH_SIDES',
|
|
577
|
-
behind: 'BOTH_SIDES',
|
|
578
|
-
front: 'BOTH_SIDES',
|
|
579
|
-
};
|
|
580
|
-
const HORZ_RELTO_HWPX: Record<string, string> = {
|
|
581
|
-
para: 'PARA', margin: 'MARGIN', page: 'PAPER', column: 'COLUMN',
|
|
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",
|
|
582
1473
|
};
|
|
583
|
-
const
|
|
584
|
-
|
|
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",
|
|
585
1482
|
};
|
|
586
1483
|
|
|
587
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
|
+
|
|
588
1490
|
const binId = ctx.imgMap.get(img);
|
|
589
1491
|
if (!binId) return "";
|
|
590
1492
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
const cy = Math.round(h / 2);
|
|
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;
|
|
596
1497
|
|
|
597
|
-
|
|
598
|
-
|
|
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
|
+
}
|
|
599
1506
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const allowOverlap = (!isInline && layout?.wrap !== 'behind' && layout?.wrap !== 'front') ? '1' : '0';
|
|
1507
|
+
// 가용 너비 초과 방지 (비율 유지)
|
|
1508
|
+
if (wHwp > ctx.availableWidth) {
|
|
1509
|
+
hHwp = Math.round((hHwp * ctx.availableWidth) / wHwp);
|
|
1510
|
+
wHwp = ctx.availableWidth;
|
|
1511
|
+
}
|
|
606
1512
|
|
|
607
|
-
|
|
608
|
-
const
|
|
1513
|
+
// 회전 중심점 (rotation center) 계산: 이미지 중앙을 기준으로 회전
|
|
1514
|
+
const rotationCenterX = Math.round(wHwp / 2);
|
|
1515
|
+
const rotationCenterY = Math.round(hHwp / 2);
|
|
609
1516
|
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
1517
|
+
const layout = img.layout;
|
|
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";
|
|
1523
|
+
const zOrder = ctx.nextZOrder++;
|
|
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
|
+
);
|
|
1554
|
+
}
|
|
616
1555
|
|
|
617
|
-
|
|
618
|
-
|
|
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>`;
|
|
619
1562
|
}
|
|
620
1563
|
|
|
621
|
-
|
|
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 } {
|
|
622
1600
|
const rowCount = grid.kids.length;
|
|
1601
|
+
// ... (기존 tableMap 생성 로직 동일)
|
|
623
1602
|
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
for (const cell of row.kids) rowCols += cell.cs;
|
|
629
|
-
if (rowCols > colCount) colCount = rowCols;
|
|
1603
|
+
// 가상 2D 맵 — 병합 셀 처리
|
|
1604
|
+
interface CellEntry {
|
|
1605
|
+
type: "real" | "absorbed";
|
|
1606
|
+
cell?: CellNode;
|
|
630
1607
|
}
|
|
631
|
-
|
|
1608
|
+
const tableMap: CellEntry[][] = Array.from({ length: rowCount }, () => []);
|
|
1609
|
+
|
|
1610
|
+
for (let ri = 0; ri < rowCount; ri++) {
|
|
1611
|
+
let ci = 0;
|
|
1612
|
+
for (const cell of grid.kids[ri].kids) {
|
|
1613
|
+
while (tableMap[ri][ci]) ci++;
|
|
1614
|
+
tableMap[ri][ci] = { type: "real", cell };
|
|
1615
|
+
for (let rr = 0; rr < cell.rs; rr++) {
|
|
1616
|
+
const tri = ri + rr;
|
|
1617
|
+
if (tri >= rowCount) break;
|
|
1618
|
+
for (let cc = 0; cc < cell.cs; cc++) {
|
|
1619
|
+
if (rr === 0 && cc === 0) continue;
|
|
1620
|
+
tableMap[tri][ci + cc] = { type: "absorbed" };
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
ci += cell.cs;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
let colCount = 0;
|
|
1628
|
+
for (let ri = 0; ri < rowCount; ri++)
|
|
1629
|
+
colCount = Math.max(colCount, tableMap[ri].length);
|
|
1630
|
+
if (colCount === 0) colCount = 1;
|
|
632
1631
|
|
|
633
|
-
//
|
|
634
|
-
const
|
|
635
|
-
const defaultColW = Math.round(totalWidth / (colCount || 1));
|
|
1632
|
+
// 컬럼 너비 계산 (Bug 6: 균등 배분 금지, 원본 보존)
|
|
1633
|
+
const totalW = ctx.availableWidth;
|
|
636
1634
|
const colWidths: number[] = [];
|
|
1635
|
+
|
|
637
1636
|
if (grid.props.colWidths && grid.props.colWidths.length === colCount) {
|
|
638
|
-
//
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
const zeroCount = srcPt.filter((w) => w <= 0).length;
|
|
642
|
-
const remaining = Math.max(0, Metric.hwpToPt(totalWidth) - knownTotal);
|
|
643
|
-
const zeroFill = zeroCount > 0 ? remaining / zeroCount : 0;
|
|
644
|
-
for (let i = 0; i < srcPt.length; i++) {
|
|
645
|
-
if (srcPt[i] <= 0)
|
|
646
|
-
srcPt[i] = zeroFill > 0 ? zeroFill : Metric.hwpToPt(defaultColW);
|
|
1637
|
+
// pt -> HWPUNIT 변환하여 원본 값 보존
|
|
1638
|
+
for (const wPt of grid.props.colWidths) {
|
|
1639
|
+
colWidths.push(Metric.ptToHwp(wPt));
|
|
647
1640
|
}
|
|
648
|
-
for (const wPt of srcPt) colWidths.push(Metric.ptToHwp(wPt));
|
|
649
1641
|
} else {
|
|
650
|
-
|
|
1642
|
+
// 너비 정보가 없을 때만 균등 배분
|
|
1643
|
+
const defW = Math.round(totalW / colCount);
|
|
1644
|
+
for (let c = 0; c < colCount; c++) colWidths.push(defW);
|
|
651
1645
|
}
|
|
652
|
-
|
|
1646
|
+
|
|
1647
|
+
// 본문 너비 초과 시에만 비율 축소 보정
|
|
653
1648
|
const rawTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
654
|
-
if (rawTotal >
|
|
655
|
-
const scale =
|
|
656
|
-
for (let i = 0; i < colWidths.length; i++)
|
|
657
|
-
colWidths[i] = Math.round(colWidths[i] * scale);
|
|
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
|
+
}
|
|
658
1654
|
}
|
|
659
1655
|
const actualTotal = colWidths.reduce((s, w) => s + w, 0);
|
|
660
1656
|
|
|
661
|
-
//
|
|
662
|
-
const tblBfId = grid.props.defaultStroke
|
|
663
|
-
? addBorderFill(ctx, grid.props.defaultStroke)
|
|
664
|
-
: 2; // default table border
|
|
665
|
-
|
|
666
|
-
// Pre-calculate row heights (max cell height per row)
|
|
1657
|
+
// 행 높이 계산
|
|
667
1658
|
const rowHeights: number[] = [];
|
|
668
|
-
for (
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1659
|
+
for (let ri = 0; ri < rowCount; ri++) {
|
|
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));
|
|
1665
|
+
} else {
|
|
1666
|
+
let maxH = 0;
|
|
1667
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
1668
|
+
const entry = tableMap[ri][ci];
|
|
1669
|
+
if (entry?.type === "real") {
|
|
1670
|
+
const h = estimateCellHeight(entry.cell!, ctx);
|
|
1671
|
+
if (h > maxH) maxH = h;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
rowHeights.push(maxH || Math.round(1000 * 1.6));
|
|
673
1675
|
}
|
|
674
|
-
rowHeights.push(maxH);
|
|
675
1676
|
}
|
|
676
|
-
const
|
|
1677
|
+
const totalH = rowHeights.reduce((s, h) => s + h, 0);
|
|
1678
|
+
|
|
1679
|
+
const defStroke = grid.props.defaultStroke ?? DEFAULT_STROKE;
|
|
1680
|
+
// 표 기본 테두리 — BorderFillBank에서 실제 ID 조회
|
|
1681
|
+
const tblBfId = ctx.borderFillBank.addUniform(defStroke);
|
|
677
1682
|
|
|
678
|
-
// Rows
|
|
679
1683
|
let rowsXml = "";
|
|
680
|
-
for (let ri = 0; ri <
|
|
681
|
-
const row = grid.kids[ri];
|
|
682
|
-
const rowH = rowHeights[ri];
|
|
1684
|
+
for (let ri = 0; ri < rowCount; ri++) {
|
|
683
1685
|
let cellsXml = "";
|
|
684
|
-
let
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
ctx,
|
|
693
|
-
grid.props.defaultStroke ?? DEFAULT_STROKE,
|
|
694
|
-
cell.props.bg,
|
|
695
|
-
);
|
|
696
|
-
}
|
|
1686
|
+
for (let ci = 0; ci < colCount; ci++) {
|
|
1687
|
+
const entry = tableMap[ri][ci];
|
|
1688
|
+
if (!entry || entry.type === "absorbed") continue;
|
|
1689
|
+
const cell = entry.cell!;
|
|
1690
|
+
const cp = cell.props;
|
|
1691
|
+
|
|
1692
|
+
// 셀 테두리 — BorderFillBank에서 실제 ID 조회 (하드코딩 제거)
|
|
1693
|
+
const cellBfId = ctx.borderFillBank.addFromCellProps(cp, defStroke);
|
|
697
1694
|
|
|
698
|
-
// Calculate cell width from column widths
|
|
699
1695
|
let cellW = 0;
|
|
700
|
-
for (
|
|
701
|
-
let sc = colIdx;
|
|
702
|
-
sc < colIdx + cell.cs && sc < colWidths.length;
|
|
703
|
-
sc++
|
|
704
|
-
)
|
|
1696
|
+
for (let sc = ci; sc < ci + cell.cs && sc < colWidths.length; sc++)
|
|
705
1697
|
cellW += colWidths[sc];
|
|
706
|
-
if (cellW
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
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
|
+
}
|
|
715
1725
|
|
|
716
|
-
|
|
717
|
-
|
|
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>`;
|
|
718
1740
|
}
|
|
719
1741
|
rowsXml += `<hp:tr>${cellsXml}</hp:tr>`;
|
|
720
1742
|
}
|
|
721
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
|
+
|
|
722
1750
|
const headerRow = grid.props.headerRow ? ' repeatHeader="1"' : "";
|
|
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
|
+
}
|
|
723
1762
|
|
|
724
|
-
|
|
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;
|
|
725
1784
|
}
|
|
726
1785
|
|
|
1786
|
+
// ─── 미리보기 텍스트 추출 ────────────────────────────────────
|
|
1787
|
+
|
|
727
1788
|
function extractPreviewText(sheet?: SheetNode): string {
|
|
728
1789
|
if (!sheet) return "";
|
|
729
1790
|
const lines: string[] = [];
|
|
730
1791
|
for (const kid of sheet.kids) {
|
|
731
1792
|
if (kid.tag === "para") {
|
|
732
1793
|
const text = kid.kids
|
|
733
|
-
.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
return "";
|
|
739
|
-
})
|
|
1794
|
+
.flatMap((k) =>
|
|
1795
|
+
k.tag === "span"
|
|
1796
|
+
? k.kids.flatMap((c) => (c.tag === "txt" ? [c.content] : []))
|
|
1797
|
+
: [],
|
|
1798
|
+
)
|
|
740
1799
|
.join("");
|
|
741
1800
|
if (text) lines.push(text);
|
|
742
1801
|
} else if (kid.tag === "grid") {
|
|
743
1802
|
for (const row of kid.kids) {
|
|
744
1803
|
const cells = row.kids.map((cell) =>
|
|
745
1804
|
cell.kids
|
|
746
|
-
.
|
|
747
|
-
p.
|
|
748
|
-
.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
})
|
|
755
|
-
.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
|
+
: [],
|
|
756
1813
|
)
|
|
757
1814
|
.join(""),
|
|
758
1815
|
);
|
|
@@ -763,7 +1820,18 @@ function extractPreviewText(sheet?: SheetNode): string {
|
|
|
763
1820
|
return lines.join("\r\n");
|
|
764
1821
|
}
|
|
765
1822
|
|
|
1823
|
+
// ─── XML 이스케이프 ──────────────────────────────────────────
|
|
1824
|
+
|
|
766
1825
|
function esc(s: string): string {
|
|
1826
|
+
if (!s) return "";
|
|
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
|
+
);
|
|
767
1835
|
return TextKit.escapeXml(s);
|
|
768
1836
|
}
|
|
769
1837
|
|