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