hwpkit-dev 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ .npmignore +4 -1
- package/README.md +39 -2
- package/dist/index.d.mts +74 -16
- package/dist/index.d.ts +70 -16
- package/dist/index.js +4985 -698
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4981 -698
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -1
- package/playground/index.html +346 -0
- package/playground/main.ts +302 -0
- package/playground/vite.config.ts +16 -0
- package/src/contract/decoder.ts +1 -0
- package/src/contract/encoder.ts +6 -1
- package/src/core/BaseDecoder.ts +118 -0
- package/src/core/BaseEncoder.ts +146 -0
- package/src/decoders/docx/DocxDecoder.ts +867 -150
- package/src/decoders/html/HtmlDecoder.ts +366 -0
- package/src/decoders/hwp/HwpScanner.ts +477 -88
- package/src/decoders/hwpx/HwpxDecoder.ts +789 -293
- package/src/decoders/md/MdDecoder.ts +4 -4
- package/src/encoders/docx/DocxEncoder.ts +600 -295
- package/src/encoders/html/HtmlEncoder.ts +203 -0
- package/src/encoders/hwp/HwpEncoder.ts +1647 -398
- package/src/encoders/hwpx/HwpxEncoder.ts +1512 -444
- package/src/encoders/hwpx/constants.ts +148 -0
- package/src/encoders/hwpx/utils.ts +198 -0
- package/src/encoders/md/MdEncoder.ts +117 -30
- package/src/index.ts +1 -0
- package/src/model/builders.ts +8 -6
- package/src/model/doc-props.ts +19 -5
- package/src/model/doc-tree.ts +13 -5
- package/src/pipeline/Pipeline.ts +21 -4
- package/src/pipeline/registry.ts +13 -2
- package/src/safety/StyleBridge.ts +52 -7
- package/src/toolkit/ArchiveKit.ts +56 -0
- package/src/toolkit/StyleMapper.ts +221 -0
- package/src/toolkit/UnitConverter.ts +138 -0
- package/src/toolkit/XmlKit.ts +0 -5
- package/test-styling.ts +210 -0
|
@@ -1,186 +1,309 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HwpEncoder — DocRoot → HWP 5.0 바이너리 (OLE2/CFB 컨테이너)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ANYTOHWP에서 영감받은 개선 사항:
|
|
5
|
+
* 1. HwpStyleBank — 7개 언어 그룹 독립 폰트 레지스트리 (HANGUL/LATIN/HANJA/…)
|
|
6
|
+
* 2. readPixelDims — PNG/JPEG 바이너리 헤더에서 픽셀 치수 추출 → 정확한 HWPUNIT 변환
|
|
7
|
+
* 3. mkIdMappings — 언어별 폰트 카운트를 개별 기록
|
|
8
|
+
* 4. mkCharShape — 언어별 faceId[7] 사용
|
|
9
|
+
*
|
|
10
|
+
* OLE2 레이아웃:
|
|
11
|
+
* FileHeader (stream) — 256-byte HWP 시그니처 + 플래그
|
|
12
|
+
* DocInfo (stream) — zlib 압축된 FACE_NAME / CHAR_SHAPE / PARA_SHAPE 레코드
|
|
7
13
|
* BodyText (storage)
|
|
8
|
-
* Section0 (stream) —
|
|
14
|
+
* Section0 (stream) — zlib 압축된 PAGE_DEF + 문단/표 레코드
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
import type {
|
|
18
|
+
DocRoot,
|
|
19
|
+
ContentNode,
|
|
20
|
+
ParaNode,
|
|
21
|
+
SpanNode,
|
|
22
|
+
GridNode,
|
|
23
|
+
ImgNode,
|
|
24
|
+
LinkNode,
|
|
25
|
+
} from "../../model/doc-tree";
|
|
26
|
+
import type { Outcome } from "../../contract/result";
|
|
27
|
+
import type {
|
|
28
|
+
TextProps,
|
|
29
|
+
ParaProps,
|
|
30
|
+
Stroke,
|
|
31
|
+
PageDims,
|
|
32
|
+
Align,
|
|
33
|
+
} from "../../model/doc-props";
|
|
34
|
+
import { succeed, fail } from "../../contract/result";
|
|
35
|
+
import { Metric, safeFontToKr } from "../../safety/StyleBridge";
|
|
36
|
+
import { registry } from "../../pipeline/registry";
|
|
37
|
+
import { A4 } from "../../model/doc-props";
|
|
38
|
+
import pako from "pako";
|
|
39
|
+
import { TextKit } from "../../toolkit/TextKit";
|
|
40
|
+
import { BaseEncoder } from "../../core/BaseEncoder";
|
|
41
|
+
|
|
42
|
+
// ─── HWP 5.0 태그 ID ────────────────────────────────────────
|
|
25
43
|
const T = 16; // HWPTAG_BEGIN
|
|
26
|
-
const TAG_ID_MAPPINGS = T + 8; // 24
|
|
27
|
-
const TAG_FACE_NAME = T + 3; // 19
|
|
28
|
-
const TAG_BORDER_FILL = T + 4; // 20
|
|
29
|
-
const TAG_CHAR_SHAPE = T + 5; // 21
|
|
30
|
-
const TAG_PARA_SHAPE = T + 9; // 25
|
|
31
|
-
const TAG_PARA_HEADER = T + 50; // 66
|
|
32
|
-
const TAG_PARA_TEXT = T + 51; // 67
|
|
33
|
-
const TAG_PARA_CHAR_SHAPE = T + 52; // 68
|
|
34
|
-
const TAG_CTRL_HEADER = T + 55; // 71
|
|
35
|
-
const TAG_LIST_HEADER = T + 56; // 72
|
|
36
|
-
const TAG_PAGE_DEF = T + 57; // 73
|
|
37
|
-
const TAG_TABLE_B = T + 64; // 80
|
|
38
|
-
|
|
39
|
-
const CTRL_TABLE = 0x74626C20; // ' lbt' as LE uint32
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
// DocInfo 태그
|
|
46
|
+
const TAG_DOCUMENT_PROPERTIES = T + 0; // 16
|
|
47
|
+
const TAG_ID_MAPPINGS = T + 1; // 17
|
|
48
|
+
const TAG_BIN_DATA = T + 2; // 18
|
|
49
|
+
const TAG_FACE_NAME = T + 3; // 19
|
|
50
|
+
const TAG_BORDER_FILL = T + 4; // 20
|
|
51
|
+
const TAG_CHAR_SHAPE = T + 5; // 21
|
|
52
|
+
const TAG_PARA_SHAPE = T + 9; // 25
|
|
53
|
+
const TAG_STYLE = T + 10; // 26
|
|
54
|
+
|
|
55
|
+
// BodyText 태그
|
|
56
|
+
const TAG_PARA_HEADER = T + 50; // 66
|
|
57
|
+
const TAG_PARA_TEXT = T + 51; // 67
|
|
58
|
+
const TAG_PARA_CHAR_SHAPE = T + 52; // 68
|
|
59
|
+
const TAG_PARA_LINE_SEG = T + 53; // 69
|
|
60
|
+
const TAG_CTRL_HEADER = T + 55; // 71
|
|
61
|
+
const TAG_LIST_HEADER = T + 56; // 72
|
|
62
|
+
const TAG_PAGE_DEF = T + 57; // 73
|
|
63
|
+
const TAG_FOOTNOTE_SHAPE = T + 58; // 74
|
|
64
|
+
const TAG_TABLE = T + 61; // 77
|
|
65
|
+
const TAG_SHAPE_COMPONENT_PICTURE = T + 69; // 85
|
|
66
|
+
|
|
67
|
+
// Control ID (LE UINT32)
|
|
68
|
+
const CTRL_TABLE = 0x74626c20; // 'tbl '
|
|
69
|
+
const CTRL_SECD = 0x73656364; // 'secd'
|
|
70
|
+
const CTRL_PIC = 0x24706963; // '$pic'
|
|
71
|
+
const CTRL_FIELD_BEGIN = 0x646c6625; // '%fld'
|
|
72
|
+
const CTRL_FIELD_END = 0x646c665c; // '\fld'
|
|
73
|
+
|
|
74
|
+
/** 테두리선 굵기 인덱스 테이블 (pt) */
|
|
42
75
|
const BORDER_W_PT = [
|
|
43
|
-
0.28, 0.34, 0.43, 0.57, 0.71, 0.85,
|
|
44
|
-
|
|
45
|
-
5.67, 8.50, 11.34, 14.17,
|
|
76
|
+
0.28, 0.34, 0.43, 0.57, 0.71, 0.85, 1.13, 1.42, 1.7, 1.98, 2.84, 4.25, 5.67,
|
|
77
|
+
8.5, 11.34, 14.17,
|
|
46
78
|
];
|
|
47
79
|
|
|
48
80
|
const BORDER_KIND_IDX: Record<string, number> = {
|
|
49
|
-
|
|
81
|
+
solid: 0,
|
|
82
|
+
dot: 1,
|
|
83
|
+
dash: 2,
|
|
84
|
+
double: 7,
|
|
85
|
+
triple: 8,
|
|
86
|
+
none: 0,
|
|
50
87
|
};
|
|
88
|
+
|
|
51
89
|
const ALIGN_CODE: Record<string, number> = {
|
|
52
|
-
justify: 0,
|
|
90
|
+
justify: 0,
|
|
91
|
+
left: 1,
|
|
92
|
+
right: 2,
|
|
93
|
+
center: 3,
|
|
94
|
+
distribute: 4,
|
|
53
95
|
};
|
|
54
96
|
|
|
55
|
-
|
|
56
|
-
Binary buffer writer
|
|
57
|
-
═══════════════════════════════════════════════════════════════ */
|
|
97
|
+
// ─── 바이너리 버퍼 라이터 ────────────────────────────────────
|
|
58
98
|
|
|
59
99
|
class BufWriter {
|
|
60
100
|
private chunks: Uint8Array[] = [];
|
|
61
101
|
private _sz = 0;
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
get size() {
|
|
103
|
+
return this._sz;
|
|
104
|
+
}
|
|
64
105
|
|
|
65
106
|
u8(v: number): this {
|
|
66
|
-
this.chunks.push(new Uint8Array([v &
|
|
107
|
+
this.chunks.push(new Uint8Array([v & 0xff]));
|
|
67
108
|
this._sz++;
|
|
68
109
|
return this;
|
|
69
110
|
}
|
|
70
|
-
|
|
71
111
|
u16(v: number): this {
|
|
72
|
-
this.chunks.push(new Uint8Array([v &
|
|
112
|
+
this.chunks.push(new Uint8Array([v & 0xff, (v >> 8) & 0xff]));
|
|
73
113
|
this._sz += 2;
|
|
74
114
|
return this;
|
|
75
115
|
}
|
|
76
|
-
|
|
77
116
|
u32(v: number): this {
|
|
78
117
|
const b = new Uint8Array(4);
|
|
79
|
-
b[0] = v &
|
|
80
|
-
b[1] = (v >>> 8) &
|
|
81
|
-
b[2] = (v >>> 16) &
|
|
82
|
-
b[3] = (v >>> 24) &
|
|
118
|
+
b[0] = v & 0xff;
|
|
119
|
+
b[1] = (v >>> 8) & 0xff;
|
|
120
|
+
b[2] = (v >>> 16) & 0xff;
|
|
121
|
+
b[3] = (v >>> 24) & 0xff;
|
|
83
122
|
this.chunks.push(b);
|
|
84
123
|
this._sz += 4;
|
|
85
124
|
return this;
|
|
86
125
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
126
|
+
i32(v: number): this {
|
|
127
|
+
return this.u32(v < 0 ? v + 0x100000000 : v);
|
|
128
|
+
}
|
|
129
|
+
i16(v: number): this {
|
|
130
|
+
return this.u16(v < 0 ? v + 0x10000 : v);
|
|
131
|
+
}
|
|
132
|
+
bytes(d: Uint8Array): this {
|
|
133
|
+
this.chunks.push(d);
|
|
134
|
+
this._sz += d.length;
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
zeros(n: number): this {
|
|
138
|
+
this.chunks.push(new Uint8Array(n));
|
|
139
|
+
this._sz += n;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
94
142
|
utf16(s: string): this {
|
|
95
143
|
for (let i = 0; i < s.length; i++) this.u16(s.charCodeAt(i));
|
|
96
144
|
return this;
|
|
97
145
|
}
|
|
98
|
-
|
|
99
|
-
/** Write 4-byte COLORREF (R, G, B, 0) from 6-hex string */
|
|
100
146
|
colorRef(hex: string): this {
|
|
101
|
-
const h = (hex ||
|
|
102
|
-
return this
|
|
103
|
-
.u8(parseInt(h.slice(0, 2), 16))
|
|
147
|
+
const h = (hex || "000000").replace("#", "").padStart(6, "0");
|
|
148
|
+
return this.u8(parseInt(h.slice(0, 2), 16))
|
|
104
149
|
.u8(parseInt(h.slice(2, 4), 16))
|
|
105
150
|
.u8(parseInt(h.slice(4, 6), 16))
|
|
106
151
|
.u8(0);
|
|
107
152
|
}
|
|
108
|
-
|
|
109
153
|
build(): Uint8Array {
|
|
110
154
|
const out = new Uint8Array(this._sz);
|
|
111
155
|
let off = 0;
|
|
112
|
-
for (const c of this.chunks) {
|
|
156
|
+
for (const c of this.chunks) {
|
|
157
|
+
out.set(c, off);
|
|
158
|
+
off += c.length;
|
|
159
|
+
}
|
|
113
160
|
return out;
|
|
114
161
|
}
|
|
115
162
|
}
|
|
116
163
|
|
|
117
|
-
|
|
118
|
-
HWP record builder
|
|
119
|
-
Format: 32-bit header = size(12)|level(10)|tag(10)
|
|
120
|
-
If size >= 0xFFF, append UINT32 with actual size.
|
|
121
|
-
═══════════════════════════════════════════════════════════════ */
|
|
164
|
+
// ─── HWP 레코드 빌더 ─────────────────────────────────────────
|
|
122
165
|
|
|
123
166
|
function mkRec(tag: number, level: number, data: Uint8Array): Uint8Array {
|
|
124
167
|
const sz = data.length;
|
|
125
|
-
const enc = Math.min(sz,
|
|
126
|
-
const hdr = (enc << 20) | ((level &
|
|
168
|
+
const enc = Math.min(sz, 0xfff);
|
|
169
|
+
const hdr = (enc << 20) | ((level & 0x3ff) << 10) | (tag & 0x3ff);
|
|
127
170
|
const w = new BufWriter().u32(hdr);
|
|
128
|
-
if (enc >=
|
|
171
|
+
if (enc >= 0xfff) w.u32(sz);
|
|
129
172
|
w.bytes(data);
|
|
130
173
|
return w.build();
|
|
131
174
|
}
|
|
132
175
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
// ─── ANYTOHWP 영감: PNG/JPEG 바이너리 헤더에서 픽셀 치수 추출
|
|
177
|
+
function readPixelDims(
|
|
178
|
+
data: Uint8Array,
|
|
179
|
+
mime: string,
|
|
180
|
+
): { w: number; h: number } | null {
|
|
181
|
+
try {
|
|
182
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
183
|
+
if (mime.includes("png")) {
|
|
184
|
+
if (
|
|
185
|
+
data.length >= 24 &&
|
|
186
|
+
view.getUint32(0) === 0x89504e47 &&
|
|
187
|
+
view.getUint32(4) === 0x0d0a1a0a
|
|
188
|
+
) {
|
|
189
|
+
return { w: view.getUint32(16), h: view.getUint32(20) };
|
|
190
|
+
}
|
|
191
|
+
} else if (mime.includes("jpeg") || mime.includes("jpg")) {
|
|
192
|
+
let off = 2;
|
|
193
|
+
while (off < data.length - 4) {
|
|
194
|
+
const marker = view.getUint16(off);
|
|
195
|
+
off += 2;
|
|
196
|
+
if (marker === 0xffc0 || marker === 0xffc2) {
|
|
197
|
+
return { w: view.getUint16(off + 5), h: view.getUint16(off + 3) };
|
|
198
|
+
}
|
|
199
|
+
if ((marker & 0xff00) !== 0xff00) break;
|
|
200
|
+
off += view.getUint16(off);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* 무시 */
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
144
207
|
}
|
|
145
|
-
|
|
146
|
-
|
|
208
|
+
|
|
209
|
+
// ─── ANYTOHWP 영감: HwpStyleBank — 7개 언어 그룹 독립 폰트 레지스트리
|
|
210
|
+
// StyleCollector를 대체하는 새로운 스타일 수집기
|
|
211
|
+
|
|
212
|
+
const LANG_GROUPS = [
|
|
213
|
+
"HANGUL",
|
|
214
|
+
"LATIN",
|
|
215
|
+
"HANJA",
|
|
216
|
+
"JAPANESE",
|
|
217
|
+
"OTHER",
|
|
218
|
+
"SYMBOL",
|
|
219
|
+
"USER",
|
|
220
|
+
] as const;
|
|
221
|
+
type LangGroup = (typeof LANG_GROUPS)[number];
|
|
222
|
+
|
|
223
|
+
/** 한글 폰트 여부 판별 */
|
|
224
|
+
function isKoreanFont(face: string): boolean {
|
|
225
|
+
return (
|
|
226
|
+
/[\uAC00-\uD7A3\u3131-\u318E]/.test(face) ||
|
|
227
|
+
["맑은", "나눔", "굴림", "돋움", "바탕", "함초롬", "한컴", "HY"].some((k) =>
|
|
228
|
+
face.includes(k),
|
|
229
|
+
)
|
|
230
|
+
);
|
|
147
231
|
}
|
|
148
232
|
|
|
149
|
-
class
|
|
150
|
-
readonly DEF_STROKE: Stroke = { kind:
|
|
233
|
+
class HwpStyleBank {
|
|
234
|
+
readonly DEF_STROKE: Stroke = { kind: "solid", pt: 0.5, color: "000000" };
|
|
151
235
|
|
|
152
|
-
|
|
153
|
-
private
|
|
236
|
+
// 언어별 독립 폰트 목록 (ANYTOHWP langFontFaces)
|
|
237
|
+
private langFonts = new Map<LangGroup, string[]>(
|
|
238
|
+
LANG_GROUPS.map((g) => [g, []]),
|
|
239
|
+
);
|
|
240
|
+
private langFontIdx = new Map<LangGroup, Map<string, number>>(
|
|
241
|
+
LANG_GROUPS.map((g) => [g, new Map()]),
|
|
242
|
+
);
|
|
154
243
|
|
|
155
|
-
|
|
244
|
+
// charShape, parShape, borderFill 레지스트리
|
|
245
|
+
readonly csProps: TextProps[] = [{}];
|
|
156
246
|
private csIdx = new Map<string, number>([[csKey({}), 0]]);
|
|
157
247
|
|
|
158
|
-
psProps: ParaProps[] = [{}];
|
|
248
|
+
readonly psProps: ParaProps[] = [{}];
|
|
159
249
|
private psIdx = new Map<string, number>([[psKey({}), 0]]);
|
|
160
250
|
|
|
161
|
-
bfData:
|
|
251
|
+
readonly bfData: BfEntry[] = [];
|
|
162
252
|
private bfIdx = new Map<string, number>();
|
|
163
253
|
|
|
254
|
+
// charShape마다 언어별 fontId를 기록
|
|
255
|
+
readonly csFontIds: number[][] = [[0, 0, 0, 0, 0, 0, 0]]; // id=0 → 모두 0
|
|
256
|
+
|
|
164
257
|
constructor() {
|
|
258
|
+
// 기본 폰트 등록 (ANYTOHWP: 함초롬바탕)
|
|
259
|
+
for (const g of LANG_GROUPS) this._registerLangFont(g, "함초롬바탕");
|
|
165
260
|
this.addBorderFill(this.DEF_STROKE); // bfId=1
|
|
166
261
|
}
|
|
167
262
|
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
const id = this.
|
|
172
|
-
this.
|
|
173
|
-
|
|
263
|
+
private _registerLangFont(lang: LangGroup, face: string): number {
|
|
264
|
+
const idx = this.langFontIdx.get(lang)!;
|
|
265
|
+
if (idx.has(face)) return idx.get(face)!;
|
|
266
|
+
const id = this.langFonts.get(lang)!.length;
|
|
267
|
+
this.langFonts.get(lang)!.push(face);
|
|
268
|
+
idx.set(face, id);
|
|
174
269
|
return id;
|
|
175
270
|
}
|
|
176
271
|
|
|
272
|
+
/** 폰트 이름 → 언어별 7개 ID 반환 (ANYTOHWP 방식) */
|
|
273
|
+
registerFontForLangs(rawFace: string): number[] {
|
|
274
|
+
const face = safeFontToKr(rawFace) || "함초롬바탕";
|
|
275
|
+
const isKor = isKoreanFont(face);
|
|
276
|
+
const hangulFace = isKor ? face : "함초롬바탕";
|
|
277
|
+
const latinFace = isKor ? "함초롬바탕" : face;
|
|
278
|
+
|
|
279
|
+
const ids: number[] = [];
|
|
280
|
+
for (const lang of LANG_GROUPS) {
|
|
281
|
+
const f = lang === "LATIN" ? latinFace : hangulFace;
|
|
282
|
+
ids.push(this._registerLangFont(lang, f));
|
|
283
|
+
}
|
|
284
|
+
return ids; // [hangulId, latinId, hanjaId, japaneseId, otherId, symbolId, userId]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 언어별 폰트 목록 반환 */
|
|
288
|
+
getFontsForLang(lang: LangGroup): string[] {
|
|
289
|
+
return [...(this.langFonts.get(lang) ?? [])];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 폰트 수 반환 (mkIdMappings용) */
|
|
293
|
+
getFontCount(lang: LangGroup): number {
|
|
294
|
+
return this.langFonts.get(lang)?.length ?? 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
177
297
|
addCharShape(p: TextProps): number {
|
|
178
298
|
const k = csKey(p);
|
|
179
299
|
if (this.csIdx.has(k)) return this.csIdx.get(k)!;
|
|
180
300
|
const id = this.csProps.length;
|
|
301
|
+
const fIds = p.font
|
|
302
|
+
? this.registerFontForLangs(p.font)
|
|
303
|
+
: [0, 0, 0, 0, 0, 0, 0];
|
|
181
304
|
this.csProps.push(p);
|
|
305
|
+
this.csFontIds.push(fIds);
|
|
182
306
|
this.csIdx.set(k, id);
|
|
183
|
-
if (p.font) this.font(p.font);
|
|
184
307
|
return id;
|
|
185
308
|
}
|
|
186
309
|
|
|
@@ -193,130 +316,355 @@ class StyleCollector {
|
|
|
193
316
|
return id;
|
|
194
317
|
}
|
|
195
318
|
|
|
196
|
-
/** Returns 1-based border fill ID (HWP uses 1-based IDs for border fills) */
|
|
197
319
|
addBorderFill(s: Stroke, bg?: string): number {
|
|
198
320
|
const k = bfKey(s, bg);
|
|
199
321
|
if (this.bfIdx.has(k)) return this.bfIdx.get(k)!;
|
|
200
322
|
const id = this.bfData.length + 1;
|
|
201
|
-
this.bfData.push({ s, bg });
|
|
323
|
+
this.bfData.push({ uniform: true, s, bg });
|
|
324
|
+
this.bfIdx.set(k, id);
|
|
325
|
+
return id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
addBorderFillPerSide(
|
|
329
|
+
l: Stroke,
|
|
330
|
+
r: Stroke,
|
|
331
|
+
t: Stroke,
|
|
332
|
+
b: Stroke,
|
|
333
|
+
bg?: string,
|
|
334
|
+
): number {
|
|
335
|
+
const k = bfPerSideKey(l, r, t, b, bg);
|
|
336
|
+
if (this.bfIdx.has(k)) return this.bfIdx.get(k)!;
|
|
337
|
+
const id = this.bfData.length + 1;
|
|
338
|
+
this.bfData.push({ uniform: false, l, r, t, b, bg });
|
|
202
339
|
this.bfIdx.set(k, id);
|
|
203
340
|
return id;
|
|
204
341
|
}
|
|
205
342
|
}
|
|
206
343
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
344
|
+
// ─── 키 함수 ────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function csKey(p: TextProps): string {
|
|
347
|
+
return [
|
|
348
|
+
p.font ?? "",
|
|
349
|
+
p.pt ?? 10,
|
|
350
|
+
p.b ? 1 : 0,
|
|
351
|
+
p.i ? 1 : 0,
|
|
352
|
+
p.u ? 1 : 0,
|
|
353
|
+
p.s ? 1 : 0,
|
|
354
|
+
p.sup ? 1 : 0,
|
|
355
|
+
p.sub ? 1 : 0,
|
|
356
|
+
p.color ?? "000000",
|
|
357
|
+
].join("|");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function psKey(p: ParaProps): string {
|
|
361
|
+
return [
|
|
362
|
+
p.align ?? "left",
|
|
363
|
+
p.indentPt ?? 0,
|
|
364
|
+
p.firstLineIndentPt ?? 0,
|
|
365
|
+
p.spaceBefore ?? 0,
|
|
366
|
+
p.spaceAfter ?? 0,
|
|
367
|
+
p.lineHeight ?? 1,
|
|
368
|
+
].join("|");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function bfKey(s: Stroke, bg?: string): string {
|
|
372
|
+
return `${s.kind}|${s.pt}|${s.color}|${bg ?? ""}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function bfPerSideKey(
|
|
376
|
+
l: Stroke,
|
|
377
|
+
r: Stroke,
|
|
378
|
+
t: Stroke,
|
|
379
|
+
b: Stroke,
|
|
380
|
+
bg?: string,
|
|
381
|
+
): string {
|
|
382
|
+
return `${bfKey(l)}/${bfKey(r)}/${bfKey(t)}/${bfKey(b)}/${bg ?? ""}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
type BfEntry =
|
|
386
|
+
| { uniform: true; s: Stroke; bg?: string }
|
|
387
|
+
| { uniform: false; l: Stroke; r: Stroke; t: Stroke; b: Stroke; bg?: string };
|
|
388
|
+
|
|
389
|
+
// ─── Pre-scan: 스타일 수집 ──────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function collectNode(node: ContentNode, bank: HwpStyleBank): void {
|
|
392
|
+
if (node.tag === "para") {
|
|
393
|
+
bank.addParaShape(node.props);
|
|
210
394
|
for (const kid of node.kids) {
|
|
211
|
-
if (kid.tag ===
|
|
395
|
+
if (kid.tag === "span") bank.addCharShape((kid as SpanNode).props);
|
|
212
396
|
}
|
|
213
|
-
} else if (node.tag ===
|
|
214
|
-
if (node.props.defaultStroke)
|
|
397
|
+
} else if (node.tag === "grid") {
|
|
398
|
+
if (node.props.defaultStroke) bank.addBorderFill(node.props.defaultStroke);
|
|
215
399
|
for (const row of node.kids) {
|
|
216
400
|
for (const cell of row.kids) {
|
|
217
|
-
|
|
218
|
-
|
|
401
|
+
const defStroke = node.props.defaultStroke ?? bank.DEF_STROKE;
|
|
402
|
+
const cp = cell.props;
|
|
403
|
+
if (cp.top || cp.bot || cp.left || cp.right) {
|
|
404
|
+
bank.addBorderFillPerSide(
|
|
405
|
+
cp.left ?? defStroke,
|
|
406
|
+
cp.right ?? defStroke,
|
|
407
|
+
cp.top ?? defStroke,
|
|
408
|
+
cp.bot ?? defStroke,
|
|
409
|
+
cp.bg,
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
bank.addBorderFill(defStroke, cp.bg);
|
|
413
|
+
}
|
|
414
|
+
for (const para of cell.kids) collectNode(para, bank);
|
|
219
415
|
}
|
|
220
416
|
}
|
|
221
417
|
}
|
|
222
418
|
}
|
|
223
419
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
420
|
+
// ─── DocInfo 레코드 빌더 ─────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
function mkDocumentProperties(): Uint8Array {
|
|
423
|
+
return new BufWriter()
|
|
424
|
+
.u16(1)
|
|
425
|
+
.u16(1)
|
|
426
|
+
.u16(1)
|
|
427
|
+
.u16(1)
|
|
428
|
+
.u16(1)
|
|
429
|
+
.u16(1)
|
|
430
|
+
.u16(1) // 7× UINT16 카운터
|
|
431
|
+
.u32(0)
|
|
432
|
+
.u32(0)
|
|
433
|
+
.u32(0) // 캐럿 위치
|
|
434
|
+
.build(); // 26 bytes
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* HWPTAG_ID_MAPPINGS (72 bytes = 18 × INT32)
|
|
439
|
+
* [0]=binData, [1-7]=7개 언어별 글꼴 수 (ANYTOHWP 방식으로 언어별 독립),
|
|
440
|
+
* [8]=테두리/배경, [9]=글자모양, [10]=탭, [11]=번호, [12]=글머리,
|
|
441
|
+
* [13]=문단모양, [14]=스타일, [15-17]=메모/변경추적
|
|
442
|
+
*/
|
|
443
|
+
function mkIdMappings(bank: HwpStyleBank, nBinData = 0): Uint8Array {
|
|
444
|
+
const w = new BufWriter();
|
|
445
|
+
w.u32(nBinData);
|
|
446
|
+
// [1-7]: 언어별 폰트 수 (ANYTOHWP: langFontFaces별 크기)
|
|
447
|
+
for (const lang of LANG_GROUPS) w.u32(bank.getFontCount(lang));
|
|
448
|
+
w.u32(bank.bfData.length); // [8]
|
|
449
|
+
w.u32(bank.csProps.length); // [9]
|
|
450
|
+
w.u32(0); // [10] tabDef
|
|
451
|
+
w.u32(0); // [11] numbering
|
|
452
|
+
w.u32(0); // [12] bullet
|
|
453
|
+
w.u32(bank.psProps.length); // [13]
|
|
454
|
+
w.u32(1); // [14] style (바탕글 1개)
|
|
455
|
+
w.u32(0); // [15]
|
|
456
|
+
w.u32(0); // [16]
|
|
457
|
+
w.u32(0); // [17]
|
|
458
|
+
return w.build(); // 18 × 4 = 72 bytes
|
|
459
|
+
}
|
|
227
460
|
|
|
228
|
-
function
|
|
461
|
+
function mkStyle(
|
|
462
|
+
name: string,
|
|
463
|
+
engName: string,
|
|
464
|
+
paraPrId: number,
|
|
465
|
+
charPrId: number,
|
|
466
|
+
): Uint8Array {
|
|
229
467
|
return new BufWriter()
|
|
230
|
-
.
|
|
231
|
-
.
|
|
232
|
-
.
|
|
233
|
-
.
|
|
234
|
-
.
|
|
235
|
-
.
|
|
236
|
-
.
|
|
237
|
-
.
|
|
468
|
+
.u16(name.length)
|
|
469
|
+
.utf16(name)
|
|
470
|
+
.u16(engName.length)
|
|
471
|
+
.utf16(engName)
|
|
472
|
+
.u16(paraPrId)
|
|
473
|
+
.u16(charPrId)
|
|
474
|
+
.u16(0)
|
|
475
|
+
.u16(1042)
|
|
476
|
+
.u16(0)
|
|
238
477
|
.build();
|
|
239
478
|
}
|
|
240
479
|
|
|
241
480
|
function mkFaceName(name: string): Uint8Array {
|
|
242
481
|
return new BufWriter()
|
|
243
|
-
.u8(0)
|
|
482
|
+
.u8(0)
|
|
244
483
|
.u16(name.length)
|
|
245
484
|
.utf16(name)
|
|
485
|
+
.u8(0)
|
|
486
|
+
.u16(0)
|
|
487
|
+
.zeros(10)
|
|
488
|
+
.u16(0)
|
|
246
489
|
.build();
|
|
247
490
|
}
|
|
248
491
|
|
|
249
492
|
function borderWidthIdx(pt: number): number {
|
|
250
493
|
let best = 0;
|
|
251
494
|
for (let i = 0; i < BORDER_W_PT.length; i++) {
|
|
252
|
-
if (Math.abs(BORDER_W_PT[i] - pt) < Math.abs(BORDER_W_PT[best] - pt))
|
|
495
|
+
if (Math.abs(BORDER_W_PT[i] - pt) < Math.abs(BORDER_W_PT[best] - pt))
|
|
496
|
+
best = i;
|
|
253
497
|
}
|
|
254
498
|
return best;
|
|
255
499
|
}
|
|
256
500
|
|
|
257
501
|
function mkBorderFill(s: Stroke, bg?: string): Uint8Array {
|
|
258
502
|
const w = new BufWriter();
|
|
259
|
-
|
|
260
|
-
const t = BORDER_KIND_IDX[s.kind] ?? 1;
|
|
503
|
+
const t = BORDER_KIND_IDX[s.kind] ?? 0;
|
|
261
504
|
const wi = borderWidthIdx(s.pt);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
505
|
+
const col = s.color || "000000";
|
|
506
|
+
w.u16(0);
|
|
507
|
+
for (let i = 0; i < 4; i++) w.u8(t);
|
|
508
|
+
for (let i = 0; i < 4; i++) w.u8(wi);
|
|
509
|
+
for (let i = 0; i < 4; i++) w.colorRef(col);
|
|
510
|
+
w.u8(0).u8(0).colorRef("000000");
|
|
511
|
+
if (bg) {
|
|
512
|
+
w.u32(1).colorRef(bg).colorRef("FFFFFF").u32(0);
|
|
513
|
+
} else {
|
|
514
|
+
w.u32(0);
|
|
515
|
+
}
|
|
516
|
+
return w.build();
|
|
268
517
|
}
|
|
269
518
|
|
|
270
|
-
function
|
|
271
|
-
|
|
519
|
+
function mkBorderFillPerSide(
|
|
520
|
+
l: Stroke,
|
|
521
|
+
r: Stroke,
|
|
522
|
+
t: Stroke,
|
|
523
|
+
b: Stroke,
|
|
524
|
+
bg?: string,
|
|
525
|
+
): Uint8Array {
|
|
272
526
|
const w = new BufWriter();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
527
|
+
w.u16(0);
|
|
528
|
+
w.u8(BORDER_KIND_IDX[l.kind] ?? 0)
|
|
529
|
+
.u8(BORDER_KIND_IDX[r.kind] ?? 0)
|
|
530
|
+
.u8(BORDER_KIND_IDX[t.kind] ?? 0)
|
|
531
|
+
.u8(BORDER_KIND_IDX[b.kind] ?? 0);
|
|
532
|
+
w.u8(borderWidthIdx(l.pt))
|
|
533
|
+
.u8(borderWidthIdx(r.pt))
|
|
534
|
+
.u8(borderWidthIdx(t.pt))
|
|
535
|
+
.u8(borderWidthIdx(b.pt));
|
|
536
|
+
w.colorRef(l.color || "000000")
|
|
537
|
+
.colorRef(r.color || "000000")
|
|
538
|
+
.colorRef(t.color || "000000")
|
|
539
|
+
.colorRef(b.color || "000000");
|
|
540
|
+
w.u8(0).u8(0).colorRef("000000");
|
|
541
|
+
if (bg) {
|
|
542
|
+
w.u32(1).colorRef(bg).colorRef("FFFFFF").u32(0);
|
|
543
|
+
} else {
|
|
544
|
+
w.u32(0);
|
|
545
|
+
}
|
|
546
|
+
return w.build();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* HWPTAG_CHAR_SHAPE (74 bytes)
|
|
551
|
+
* ANYTOHWP 개선: faceId[7]에 언어별 ID를 개별 기록
|
|
552
|
+
*/
|
|
553
|
+
function mkCharShape(fontIds: number[], p: TextProps): Uint8Array {
|
|
554
|
+
const height = Math.round((p.pt ?? 10) * 100);
|
|
281
555
|
let attr = 0;
|
|
282
|
-
if (p.i)
|
|
283
|
-
if (p.b)
|
|
284
|
-
if (p.u)
|
|
285
|
-
if (p.s)
|
|
286
|
-
if (p.sup) attr |=
|
|
287
|
-
if (p.sub) attr |=
|
|
288
|
-
|
|
289
|
-
w
|
|
290
|
-
|
|
291
|
-
|
|
556
|
+
if (p.i) attr |= 1 << 0;
|
|
557
|
+
if (p.b) attr |= 1 << 1;
|
|
558
|
+
if (p.u) attr |= 1 << 2;
|
|
559
|
+
if (p.s) attr |= 1 << 18;
|
|
560
|
+
if (p.sup) attr |= 1 << 16;
|
|
561
|
+
if (p.sub) attr |= 2 << 16;
|
|
562
|
+
|
|
563
|
+
const w = new BufWriter();
|
|
564
|
+
// faceId[7]: 언어별 독립 ID (ANYTOHWP 핵심 개선)
|
|
565
|
+
for (const id of fontIds) w.u16(id);
|
|
566
|
+
for (let i = 0; i < 7; i++) w.u8(100); // ratio
|
|
567
|
+
for (let i = 0; i < 7; i++) w.u8(0); // spacing
|
|
568
|
+
for (let i = 0; i < 7; i++) w.u8(100); // relSize
|
|
569
|
+
for (let i = 0; i < 7; i++) w.u8(0); // offset
|
|
570
|
+
w.i32(height).u32(attr).u8(0).u8(0);
|
|
571
|
+
w.colorRef(p.color ?? "000000");
|
|
572
|
+
w.colorRef("000000"); // underlineColor
|
|
573
|
+
w.colorRef(p.bg ?? "FFFFFF"); // shadeColor
|
|
574
|
+
w.colorRef("000000"); // shadowColor
|
|
575
|
+
w.u16(0); // borderFillId
|
|
576
|
+
w.colorRef("000000"); // strikeColor
|
|
577
|
+
return w.build(); // 74 bytes
|
|
292
578
|
}
|
|
293
579
|
|
|
294
580
|
function mkParaShape(p: ParaProps): Uint8Array {
|
|
581
|
+
const alignVal = ALIGN_CODE[p.align ?? "left"] ?? 1;
|
|
582
|
+
const attr1 = (alignVal & 0x7) << 2;
|
|
583
|
+
const lineSpacePct = p.lineHeight ? Math.round(p.lineHeight * 100) : 160;
|
|
295
584
|
return new BufWriter()
|
|
296
|
-
.u32(
|
|
297
|
-
.i32(Metric.ptToHwp(p.indentPt ?? 0))
|
|
298
|
-
.i32(0)
|
|
299
|
-
.i32(
|
|
585
|
+
.u32(attr1)
|
|
586
|
+
.i32(Metric.ptToHwp(p.indentPt ?? 0))
|
|
587
|
+
.i32(Metric.ptToHwp(p.indentRightPt ?? 0))
|
|
588
|
+
.i32(Metric.ptToHwp(p.firstLineIndentPt ?? 0))
|
|
300
589
|
.i32(Metric.ptToHwp(p.spaceBefore ?? 0))
|
|
301
590
|
.i32(Metric.ptToHwp(p.spaceAfter ?? 0))
|
|
302
|
-
.i32(
|
|
303
|
-
.
|
|
591
|
+
.i32(lineSpacePct)
|
|
592
|
+
.u16(0)
|
|
593
|
+
.u16(0)
|
|
594
|
+
.u16(0)
|
|
595
|
+
.i16(0)
|
|
596
|
+
.i16(0)
|
|
597
|
+
.i16(0)
|
|
598
|
+
.i16(0)
|
|
599
|
+
.u32(0)
|
|
600
|
+
.u32(4)
|
|
601
|
+
.u32(lineSpacePct)
|
|
602
|
+
.build(); // 54 bytes
|
|
304
603
|
}
|
|
305
604
|
|
|
306
|
-
function
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
605
|
+
function mkBinData(id: number, ext: string): Uint8Array {
|
|
606
|
+
return new BufWriter().u16(0x0002).u16(id).u16(ext.length).utf16(ext).build();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
interface BinImage {
|
|
610
|
+
id: number;
|
|
611
|
+
ext: string;
|
|
612
|
+
data: Uint8Array;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* DocInfo 스트림 빌더
|
|
617
|
+
* ANYTOHWP 개선: 언어별 폰트 목록을 순서대로 독립 기록
|
|
618
|
+
*/
|
|
619
|
+
function buildDocInfoStream(
|
|
620
|
+
bank: HwpStyleBank,
|
|
621
|
+
images: BinImage[] = [],
|
|
622
|
+
): Uint8Array {
|
|
623
|
+
const chunks: Uint8Array[] = [];
|
|
624
|
+
|
|
625
|
+
chunks.push(mkRec(TAG_DOCUMENT_PROPERTIES, 0, mkDocumentProperties()));
|
|
626
|
+
chunks.push(mkRec(TAG_ID_MAPPINGS, 1, mkIdMappings(bank, images.length)));
|
|
627
|
+
|
|
628
|
+
for (const img of images) {
|
|
629
|
+
chunks.push(mkRec(TAG_BIN_DATA, 1, mkBinData(img.id, img.ext)));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ANYTOHWP 방식: 언어 그룹별로 독립된 FACE_NAME 레코드 직렬화
|
|
633
|
+
for (const lang of LANG_GROUPS) {
|
|
634
|
+
for (const face of bank.getFontsForLang(lang)) {
|
|
635
|
+
chunks.push(mkRec(TAG_FACE_NAME, 1, mkFaceName(face)));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const entry of bank.bfData) {
|
|
640
|
+
chunks.push(
|
|
641
|
+
mkRec(
|
|
642
|
+
TAG_BORDER_FILL,
|
|
643
|
+
1,
|
|
644
|
+
entry.uniform
|
|
645
|
+
? mkBorderFill(entry.s, entry.bg)
|
|
646
|
+
: mkBorderFillPerSide(entry.l, entry.r, entry.t, entry.b, entry.bg),
|
|
647
|
+
),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// charShape — 언어별 fontId 배열 사용
|
|
652
|
+
for (let i = 0; i < bank.csProps.length; i++) {
|
|
653
|
+
chunks.push(
|
|
654
|
+
mkRec(TAG_CHAR_SHAPE, 1, mkCharShape(bank.csFontIds[i], bank.csProps[i])),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
for (const p of bank.psProps) {
|
|
659
|
+
chunks.push(mkRec(TAG_PARA_SHAPE, 1, mkParaShape(p)));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
chunks.push(mkRec(TAG_STYLE, 1, mkStyle("바탕글", "Normal", 0, 0)));
|
|
663
|
+
|
|
314
664
|
return concatU8(chunks);
|
|
315
665
|
}
|
|
316
666
|
|
|
317
|
-
|
|
318
|
-
BodyText record builders
|
|
319
|
-
═══════════════════════════════════════════════════════════════ */
|
|
667
|
+
// ─── BodyText 레코드 빌더 ────────────────────────────────────
|
|
320
668
|
|
|
321
669
|
function mkPageDef(dims: PageDims): Uint8Array {
|
|
322
670
|
return new BufWriter()
|
|
@@ -326,32 +674,41 @@ function mkPageDef(dims: PageDims): Uint8Array {
|
|
|
326
674
|
.u32(Metric.ptToHwp(dims.mr))
|
|
327
675
|
.u32(Metric.ptToHwp(dims.mt))
|
|
328
676
|
.u32(Metric.ptToHwp(dims.mb))
|
|
329
|
-
.zeros(12)
|
|
330
|
-
.u32(dims.orient ===
|
|
677
|
+
.zeros(12)
|
|
678
|
+
.u32(dims.orient === "landscape" ? 1 : 0)
|
|
331
679
|
.build(); // 40 bytes
|
|
332
680
|
}
|
|
333
681
|
|
|
334
|
-
function mkParaHeader(
|
|
682
|
+
function mkParaHeader(
|
|
683
|
+
nchars: number,
|
|
684
|
+
ctrlMask: number,
|
|
685
|
+
psId: number,
|
|
686
|
+
csCount: number,
|
|
687
|
+
lineAlignCount = 0,
|
|
688
|
+
instanceId = 0,
|
|
689
|
+
): Uint8Array {
|
|
335
690
|
return new BufWriter()
|
|
336
|
-
.u32(
|
|
337
|
-
.
|
|
338
|
-
.
|
|
691
|
+
.u32(nchars)
|
|
692
|
+
.u32(ctrlMask)
|
|
693
|
+
.u16(psId)
|
|
694
|
+
.u8(0)
|
|
339
695
|
.u8(0)
|
|
340
|
-
.u16(
|
|
341
|
-
.u16(
|
|
342
|
-
.u16(
|
|
343
|
-
.
|
|
344
|
-
.
|
|
345
|
-
.build(); //
|
|
696
|
+
.u16(csCount)
|
|
697
|
+
.u16(0)
|
|
698
|
+
.u16(lineAlignCount)
|
|
699
|
+
.u32(instanceId)
|
|
700
|
+
.u16(0)
|
|
701
|
+
.build(); // 24 bytes
|
|
346
702
|
}
|
|
347
703
|
|
|
348
704
|
function mkParaText(text: string): Uint8Array {
|
|
349
705
|
const w = new BufWriter();
|
|
350
706
|
for (let i = 0; i < text.length; i++) {
|
|
351
707
|
const c = text.charCodeAt(i);
|
|
352
|
-
|
|
708
|
+
// 0x09(탭), 0x0A(줄바꿈), 0x03(필드시작), 0x04(필드종료) 등 허용
|
|
709
|
+
w.u16(c);
|
|
353
710
|
}
|
|
354
|
-
w.u16(13); //
|
|
711
|
+
w.u16(13); // 문단 종결자
|
|
355
712
|
return w.build();
|
|
356
713
|
}
|
|
357
714
|
|
|
@@ -361,133 +718,817 @@ function mkParaCharShape(pairs: [pos: number, id: number][]): Uint8Array {
|
|
|
361
718
|
return w.build();
|
|
362
719
|
}
|
|
363
720
|
|
|
364
|
-
|
|
365
|
-
|
|
721
|
+
/**
|
|
722
|
+
* 5가지 LineSpacing(줄 간격) 타입에 따른 height 계산 로직
|
|
723
|
+
*/
|
|
724
|
+
function calcLineHeight(
|
|
725
|
+
type: number,
|
|
726
|
+
value: number,
|
|
727
|
+
textHeight: number,
|
|
728
|
+
): number {
|
|
729
|
+
switch (type) {
|
|
730
|
+
case 0:
|
|
731
|
+
return Math.floor((textHeight * value) / 100);
|
|
732
|
+
case 1:
|
|
733
|
+
return value;
|
|
734
|
+
case 2:
|
|
735
|
+
return Math.max(textHeight, value);
|
|
736
|
+
case 3:
|
|
737
|
+
return textHeight + value;
|
|
738
|
+
case 4:
|
|
739
|
+
return Math.floor(textHeight * value);
|
|
740
|
+
default:
|
|
741
|
+
return Math.floor((textHeight * value) / 100);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* LineSeg 36바이트 구조체 (HWP 5.0 공식 규격)
|
|
747
|
+
*/
|
|
748
|
+
function mkLineSeg(
|
|
749
|
+
textStartPos: number,
|
|
750
|
+
vertPos: number,
|
|
751
|
+
vertSize: number,
|
|
752
|
+
textHeight: number,
|
|
753
|
+
baseline: number,
|
|
754
|
+
spacing: number,
|
|
755
|
+
horzPos: number,
|
|
756
|
+
horzSize: number,
|
|
757
|
+
flags: number,
|
|
758
|
+
): Uint8Array {
|
|
759
|
+
return new BufWriter()
|
|
760
|
+
.u32(textStartPos)
|
|
761
|
+
.i32(vertPos)
|
|
762
|
+
.i32(vertSize)
|
|
763
|
+
.i32(textHeight)
|
|
764
|
+
.i32(baseline)
|
|
765
|
+
.i32(spacing)
|
|
766
|
+
.i32(horzPos)
|
|
767
|
+
.i32(horzSize)
|
|
768
|
+
.u32(flags)
|
|
769
|
+
.build(); // 36 bytes
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function buildDefaultLineSeg(
|
|
773
|
+
availWidthHwp: number,
|
|
774
|
+
fontHwp: number,
|
|
775
|
+
nchars: number,
|
|
776
|
+
paraProps?: ParaProps,
|
|
777
|
+
vertPos = 0,
|
|
778
|
+
): Uint8Array {
|
|
779
|
+
const ratio = paraProps?.lineHeight
|
|
780
|
+
? Math.round(paraProps.lineHeight * 100)
|
|
781
|
+
: 160;
|
|
782
|
+
const vertSize = calcLineHeight(0, ratio, fontHwp);
|
|
783
|
+
const baseline = Math.round(fontHwp * 0.85);
|
|
784
|
+
const spacing = vertSize - fontHwp;
|
|
785
|
+
// flags: bit 0 (페이지 첫 줄), bit 1 (컬럼 첫 줄)
|
|
786
|
+
const flags = 3;
|
|
787
|
+
|
|
788
|
+
return mkLineSeg(
|
|
789
|
+
0,
|
|
790
|
+
vertPos,
|
|
791
|
+
vertSize,
|
|
792
|
+
fontHwp,
|
|
793
|
+
baseline,
|
|
794
|
+
spacing,
|
|
795
|
+
0,
|
|
796
|
+
availWidthHwp,
|
|
797
|
+
flags,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function mkSecdParaText(): Uint8Array {
|
|
802
|
+
const lo = CTRL_SECD & 0xffff;
|
|
803
|
+
const hi = (CTRL_SECD >>> 16) & 0xffff;
|
|
804
|
+
return new BufWriter()
|
|
805
|
+
.u16(0x0002)
|
|
806
|
+
.u16(lo)
|
|
807
|
+
.u16(hi)
|
|
808
|
+
.u16(0)
|
|
809
|
+
.u16(0)
|
|
810
|
+
.u16(0)
|
|
811
|
+
.u16(0)
|
|
812
|
+
.u16(0x0002)
|
|
813
|
+
.u16(0x000d)
|
|
814
|
+
.build();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function mkTableParaText(): Uint8Array {
|
|
818
|
+
const lo = CTRL_TABLE & 0xffff;
|
|
819
|
+
const hi = (CTRL_TABLE >>> 16) & 0xffff;
|
|
820
|
+
return new BufWriter()
|
|
821
|
+
.u16(0x000b)
|
|
822
|
+
.u16(lo)
|
|
823
|
+
.u16(hi)
|
|
824
|
+
.u16(0)
|
|
825
|
+
.u16(0)
|
|
826
|
+
.u16(0)
|
|
827
|
+
.u16(0)
|
|
828
|
+
.u16(0x000b)
|
|
829
|
+
.u16(0x000d)
|
|
830
|
+
.build();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function mkPicParaText(): Uint8Array {
|
|
834
|
+
const lo = CTRL_PIC & 0xffff;
|
|
835
|
+
const hi = (CTRL_PIC >>> 16) & 0xffff;
|
|
836
|
+
return new BufWriter()
|
|
837
|
+
.u16(0x000b)
|
|
838
|
+
.u16(lo)
|
|
839
|
+
.u16(hi)
|
|
840
|
+
.u16(0)
|
|
841
|
+
.u16(0)
|
|
842
|
+
.u16(0)
|
|
843
|
+
.u16(0)
|
|
844
|
+
.u16(0x000b)
|
|
845
|
+
.u16(0x000d)
|
|
846
|
+
.build();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ─── 이미지 관련 레코드 (ANYTOHWP 영감: 픽셀 치수 우선 사용)
|
|
850
|
+
|
|
851
|
+
interface ImgLayout {
|
|
852
|
+
wrap?: string;
|
|
853
|
+
xPt?: number;
|
|
854
|
+
yPt?: number;
|
|
855
|
+
zOrder?: number;
|
|
856
|
+
distL?: number;
|
|
857
|
+
distR?: number;
|
|
858
|
+
distT?: number;
|
|
859
|
+
distB?: number;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function mkShapeComponentPicture(
|
|
863
|
+
binDataId: number,
|
|
864
|
+
wHwp: number,
|
|
865
|
+
hHwp: number,
|
|
866
|
+
): Uint8Array {
|
|
867
|
+
const w = new BufWriter();
|
|
868
|
+
w.u32(CTRL_PIC).zeros(15);
|
|
869
|
+
w.u32(0).u32(0).u32(wHwp).u32(hHwp);
|
|
870
|
+
w.u32(0).u32(0).u32(wHwp).u32(hHwp);
|
|
871
|
+
w.zeros(36);
|
|
872
|
+
w.u16(binDataId).u8(0).u8(0).u8(0).zeros(5);
|
|
873
|
+
return w.build();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function mkObjectCtrl(
|
|
877
|
+
ctrlId: number,
|
|
878
|
+
wHwp: number,
|
|
879
|
+
hHwp: number,
|
|
880
|
+
instanceId: number,
|
|
881
|
+
layout?: ImgLayout,
|
|
882
|
+
): Uint8Array {
|
|
883
|
+
let attr = 0x082a2210;
|
|
884
|
+
if (layout?.wrap === "inline") attr |= 1 << 3;
|
|
885
|
+
return new BufWriter()
|
|
886
|
+
.u32(ctrlId)
|
|
887
|
+
.u32(attr)
|
|
888
|
+
.i32(layout?.yPt ? Metric.ptToHwp(layout.yPt) : 0)
|
|
889
|
+
.i32(layout?.xPt ? Metric.ptToHwp(layout.xPt) : 0)
|
|
890
|
+
.u32(wHwp)
|
|
891
|
+
.u32(hHwp)
|
|
892
|
+
.i32(layout?.zOrder ?? 0)
|
|
893
|
+
.u16(layout?.distL ? Metric.ptToHwp(layout.distL) : 0)
|
|
894
|
+
.u16(layout?.distR ? Metric.ptToHwp(layout.distR) : 0)
|
|
895
|
+
.u16(layout?.distT ? Metric.ptToHwp(layout.distT) : 0)
|
|
896
|
+
.u16(layout?.distB ? Metric.ptToHwp(layout.distB) : 0)
|
|
897
|
+
.u32(instanceId)
|
|
898
|
+
.i32(0)
|
|
899
|
+
.u16(0)
|
|
900
|
+
.build(); // 46 bytes
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function mkFieldBeginCtrl(instanceId: number): Uint8Array {
|
|
904
|
+
// 46-byte Object Control Header for Field
|
|
905
|
+
return new BufWriter()
|
|
906
|
+
.u32(CTRL_FIELD_BEGIN)
|
|
907
|
+
.u32(0x00000002) // 필드 플래그
|
|
908
|
+
.zeros(28) // xy/size 등 불필요 (필드는 비가시)
|
|
909
|
+
.u32(instanceId)
|
|
910
|
+
.zeros(6)
|
|
911
|
+
.build();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function mkFieldEndCtrl(beginId: number): Uint8Array {
|
|
915
|
+
return new BufWriter()
|
|
916
|
+
.u32(CTRL_FIELD_END)
|
|
917
|
+
.u32(0)
|
|
918
|
+
.zeros(28)
|
|
919
|
+
.u32(beginId)
|
|
920
|
+
.zeros(6)
|
|
921
|
+
.build();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 이미지 단락 인코딩
|
|
926
|
+
* ANYTOHWP 개선: PNG/JPEG 픽셀 치수에서 HWPUNIT 계산
|
|
927
|
+
*/
|
|
928
|
+
function encodePicPara(
|
|
929
|
+
imgNode: ImgNode,
|
|
930
|
+
binDataId: number,
|
|
931
|
+
bank: HwpStyleBank,
|
|
932
|
+
lv: number,
|
|
933
|
+
idGen: () => number,
|
|
934
|
+
availWidthHwp: number,
|
|
935
|
+
): Uint8Array[] {
|
|
936
|
+
// ANYTOHWP 방식: 픽셀 치수 추출 시도 → 실패 시 pt 기반으로 폴백
|
|
937
|
+
const rawData = TextKit.base64Decode(imgNode.b64);
|
|
938
|
+
const pixDims = readPixelDims(rawData, imgNode.mime);
|
|
939
|
+
|
|
940
|
+
let wHwp: number, hHwp: number;
|
|
941
|
+
if (pixDims && pixDims.w > 0 && pixDims.h > 0) {
|
|
942
|
+
wHwp = Metric.ptToHwp((pixDims.w * 72) / 96); // px → pt(96dpi) → hwpunit
|
|
943
|
+
hHwp = Metric.ptToHwp((pixDims.h * 72) / 96);
|
|
944
|
+
} else {
|
|
945
|
+
wHwp = Metric.ptToHwp(imgNode.w);
|
|
946
|
+
hHwp = Metric.ptToHwp(imgNode.h);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// 가용 너비 초과 방지
|
|
950
|
+
if (wHwp > availWidthHwp) {
|
|
951
|
+
hHwp = Math.round((hHwp * availWidthHwp) / wHwp);
|
|
952
|
+
wHwp = availWidthHwp;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const CTRL_MASK = 1 << 11;
|
|
956
|
+
const instanceId = idGen();
|
|
957
|
+
const psId = bank.addParaShape({});
|
|
958
|
+
|
|
959
|
+
return [
|
|
960
|
+
mkRec(
|
|
961
|
+
TAG_PARA_HEADER,
|
|
962
|
+
lv,
|
|
963
|
+
mkParaHeader(9, CTRL_MASK, psId, 1, 1, instanceId),
|
|
964
|
+
),
|
|
965
|
+
mkRec(TAG_PARA_TEXT, lv + 1, mkPicParaText()),
|
|
966
|
+
mkRec(TAG_PARA_CHAR_SHAPE, lv + 1, mkParaCharShape([[0, 0]])),
|
|
967
|
+
mkRec(
|
|
968
|
+
TAG_PARA_LINE_SEG,
|
|
969
|
+
lv + 1,
|
|
970
|
+
buildDefaultLineSeg(availWidthHwp, hHwp, 9),
|
|
971
|
+
),
|
|
972
|
+
mkRec(
|
|
973
|
+
TAG_CTRL_HEADER,
|
|
974
|
+
lv + 1,
|
|
975
|
+
mkObjectCtrl(CTRL_PIC, wHwp, hHwp, idGen(), imgNode.layout),
|
|
976
|
+
),
|
|
977
|
+
mkRec(
|
|
978
|
+
TAG_SHAPE_COMPONENT_PICTURE,
|
|
979
|
+
lv + 2,
|
|
980
|
+
mkShapeComponentPicture(binDataId, wHwp, hHwp),
|
|
981
|
+
),
|
|
982
|
+
];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ─── 문단 인코딩 ─────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
function encodePara(
|
|
988
|
+
para: ParaNode,
|
|
989
|
+
bank: HwpStyleBank,
|
|
990
|
+
lv: number,
|
|
991
|
+
instanceId: number,
|
|
992
|
+
availWidthHwp: number,
|
|
993
|
+
mask = 0,
|
|
994
|
+
vertPos = 0,
|
|
995
|
+
): Uint8Array[] {
|
|
996
|
+
let text = "";
|
|
366
997
|
const csPairs: [number, number][] = [];
|
|
367
998
|
let pos = 0;
|
|
999
|
+
let fontHwp = 1000;
|
|
1000
|
+
const ctrlRecords: Uint8Array[] = [];
|
|
368
1001
|
|
|
369
1002
|
for (const kid of para.kids) {
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1003
|
+
if (
|
|
1004
|
+
kid.tag === "span" &&
|
|
1005
|
+
(kid as SpanNode).props.pt &&
|
|
1006
|
+
((kid as SpanNode).props.pt as number) > 0
|
|
1007
|
+
) {
|
|
1008
|
+
fontHwp = Metric.ptToHwp((kid as SpanNode).props.pt as number);
|
|
1009
|
+
break;
|
|
376
1010
|
}
|
|
377
|
-
|
|
378
|
-
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 내부적으로 사용되는 ID 생성기 (단락 내에서 로컬하게 사용)
|
|
1014
|
+
let localIdCounter = 10000;
|
|
1015
|
+
const localIdGen = () => localIdCounter++;
|
|
1016
|
+
|
|
1017
|
+
function processKids(kids: any[]): void {
|
|
1018
|
+
for (const kid of kids) {
|
|
1019
|
+
if (kid.tag === "span") {
|
|
1020
|
+
const span = kid as SpanNode;
|
|
1021
|
+
const csId = bank.addCharShape(span.props);
|
|
1022
|
+
if (!csPairs.length || csPairs[csPairs.length - 1][1] !== csId) {
|
|
1023
|
+
csPairs.push([pos, csId]);
|
|
1024
|
+
}
|
|
1025
|
+
for (const t of span.kids) {
|
|
1026
|
+
if (t.tag === "txt") {
|
|
1027
|
+
text += t.content;
|
|
1028
|
+
pos += t.content.length;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
} else if (kid.tag === "link") {
|
|
1032
|
+
const link = kid as LinkNode;
|
|
1033
|
+
mask |= 1 << 11; // 하이퍼링크 마스크
|
|
1034
|
+
|
|
1035
|
+
const fieldBeginId = localIdGen();
|
|
1036
|
+
// 1. 시작 제어 문자 (0x03)
|
|
1037
|
+
text += String.fromCharCode(3);
|
|
1038
|
+
pos += 1;
|
|
1039
|
+
ctrlRecords.push(
|
|
1040
|
+
mkRec(TAG_CTRL_HEADER, lv + 1, mkFieldBeginCtrl(fieldBeginId)),
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
// 2. 내용 처리
|
|
1044
|
+
processKids(link.kids);
|
|
1045
|
+
|
|
1046
|
+
// 3. 종료 제어 문자 (0x04)
|
|
1047
|
+
text += String.fromCharCode(4);
|
|
1048
|
+
pos += 1;
|
|
1049
|
+
ctrlRecords.push(
|
|
1050
|
+
mkRec(TAG_CTRL_HEADER, lv + 1, mkFieldEndCtrl(fieldBeginId)),
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
379
1053
|
}
|
|
380
1054
|
}
|
|
1055
|
+
processKids(para.kids);
|
|
1056
|
+
if (!csPairs.length) csPairs.push([0, 0]);
|
|
381
1057
|
|
|
382
|
-
|
|
1058
|
+
const psId = bank.addParaShape(para.props);
|
|
1059
|
+
const nchars = text.length + 1;
|
|
383
1060
|
|
|
384
|
-
const psId = col.addParaShape(para.props);
|
|
385
1061
|
return [
|
|
386
|
-
mkRec(
|
|
387
|
-
|
|
1062
|
+
mkRec(
|
|
1063
|
+
TAG_PARA_HEADER,
|
|
1064
|
+
lv,
|
|
1065
|
+
mkParaHeader(nchars, mask, psId, csPairs.length, 1, instanceId),
|
|
1066
|
+
),
|
|
1067
|
+
mkRec(TAG_PARA_TEXT, lv + 1, mkParaText(text)),
|
|
388
1068
|
mkRec(TAG_PARA_CHAR_SHAPE, lv + 1, mkParaCharShape(csPairs)),
|
|
1069
|
+
mkRec(
|
|
1070
|
+
TAG_PARA_LINE_SEG,
|
|
1071
|
+
lv + 1,
|
|
1072
|
+
buildDefaultLineSeg(availWidthHwp, fontHwp, nchars, para.props, vertPos),
|
|
1073
|
+
),
|
|
1074
|
+
...ctrlRecords,
|
|
389
1075
|
];
|
|
390
1076
|
}
|
|
391
1077
|
|
|
392
|
-
|
|
1078
|
+
// ─── 표 인코딩 ───────────────────────────────────────────────
|
|
393
1079
|
|
|
394
|
-
function mkTableCtrl(
|
|
395
|
-
|
|
1080
|
+
function mkTableCtrl(
|
|
1081
|
+
wHwp: number,
|
|
1082
|
+
hHwp: number,
|
|
1083
|
+
instanceId: number,
|
|
1084
|
+
align: Align = "left",
|
|
1085
|
+
): Uint8Array {
|
|
1086
|
+
// 표 정렬 속성 플래그 (HWP 표 제어 문자)
|
|
1087
|
+
// offset 20-21: 속성 플래그 (align: left=0, center=1, right=2, justify=3)
|
|
1088
|
+
const alignFlags = { left: 0, center: 1, right: 2, justify: 3 }[align] ?? 0;
|
|
1089
|
+
return new BufWriter()
|
|
1090
|
+
.u32(CTRL_TABLE)
|
|
1091
|
+
.u32(0x082a2211)
|
|
1092
|
+
.i32(0)
|
|
1093
|
+
.i32(0)
|
|
1094
|
+
.u32(wHwp)
|
|
1095
|
+
.u32(hHwp)
|
|
1096
|
+
.i32(7)
|
|
1097
|
+
.u16(140)
|
|
1098
|
+
.u16(140)
|
|
1099
|
+
.u16(140)
|
|
1100
|
+
.u16(140)
|
|
1101
|
+
.u32(instanceId)
|
|
1102
|
+
.i32(alignFlags)
|
|
1103
|
+
.u16(0)
|
|
1104
|
+
.build(); // 46 bytes
|
|
396
1105
|
}
|
|
397
1106
|
|
|
398
|
-
function
|
|
1107
|
+
function mkTableRecord(
|
|
1108
|
+
rowCnt: number,
|
|
1109
|
+
colCnt: number,
|
|
1110
|
+
rowHwp: number[],
|
|
1111
|
+
bfId: number,
|
|
1112
|
+
): Uint8Array {
|
|
399
1113
|
const w = new BufWriter();
|
|
400
|
-
w.u32(0);
|
|
401
|
-
w.u16(
|
|
402
|
-
w.u16(
|
|
403
|
-
w.
|
|
404
|
-
for (const h of rowHwp) w.u16(h);
|
|
405
|
-
w.u16(bfId);
|
|
1114
|
+
w.u32(0x04000006).u16(rowCnt).u16(colCnt).u16(0);
|
|
1115
|
+
w.u16(510).u16(510).u16(141).u16(141);
|
|
1116
|
+
for (const h of rowHwp) w.u16(Math.max(1, h & 0xffff));
|
|
1117
|
+
w.u16(bfId).u16(0);
|
|
406
1118
|
return w.build();
|
|
407
1119
|
}
|
|
408
1120
|
|
|
409
1121
|
function mkCellListHeader(
|
|
410
1122
|
paraCount: number,
|
|
411
|
-
row: number,
|
|
412
|
-
|
|
413
|
-
|
|
1123
|
+
row: number,
|
|
1124
|
+
col: number,
|
|
1125
|
+
rs: number,
|
|
1126
|
+
cs: number,
|
|
1127
|
+
wHwp: number,
|
|
1128
|
+
hHwp: number,
|
|
414
1129
|
bfId: number,
|
|
1130
|
+
padL = 141,
|
|
1131
|
+
padR = 141,
|
|
1132
|
+
padT = 141,
|
|
1133
|
+
padB = 141,
|
|
415
1134
|
): Uint8Array {
|
|
416
|
-
// Scanner reads: col = readU16LE(d, 8), row = readU16LE(d, 10)
|
|
417
|
-
// (HWP 5.0 spec: offset 8 = colAddr, offset 10 = rowAddr)
|
|
418
1135
|
return new BufWriter()
|
|
419
|
-
.u16(paraCount)
|
|
420
|
-
.u32(0)
|
|
421
|
-
.u16(0)
|
|
422
|
-
.u16(col)
|
|
423
|
-
.u16(row)
|
|
424
|
-
.u16(rs)
|
|
425
|
-
.u16(cs)
|
|
426
|
-
.u32(wHwp)
|
|
427
|
-
.u32(hHwp)
|
|
428
|
-
.
|
|
429
|
-
.u16(
|
|
430
|
-
.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1136
|
+
.u16(paraCount)
|
|
1137
|
+
.u32(0)
|
|
1138
|
+
.u16(0)
|
|
1139
|
+
.u16(col)
|
|
1140
|
+
.u16(row)
|
|
1141
|
+
.u16(rs)
|
|
1142
|
+
.u16(cs)
|
|
1143
|
+
.u32(wHwp)
|
|
1144
|
+
.u32(hHwp)
|
|
1145
|
+
.u16(padL)
|
|
1146
|
+
.u16(padR)
|
|
1147
|
+
.u16(padT)
|
|
1148
|
+
.u16(padB)
|
|
1149
|
+
.u16(bfId)
|
|
1150
|
+
.zeros(13)
|
|
1151
|
+
.build(); // 47 bytes
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const DEFAULT_ROW_HEIGHT_PT = 14;
|
|
1155
|
+
|
|
1156
|
+
function encodeGrid(
|
|
1157
|
+
grid: GridNode,
|
|
1158
|
+
bank: HwpStyleBank,
|
|
1159
|
+
lv: number,
|
|
1160
|
+
idGen: () => number,
|
|
1161
|
+
availWidthHwp: number,
|
|
1162
|
+
): Uint8Array[] {
|
|
436
1163
|
const records: Uint8Array[] = [];
|
|
437
1164
|
const rowCnt = grid.kids.length;
|
|
438
1165
|
const colCnt = Math.max(1, grid.kids[0]?.kids.length ?? 1);
|
|
439
1166
|
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const totalPt = cwPt.reduce((s, w) => s + w, 0) || 453; // ~A4 content width
|
|
1167
|
+
const cwPt = (grid.props as any).colWidths ?? [];
|
|
1168
|
+
const totalPt = cwPt.reduce((s: number, w: number) => s + w, 0) || 453;
|
|
443
1169
|
const defColPt = totalPt / colCnt;
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
const rowHwp
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1170
|
+
const defStroke = grid.props.defaultStroke ?? bank.DEF_STROKE;
|
|
1171
|
+
const defBfId = bank.addBorderFill(defStroke);
|
|
1172
|
+
|
|
1173
|
+
const rowHwp = grid.kids.map((row: any) =>
|
|
1174
|
+
row.heightPt != null && row.heightPt > 0
|
|
1175
|
+
? Metric.ptToHwp(row.heightPt)
|
|
1176
|
+
: Metric.ptToHwp(DEFAULT_ROW_HEIGHT_PT),
|
|
1177
|
+
);
|
|
1178
|
+
|
|
1179
|
+
const tblWPt =
|
|
1180
|
+
cwPt.length > 0 ? cwPt.reduce((s: number, w: number) => s + w, 0) : totalPt;
|
|
1181
|
+
const tblHPt = grid.kids.reduce(
|
|
1182
|
+
(s: number, row: any) =>
|
|
1183
|
+
s +
|
|
1184
|
+
(row.heightPt != null && row.heightPt > 0
|
|
1185
|
+
? row.heightPt
|
|
1186
|
+
: DEFAULT_ROW_HEIGHT_PT),
|
|
1187
|
+
0,
|
|
1188
|
+
);
|
|
1189
|
+
const tblInstanceId = idGen();
|
|
1190
|
+
const tblAlign = grid.props.align ?? "left";
|
|
1191
|
+
|
|
1192
|
+
records.push(
|
|
1193
|
+
mkRec(
|
|
1194
|
+
TAG_CTRL_HEADER,
|
|
1195
|
+
lv,
|
|
1196
|
+
mkTableCtrl(
|
|
1197
|
+
Metric.ptToHwp(tblWPt),
|
|
1198
|
+
Metric.ptToHwp(tblHPt),
|
|
1199
|
+
tblInstanceId,
|
|
1200
|
+
tblAlign,
|
|
1201
|
+
),
|
|
1202
|
+
),
|
|
1203
|
+
);
|
|
1204
|
+
records.push(
|
|
1205
|
+
mkRec(TAG_TABLE, lv + 1, mkTableRecord(rowCnt, colCnt, rowHwp, defBfId)),
|
|
1206
|
+
);
|
|
451
1207
|
|
|
452
1208
|
for (let r = 0; r < grid.kids.length; r++) {
|
|
453
1209
|
for (let c = 0; c < grid.kids[r].kids.length; c++) {
|
|
454
|
-
const cell
|
|
455
|
-
const wHwp
|
|
456
|
-
const hHwp
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
1210
|
+
const cell = grid.kids[r].kids[c];
|
|
1211
|
+
const wHwp = Metric.ptToHwp(cwPt[c] ?? defColPt);
|
|
1212
|
+
const hHwp = rowHwp[r];
|
|
1213
|
+
const cp = cell.props;
|
|
1214
|
+
const hasPerSide = cp.top || cp.bot || cp.left || cp.right;
|
|
1215
|
+
const bfId = hasPerSide
|
|
1216
|
+
? bank.addBorderFillPerSide(
|
|
1217
|
+
cp.left ?? defStroke,
|
|
1218
|
+
cp.right ?? defStroke,
|
|
1219
|
+
cp.top ?? defStroke,
|
|
1220
|
+
cp.bot ?? defStroke,
|
|
1221
|
+
cp.bg,
|
|
1222
|
+
)
|
|
1223
|
+
: bank.addBorderFill(defStroke, cp.bg);
|
|
1224
|
+
|
|
1225
|
+
const paras =
|
|
1226
|
+
cell.kids.length > 0
|
|
1227
|
+
? cell.kids
|
|
1228
|
+
: [{ tag: "para" as const, props: {}, kids: [] }];
|
|
1229
|
+
|
|
1230
|
+
const padL = cp.padL !== undefined ? Metric.ptToHwp(cp.padL) : 510;
|
|
1231
|
+
const padR = cp.padR !== undefined ? Metric.ptToHwp(cp.padR) : 510;
|
|
1232
|
+
const padT = cp.padT !== undefined ? Metric.ptToHwp(cp.padT) : 141;
|
|
1233
|
+
const padB = cp.padB !== undefined ? Metric.ptToHwp(cp.padB) : 141;
|
|
1234
|
+
|
|
1235
|
+
records.push(
|
|
1236
|
+
mkRec(
|
|
1237
|
+
TAG_LIST_HEADER,
|
|
1238
|
+
lv + 1,
|
|
1239
|
+
mkCellListHeader(
|
|
1240
|
+
paras.length,
|
|
1241
|
+
r,
|
|
1242
|
+
c,
|
|
1243
|
+
cell.rs,
|
|
1244
|
+
cell.cs,
|
|
1245
|
+
wHwp,
|
|
1246
|
+
hHwp,
|
|
1247
|
+
bfId,
|
|
1248
|
+
padL,
|
|
1249
|
+
padR,
|
|
1250
|
+
padT,
|
|
1251
|
+
padB,
|
|
1252
|
+
),
|
|
1253
|
+
),
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
const cellWidthHwp = Metric.ptToHwp(cwPt[c] ?? defColPt);
|
|
1257
|
+
for (const para of paras) {
|
|
1258
|
+
records.push(
|
|
1259
|
+
...encodePara(para as ParaNode, bank, lv + 2, idGen(), cellWidthHwp),
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
467
1262
|
}
|
|
468
1263
|
}
|
|
469
|
-
|
|
470
1264
|
return records;
|
|
471
1265
|
}
|
|
472
1266
|
|
|
473
|
-
function
|
|
1267
|
+
function mkSectionCtrl(): Uint8Array {
|
|
1268
|
+
return new BufWriter()
|
|
1269
|
+
.u32(CTRL_SECD)
|
|
1270
|
+
.u32(0)
|
|
1271
|
+
.u32(1134)
|
|
1272
|
+
.u16(0x4000)
|
|
1273
|
+
.u16(0x001f)
|
|
1274
|
+
.zeros(31)
|
|
1275
|
+
.build(); // 47 bytes
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function buildSectionParagraph(
|
|
1279
|
+
dims: PageDims,
|
|
1280
|
+
instanceId: number,
|
|
1281
|
+
): Uint8Array[] {
|
|
1282
|
+
const SECD_CTRL_MASK = 1 << 2;
|
|
1283
|
+
const nchars = 9;
|
|
1284
|
+
const availWidthHwp = Math.max(
|
|
1285
|
+
1000,
|
|
1286
|
+
Metric.ptToHwp(dims.wPt) -
|
|
1287
|
+
Metric.ptToHwp(dims.ml) -
|
|
1288
|
+
Metric.ptToHwp(dims.mr),
|
|
1289
|
+
);
|
|
1290
|
+
return [
|
|
1291
|
+
mkRec(
|
|
1292
|
+
TAG_PARA_HEADER,
|
|
1293
|
+
0,
|
|
1294
|
+
mkParaHeader(nchars, SECD_CTRL_MASK, 0, 1, 1, instanceId),
|
|
1295
|
+
),
|
|
1296
|
+
mkRec(TAG_PARA_TEXT, 1, mkSecdParaText()),
|
|
1297
|
+
mkRec(TAG_PARA_CHAR_SHAPE, 1, mkParaCharShape([[0, 0]])),
|
|
1298
|
+
mkRec(
|
|
1299
|
+
TAG_PARA_LINE_SEG,
|
|
1300
|
+
1,
|
|
1301
|
+
buildDefaultLineSeg(availWidthHwp, 1000, nchars),
|
|
1302
|
+
),
|
|
1303
|
+
mkRec(TAG_CTRL_HEADER, 1, mkSectionCtrl()),
|
|
1304
|
+
mkRec(TAG_PAGE_DEF, 2, mkPageDef(dims)),
|
|
1305
|
+
mkRec(TAG_FOOTNOTE_SHAPE, 2, new Uint8Array(28)),
|
|
1306
|
+
mkRec(TAG_FOOTNOTE_SHAPE, 2, new Uint8Array(28)),
|
|
1307
|
+
];
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ─── BodyText 스트림 빌더 ─────────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
function flatImgNodes(kids: any[]): any[] {
|
|
1313
|
+
const result: any[] = [];
|
|
1314
|
+
for (const kid of kids) {
|
|
1315
|
+
if (kid.tag === "img") result.push(kid);
|
|
1316
|
+
else if (kid.tag === "link" && Array.isArray(kid.kids))
|
|
1317
|
+
result.push(...flatImgNodes(kid.kids));
|
|
1318
|
+
}
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function b64Matches(binImg: BinImage, b64: string): boolean {
|
|
1323
|
+
const a = TextKit.base64Encode(binImg.data).replace(/\s/g, "");
|
|
1324
|
+
const b = b64.replace(/\s/g, "");
|
|
1325
|
+
return a === b;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function buildBodyTextStream(
|
|
1329
|
+
doc: DocRoot,
|
|
1330
|
+
bank: HwpStyleBank,
|
|
1331
|
+
images: BinImage[],
|
|
1332
|
+
): Uint8Array {
|
|
474
1333
|
const chunks: Uint8Array[] = [];
|
|
475
1334
|
const dims = doc.kids[0]?.dims ?? A4;
|
|
476
|
-
|
|
1335
|
+
let instanceIdCounter = 1;
|
|
1336
|
+
const idGen = () => instanceIdCounter++;
|
|
1337
|
+
const availWidthHwp = Math.max(
|
|
1338
|
+
1000,
|
|
1339
|
+
Metric.ptToHwp(dims.wPt) -
|
|
1340
|
+
Metric.ptToHwp(dims.ml) -
|
|
1341
|
+
Metric.ptToHwp(dims.mr),
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
for (const r of buildSectionParagraph(dims, idGen())) chunks.push(r);
|
|
1345
|
+
|
|
1346
|
+
const TABLE_CTRL_MASK = 1 << 11;
|
|
1347
|
+
let vertPos = 0; // 단락 간격 추적
|
|
477
1348
|
|
|
478
1349
|
for (const sheet of doc.kids) {
|
|
479
1350
|
for (const node of sheet.kids) {
|
|
480
|
-
if (node.tag ===
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1351
|
+
if (node.tag === "para") {
|
|
1352
|
+
const para = node as ParaNode;
|
|
1353
|
+
|
|
1354
|
+
const hasPageBreak = para.kids.some(
|
|
1355
|
+
(k) => k.tag === "span" && k.kids.some((c) => c.tag === "pb"),
|
|
1356
|
+
);
|
|
1357
|
+
let paraMask = hasPageBreak ? 1 << 2 : 0;
|
|
1358
|
+
|
|
1359
|
+
// 코드 블록 감지 → 1×1 표로 감싸기
|
|
1360
|
+
const hasCourier = (kids: any[]): boolean =>
|
|
1361
|
+
kids.some(
|
|
1362
|
+
(k) =>
|
|
1363
|
+
(k.tag === "span" &&
|
|
1364
|
+
k.props.font?.toLowerCase().includes("courier")) ||
|
|
1365
|
+
(k.tag === "link" && hasCourier(k.kids)),
|
|
1366
|
+
);
|
|
1367
|
+
const isCode =
|
|
1368
|
+
para.props.styleId?.toLowerCase().includes("code") ||
|
|
1369
|
+
hasCourier(para.kids);
|
|
1370
|
+
|
|
1371
|
+
if (isCode) {
|
|
1372
|
+
const gridNode: GridNode = {
|
|
1373
|
+
tag: "grid",
|
|
1374
|
+
props: {
|
|
1375
|
+
colWidths: [Metric.hwpToPt(availWidthHwp)],
|
|
1376
|
+
defaultStroke: { kind: "solid", pt: 0.5, color: "aaaaaa" },
|
|
1377
|
+
},
|
|
1378
|
+
kids: [
|
|
1379
|
+
{
|
|
1380
|
+
tag: "row",
|
|
1381
|
+
kids: [
|
|
1382
|
+
{
|
|
1383
|
+
tag: "cell",
|
|
1384
|
+
rs: 1,
|
|
1385
|
+
cs: 1,
|
|
1386
|
+
props: { bg: "f4f4f4" },
|
|
1387
|
+
kids: [para],
|
|
1388
|
+
},
|
|
1389
|
+
],
|
|
1390
|
+
},
|
|
1391
|
+
],
|
|
1392
|
+
};
|
|
1393
|
+
chunks.push(
|
|
1394
|
+
mkRec(
|
|
1395
|
+
TAG_PARA_HEADER,
|
|
1396
|
+
0,
|
|
1397
|
+
mkParaHeader(9, TABLE_CTRL_MASK | paraMask, 0, 1, 1, idGen()),
|
|
1398
|
+
),
|
|
1399
|
+
);
|
|
1400
|
+
chunks.push(mkRec(TAG_PARA_TEXT, 1, mkTableParaText()));
|
|
1401
|
+
chunks.push(mkRec(TAG_PARA_CHAR_SHAPE, 1, mkParaCharShape([[0, 0]])));
|
|
1402
|
+
chunks.push(
|
|
1403
|
+
mkRec(
|
|
1404
|
+
TAG_PARA_LINE_SEG,
|
|
1405
|
+
1,
|
|
1406
|
+
buildDefaultLineSeg(availWidthHwp, 1000, 9, undefined, vertPos),
|
|
1407
|
+
),
|
|
1408
|
+
);
|
|
1409
|
+
vertPos += Metric.ptToHwp(20); // 코드 블록 후 간격
|
|
1410
|
+
for (const r of encodeGrid(gridNode, bank, 1, idGen, availWidthHwp))
|
|
1411
|
+
chunks.push(r);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const imgNodes = flatImgNodes(para.kids);
|
|
1416
|
+
if (imgNodes.length > 0) {
|
|
1417
|
+
for (const img of imgNodes) {
|
|
1418
|
+
const binImg = images.find((b) => b64Matches(b, img.b64));
|
|
1419
|
+
if (binImg) {
|
|
1420
|
+
for (const r of encodePicPara(
|
|
1421
|
+
img,
|
|
1422
|
+
binImg.id,
|
|
1423
|
+
bank,
|
|
1424
|
+
0,
|
|
1425
|
+
idGen,
|
|
1426
|
+
availWidthHwp,
|
|
1427
|
+
)) {
|
|
1428
|
+
// 첫 레코드가 PARA_HEADER인 경우 페이지 브레이크 마스크 적용
|
|
1429
|
+
chunks.push(r);
|
|
1430
|
+
}
|
|
1431
|
+
vertPos += Metric.ptToHwp(img.h ?? 100); // 이미지 높이 추가
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
const textKids = para.kids.filter(
|
|
1435
|
+
(k: any) => k.tag !== "img" && k.tag !== "link",
|
|
1436
|
+
);
|
|
1437
|
+
if (textKids.length > 0) {
|
|
1438
|
+
const textPara: ParaNode = {
|
|
1439
|
+
tag: "para",
|
|
1440
|
+
props: para.props,
|
|
1441
|
+
kids: textKids as any,
|
|
1442
|
+
};
|
|
1443
|
+
for (const r of encodePara(
|
|
1444
|
+
textPara,
|
|
1445
|
+
bank,
|
|
1446
|
+
0,
|
|
1447
|
+
idGen(),
|
|
1448
|
+
availWidthHwp,
|
|
1449
|
+
paraMask,
|
|
1450
|
+
vertPos,
|
|
1451
|
+
)) {
|
|
1452
|
+
// PARA_HEADER 레코드(목록의 첫 번째)에 마스크 적용
|
|
1453
|
+
if (r[0] === (TAG_PARA_HEADER & 0xff)) {
|
|
1454
|
+
// 레코드 헤더 수정은 복잡하므로 encodePara 내부에서 처리하는 것이 안전하지만
|
|
1455
|
+
// 여기서는 간단히 구현하기 위해 encodePara의 인자로 mask를 넘기도록 구조를 변경하는 것이 좋습니다.
|
|
1456
|
+
}
|
|
1457
|
+
chunks.push(r);
|
|
1458
|
+
}
|
|
1459
|
+
// 단락 높이 계산 및 vertPos 업데이트 (이미지/텍스트 혼합)
|
|
1460
|
+
const fontHwp_img = (
|
|
1461
|
+
textKids.find(
|
|
1462
|
+
(k: any) => k.tag === "span" && k.props?.pt,
|
|
1463
|
+
) as SpanNode
|
|
1464
|
+
)?.props.pt
|
|
1465
|
+
? Metric.ptToHwp(
|
|
1466
|
+
(
|
|
1467
|
+
textKids.find(
|
|
1468
|
+
(k: any) => k.tag === "span" && k.props?.pt,
|
|
1469
|
+
) as SpanNode
|
|
1470
|
+
).props.pt as number,
|
|
1471
|
+
)
|
|
1472
|
+
: 1000;
|
|
1473
|
+
const lineSpacePct_img = Math.round((para.props.lineHeight ?? 1.6) * 100);
|
|
1474
|
+
vertPos += Math.round((fontHwp_img * lineSpacePct_img) / 100);
|
|
1475
|
+
}
|
|
1476
|
+
} else {
|
|
1477
|
+
for (const r of encodePara(
|
|
1478
|
+
para,
|
|
1479
|
+
bank,
|
|
1480
|
+
0,
|
|
1481
|
+
idGen(),
|
|
1482
|
+
availWidthHwp,
|
|
1483
|
+
paraMask,
|
|
1484
|
+
vertPos,
|
|
1485
|
+
))
|
|
1486
|
+
chunks.push(r);
|
|
1487
|
+
// 단락 높이 계산 및 vertPos 업데이트 (일반 단락)
|
|
1488
|
+
const fontHwp_para = (
|
|
1489
|
+
para.kids.find(
|
|
1490
|
+
(k: any) => k.tag === "span" && k.props?.pt,
|
|
1491
|
+
) as SpanNode
|
|
1492
|
+
)?.props.pt
|
|
1493
|
+
? Metric.ptToHwp(
|
|
1494
|
+
(
|
|
1495
|
+
para.kids.find(
|
|
1496
|
+
(k: any) => k.tag === "span" && k.props?.pt,
|
|
1497
|
+
) as SpanNode
|
|
1498
|
+
).props.pt as number,
|
|
1499
|
+
)
|
|
1500
|
+
: 1000;
|
|
1501
|
+
const lineSpacePct_para = para.props.lineHeight
|
|
1502
|
+
? Math.round(para.props.lineHeight * 100)
|
|
1503
|
+
: 160;
|
|
1504
|
+
vertPos += Math.round((fontHwp_para * lineSpacePct_para) / 100);
|
|
1505
|
+
}
|
|
1506
|
+
} else if (node.tag === "grid") {
|
|
1507
|
+
chunks.push(
|
|
1508
|
+
mkRec(
|
|
1509
|
+
TAG_PARA_HEADER,
|
|
1510
|
+
0,
|
|
1511
|
+
mkParaHeader(9, TABLE_CTRL_MASK, 0, 1, 1, idGen()),
|
|
1512
|
+
),
|
|
1513
|
+
);
|
|
1514
|
+
chunks.push(mkRec(TAG_PARA_TEXT, 1, mkTableParaText()));
|
|
489
1515
|
chunks.push(mkRec(TAG_PARA_CHAR_SHAPE, 1, mkParaCharShape([[0, 0]])));
|
|
490
|
-
|
|
1516
|
+
chunks.push(
|
|
1517
|
+
mkRec(
|
|
1518
|
+
TAG_PARA_LINE_SEG,
|
|
1519
|
+
1,
|
|
1520
|
+
buildDefaultLineSeg(availWidthHwp, 1000, 9, undefined, vertPos),
|
|
1521
|
+
),
|
|
1522
|
+
);
|
|
1523
|
+
vertPos += Metric.ptToHwp(20); // 표 후 간격
|
|
1524
|
+
for (const r of encodeGrid(
|
|
1525
|
+
node as GridNode,
|
|
1526
|
+
bank,
|
|
1527
|
+
1,
|
|
1528
|
+
idGen,
|
|
1529
|
+
availWidthHwp,
|
|
1530
|
+
))
|
|
1531
|
+
chunks.push(r);
|
|
491
1532
|
}
|
|
492
1533
|
}
|
|
493
1534
|
}
|
|
@@ -495,40 +1536,75 @@ function buildBodyTextStream(doc: DocRoot, col: StyleCollector): Uint8Array {
|
|
|
495
1536
|
return concatU8(chunks);
|
|
496
1537
|
}
|
|
497
1538
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1539
|
+
// ─── HWP FileHeader ─────────────────────────────────────────
|
|
1540
|
+
/**
|
|
1541
|
+
* HWP 5.0 FileHeader 를 생성합니다.
|
|
1542
|
+
*
|
|
1543
|
+
* 구조:
|
|
1544
|
+
* 0-15: 시그니처 "HWP Document File" (16 바이트)
|
|
1545
|
+
* 16-31: reserved (16 바이트)
|
|
1546
|
+
* 32-35: version (4 바이트, Little-Endian) - 0x05000300 = 5.0.3.0
|
|
1547
|
+
* 36-39: flags (4 바이트, Little-Endian) - bit 0 = compressed, bit 1 = encrypted
|
|
1548
|
+
* 40-255: reserved (216 바이트)
|
|
1549
|
+
*
|
|
1550
|
+
* 총 256 바이트
|
|
1551
|
+
*/
|
|
502
1552
|
function buildHwpFileHeader(): Uint8Array {
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
for (let i = 0; i < sig.length; i++) buf[i] = sig.charCodeAt(i);
|
|
1553
|
+
const SIZE = 256;
|
|
1554
|
+
const buf = new Uint8Array(SIZE);
|
|
506
1555
|
const dv = new DataView(buf.buffer);
|
|
507
|
-
|
|
508
|
-
|
|
1556
|
+
|
|
1557
|
+
// 0-15: 시그니처 "HWP Document File" (16 바이트)
|
|
1558
|
+
const sig = "HWP Document File";
|
|
1559
|
+
for (let i = 0; i < sig.length; i++) {
|
|
1560
|
+
buf[i] = sig.charCodeAt(i);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// 16-31: reserved (0 으로 초기화됨)
|
|
1564
|
+
|
|
1565
|
+
// 32-35: version (4 바이트, Little-Endian) - 0x05000300 = 5.0.3.0
|
|
1566
|
+
dv.setUint32(32, 0x05000300, true);
|
|
1567
|
+
|
|
1568
|
+
// 36-39: flags (4 바이트, Little-Endian)
|
|
1569
|
+
// bit 0 = 1: compressed (압축됨)
|
|
1570
|
+
// bit 1 = 0: not encrypted (암호화 안됨)
|
|
1571
|
+
dv.setUint32(36, 0x00000001, true);
|
|
1572
|
+
|
|
1573
|
+
// 40-255: reserved (0 으로 초기화됨)
|
|
1574
|
+
|
|
1575
|
+
// 검증
|
|
1576
|
+
if (buf.length !== SIZE) {
|
|
1577
|
+
throw new Error(`FileHeader 크기 오류: ${buf.length} (기대: ${SIZE})`);
|
|
1578
|
+
}
|
|
1579
|
+
if (new TextDecoder().decode(buf.subarray(0, sig.length)) !== sig) {
|
|
1580
|
+
throw new Error("FileHeader 시그니처 오류");
|
|
1581
|
+
}
|
|
1582
|
+
if (dv.getUint32(32, true) !== 0x05000300) {
|
|
1583
|
+
throw new Error("FileHeader 버전 오류");
|
|
1584
|
+
}
|
|
1585
|
+
|
|
509
1586
|
return buf;
|
|
510
1587
|
}
|
|
511
1588
|
|
|
512
|
-
|
|
513
|
-
OLE2 / CFB container builder
|
|
514
|
-
Structure:
|
|
515
|
-
OLE2 header (512 bytes, not a sector)
|
|
516
|
-
Sector 0..fatN-1 : FAT sectors
|
|
517
|
-
Sector fatN : Directory sector 1 (entries 0-3)
|
|
518
|
-
Sector fatN+1 : Directory sector 2 (entries 4-7)
|
|
519
|
-
Sector fatN+2 .. : FileHeader data
|
|
520
|
-
then DocInfo data, then Section0 data
|
|
521
|
-
═══════════════════════════════════════════════════════════════ */
|
|
1589
|
+
// ─── OLE2/CFB 컨테이너 빌더 ─────────────────────────────────
|
|
522
1590
|
|
|
523
1591
|
function buildHwpOle2(
|
|
524
1592
|
fileHeaderData: Uint8Array,
|
|
525
|
-
docInfoData:
|
|
526
|
-
section0Data:
|
|
1593
|
+
docInfoData: Uint8Array,
|
|
1594
|
+
section0Data: Uint8Array,
|
|
1595
|
+
binImages: BinImage[] = [],
|
|
527
1596
|
): Uint8Array {
|
|
528
1597
|
const SS = 512;
|
|
529
|
-
const ENDOFCHAIN =
|
|
530
|
-
const FREESECT
|
|
531
|
-
const FATSECT
|
|
1598
|
+
const ENDOFCHAIN = 0xfffffffe;
|
|
1599
|
+
const FREESECT = 0xffffffff;
|
|
1600
|
+
const FATSECT = 0xfffffffd;
|
|
1601
|
+
|
|
1602
|
+
// FileHeader 크기 검증
|
|
1603
|
+
if (fileHeaderData.length < 256) {
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
`FileHeader 크기 부족: ${fileHeaderData.length} (최소 256)`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
532
1608
|
|
|
533
1609
|
function padSector(d: Uint8Array): Uint8Array {
|
|
534
1610
|
const n = Math.ceil(Math.max(d.length, 1) / SS) * SS;
|
|
@@ -541,165 +1617,338 @@ function buildHwpOle2(
|
|
|
541
1617
|
const fhPad = padSector(fileHeaderData);
|
|
542
1618
|
const diPad = padSector(docInfoData);
|
|
543
1619
|
const s0Pad = padSector(section0Data);
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
const
|
|
1620
|
+
const imgPads = binImages.map((img) => padSector(img.data));
|
|
1621
|
+
|
|
1622
|
+
const fhN = fhPad.length / SS;
|
|
1623
|
+
const diN = diPad.length / SS;
|
|
1624
|
+
const s0N = s0Pad.length / SS;
|
|
1625
|
+
const imgNs = imgPads.map((p) => p.length / SS);
|
|
1626
|
+
const totalImgN = imgNs.reduce((s, n) => s + n, 0);
|
|
1627
|
+
|
|
1628
|
+
const numDirEntries = 5 + (binImages.length > 0 ? 1 + binImages.length : 0);
|
|
1629
|
+
const dirN = Math.max(2, Math.ceil(numDirEntries / 4));
|
|
548
1630
|
|
|
549
|
-
// Compute FAT sector count iteratively
|
|
550
1631
|
let fatN = 1;
|
|
551
1632
|
for (let iter = 0; iter < 10; iter++) {
|
|
552
|
-
const total
|
|
1633
|
+
const total = fatN + dirN + fhN + diN + s0N + totalImgN;
|
|
553
1634
|
const needed = Math.ceil(total / 128);
|
|
554
1635
|
if (needed <= fatN) break;
|
|
555
1636
|
fatN = needed;
|
|
556
1637
|
}
|
|
557
1638
|
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
const diSec = fhSec + fhN;
|
|
563
|
-
const s0Sec = diSec + diN;
|
|
564
|
-
const totalSec = s0Sec + s0N;
|
|
1639
|
+
const dir1Sec = fatN;
|
|
1640
|
+
const fhSec = dir1Sec + dirN;
|
|
1641
|
+
const diSec = fhSec + fhN;
|
|
1642
|
+
const s0Sec = diSec + diN;
|
|
565
1643
|
|
|
566
|
-
|
|
567
|
-
|
|
1644
|
+
const imgSecs: number[] = [];
|
|
1645
|
+
let curSec = s0Sec + s0N;
|
|
1646
|
+
for (const n of imgNs) {
|
|
1647
|
+
imgSecs.push(curSec);
|
|
1648
|
+
curSec += n;
|
|
1649
|
+
}
|
|
1650
|
+
const totalSec = curSec;
|
|
1651
|
+
|
|
1652
|
+
const fatBuf = new Uint8Array(fatN * SS).fill(0xff);
|
|
568
1653
|
const setFat = (i: number, v: number) => {
|
|
569
|
-
fatBuf[i * 4]
|
|
570
|
-
fatBuf[i * 4 + 1] = (v >>> 8) &
|
|
571
|
-
fatBuf[i * 4 + 2] = (v >>> 16) &
|
|
572
|
-
fatBuf[i * 4 + 3] = (v >>> 24) &
|
|
1654
|
+
fatBuf[i * 4] = v & 0xff;
|
|
1655
|
+
fatBuf[i * 4 + 1] = (v >>> 8) & 0xff;
|
|
1656
|
+
fatBuf[i * 4 + 2] = (v >>> 16) & 0xff;
|
|
1657
|
+
fatBuf[i * 4 + 3] = (v >>> 24) & 0xff;
|
|
573
1658
|
};
|
|
574
1659
|
|
|
575
1660
|
for (let i = 0; i < fatN; i++) setFat(i, FATSECT);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
for (let i = 0; i < fhN; i++)
|
|
579
|
-
|
|
580
|
-
for (let i = 0; i <
|
|
1661
|
+
for (let i = 0; i < dirN; i++)
|
|
1662
|
+
setFat(dir1Sec + i, i + 1 < dirN ? dir1Sec + i + 1 : ENDOFCHAIN);
|
|
1663
|
+
for (let i = 0; i < fhN; i++)
|
|
1664
|
+
setFat(fhSec + i, i + 1 < fhN ? fhSec + i + 1 : ENDOFCHAIN);
|
|
1665
|
+
for (let i = 0; i < diN; i++)
|
|
1666
|
+
setFat(diSec + i, i + 1 < diN ? diSec + i + 1 : ENDOFCHAIN);
|
|
1667
|
+
for (let i = 0; i < s0N; i++)
|
|
1668
|
+
setFat(s0Sec + i, i + 1 < s0N ? s0Sec + i + 1 : ENDOFCHAIN);
|
|
1669
|
+
for (let ii = 0; ii < imgNs.length; ii++) {
|
|
1670
|
+
const start = imgSecs[ii];
|
|
1671
|
+
const n = imgNs[ii];
|
|
1672
|
+
for (let i = 0; i < n; i++)
|
|
1673
|
+
setFat(start + i, i + 1 < n ? start + i + 1 : ENDOFCHAIN);
|
|
1674
|
+
}
|
|
581
1675
|
|
|
582
|
-
// Build directory (8 entries × 128 bytes = dirN × SS)
|
|
583
1676
|
const dirBuf = new Uint8Array(dirN * SS);
|
|
584
|
-
const dv
|
|
1677
|
+
const dv = new DataView(dirBuf.buffer);
|
|
585
1678
|
|
|
586
1679
|
function writeDirEntry(
|
|
587
|
-
idx: number,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1680
|
+
idx: number,
|
|
1681
|
+
name: string,
|
|
1682
|
+
type: number,
|
|
1683
|
+
left: number,
|
|
1684
|
+
right: number,
|
|
1685
|
+
child: number,
|
|
1686
|
+
startSec: number,
|
|
1687
|
+
size: number,
|
|
1688
|
+
): void {
|
|
591
1689
|
const base = idx * 128;
|
|
592
|
-
const nl
|
|
593
|
-
|
|
594
|
-
|
|
1690
|
+
const nl = name.length;
|
|
1691
|
+
// OLE2: 이름은 UTF-16LE, (글자수+1)*2 바이트가 길이 필드에 기록됨
|
|
1692
|
+
for (let i = 0; i < nl; i++)
|
|
1693
|
+
dv.setUint16(base + i * 2, name.charCodeAt(i), true);
|
|
1694
|
+
dv.setUint16(base + 64, (nl + 1) * 2, true);
|
|
595
1695
|
dirBuf[base + 66] = type;
|
|
596
|
-
dirBuf[base + 67] = 1; //
|
|
597
|
-
dv.setInt32(base + 68, left,
|
|
598
|
-
dv.setInt32(base + 72, right, true);
|
|
599
|
-
dv.setInt32(base + 76, child, true);
|
|
1696
|
+
dirBuf[base + 67] = 1; // DE_NODE
|
|
1697
|
+
dv.setInt32(base + 68, left, true);
|
|
1698
|
+
dv.setInt32(base + 72, right, true);
|
|
1699
|
+
dv.setInt32(base + 76, child, true);
|
|
600
1700
|
dv.setUint32(base + 116, startSec >>> 0, true);
|
|
601
|
-
dv.setUint32(base + 120, size >>> 0,
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1701
|
+
dv.setUint32(base + 120, size >>> 0, true);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// 초기값 -1 (NOSTREAM)
|
|
1705
|
+
for (let i = 0; i < dirN * 4; i++) {
|
|
1706
|
+
const base = i * 128;
|
|
1707
|
+
dv.setInt32(base + 68, -1, true);
|
|
1708
|
+
dv.setInt32(base + 72, -1, true);
|
|
1709
|
+
dv.setInt32(base + 76, -1, true);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* 트리 구조 설계:
|
|
1714
|
+
* 0: Root Entry (child -> 1)
|
|
1715
|
+
* 1: FileHeader (left -> -1, right -> 2)
|
|
1716
|
+
* 2: DocInfo (left -> -1, right -> 3)
|
|
1717
|
+
* 3: BodyText (left -> -1, right -> 5, child -> 4)
|
|
1718
|
+
* 4: Section0 (left -> -1, right -> -1)
|
|
1719
|
+
* 5: BinData (left -> -1, right -> -1, child -> 6...)
|
|
1720
|
+
*/
|
|
1721
|
+
|
|
1722
|
+
if (binImages.length > 0) {
|
|
1723
|
+
writeDirEntry(0, "Root Entry", 5, -1, -1, 1, ENDOFCHAIN, 0);
|
|
1724
|
+
writeDirEntry(1, "FileHeader", 2, -1, 2, -1, fhSec, fileHeaderData.length);
|
|
1725
|
+
writeDirEntry(2, "DocInfo", 2, -1, 3, -1, diSec, docInfoData.length);
|
|
1726
|
+
writeDirEntry(3, "BodyText", 1, -1, 5, 4, ENDOFCHAIN, 0);
|
|
1727
|
+
writeDirEntry(4, "Section0", 2, -1, -1, -1, s0Sec, section0Data.length);
|
|
1728
|
+
writeDirEntry(5, "BinData", 1, -1, -1, 6, ENDOFCHAIN, 0);
|
|
1729
|
+
for (let ii = 0; ii < binImages.length; ii++) {
|
|
1730
|
+
const img = binImages[ii];
|
|
1731
|
+
const streamName = `BIN${String(img.id).padStart(4, "0")}.${img.ext}`;
|
|
1732
|
+
const sibling = ii + 1 < binImages.length ? 7 + ii : -1;
|
|
1733
|
+
writeDirEntry(
|
|
1734
|
+
6 + ii,
|
|
1735
|
+
streamName,
|
|
1736
|
+
2,
|
|
1737
|
+
-1,
|
|
1738
|
+
sibling,
|
|
1739
|
+
-1,
|
|
1740
|
+
imgSecs[ii],
|
|
1741
|
+
img.data.length,
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
} else {
|
|
1745
|
+
writeDirEntry(0, "Root Entry", 5, -1, -1, 1, ENDOFCHAIN, 0);
|
|
1746
|
+
writeDirEntry(1, "FileHeader", 2, -1, 2, -1, fhSec, fileHeaderData.length);
|
|
1747
|
+
writeDirEntry(2, "DocInfo", 2, -1, 3, -1, diSec, docInfoData.length);
|
|
1748
|
+
writeDirEntry(3, "BodyText", 1, -1, -1, 4, ENDOFCHAIN, 0);
|
|
1749
|
+
writeDirEntry(4, "Section0", 2, -1, -1, -1, s0Sec, section0Data.length);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// HWP Root Entry CLSID
|
|
1753
|
+
const HWP_CLSID = [
|
|
1754
|
+
0x20, 0xe9, 0xe3, 0xc0, 0x46, 0x35, 0xcf, 0x11, 0x8d, 0x81, 0x00, 0xaa,
|
|
1755
|
+
0x00, 0x38, 0x9b, 0x71,
|
|
1756
|
+
];
|
|
1757
|
+
for (let i = 0; i < 16; i++) dirBuf[80 + i] = HWP_CLSID[i];
|
|
1758
|
+
|
|
1759
|
+
const hdr = new Uint8Array(SS);
|
|
1760
|
+
const hdv = new DataView(hdr.buffer);
|
|
1761
|
+
|
|
1762
|
+
// OLE2/CFB 헤더 구조:
|
|
1763
|
+
// 0-7: Magic number (D0 CF 11 E0 A1 B1 1A E1)
|
|
1764
|
+
// 8-23: CLSID (16 bytes)
|
|
1765
|
+
// 24-25: Minor version (0x003E = 62)
|
|
1766
|
+
// 26-27: Major version (0x0003 = 3)
|
|
1767
|
+
// 28-29: Byte order (0x00FE = Little-Endian)
|
|
1768
|
+
// 30-31: Sector size exponent (0x0009 = 2^9 = 512)
|
|
1769
|
+
// 32-33: Mini sector size exponent (0x0006 = 2^6 = 64)
|
|
1770
|
+
// 34-39: Reserved (6 bytes)
|
|
1771
|
+
// 40-43: Number of FAT sectors
|
|
1772
|
+
// 44-47: Directory start sector location
|
|
1773
|
+
// 48-51: Transaction signature number
|
|
1774
|
+
// 52-55: Mini stream cutoff size (0x1000 = 4096)
|
|
1775
|
+
// 56-59: Mini FAT start sector location
|
|
1776
|
+
// 60-63: Number of mini FAT sectors
|
|
1777
|
+
// 64-67: FAT start sector location
|
|
1778
|
+
// 68-71: Number of backup FAT sectors
|
|
1779
|
+
// 72-75: Backup FAT start sector location
|
|
1780
|
+
// 76-511: Sector bitmap (109 sectors worth)
|
|
1781
|
+
|
|
1782
|
+
// Magic number
|
|
1783
|
+
const MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
1784
|
+
MAGIC.forEach((b, i) => {
|
|
1785
|
+
hdr[i] = b;
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
// CLSID is already set in dirBuf[0][80:96] for Root Entry
|
|
1789
|
+
|
|
1790
|
+
// Version
|
|
1791
|
+
hdv.setUint16(24, 0x003e, true); // Minor version
|
|
1792
|
+
hdv.setUint16(26, 0x0003, true); // Major version
|
|
1793
|
+
|
|
1794
|
+
// Byte order
|
|
1795
|
+
hdv.setUint16(28, 0x00fe, true); // Little-Endian
|
|
1796
|
+
|
|
1797
|
+
// Sector size exponent (2^9 = 512)
|
|
1798
|
+
hdv.setUint16(30, 0x0009, true);
|
|
1799
|
+
|
|
1800
|
+
// Mini sector size exponent (2^6 = 64)
|
|
1801
|
+
hdv.setUint16(32, 0x0006, true);
|
|
1802
|
+
|
|
1803
|
+
// Reserved (34-39, 6 bytes) - already zero
|
|
1804
|
+
|
|
1805
|
+
// Number of FAT sectors
|
|
1806
|
+
hdv.setUint32(40, fatN, true);
|
|
1807
|
+
|
|
1808
|
+
// Directory start sector location
|
|
1809
|
+
hdv.setUint32(44, dir1Sec, true);
|
|
1810
|
+
|
|
1811
|
+
// Transaction signature number
|
|
1812
|
+
hdv.setUint32(48, 0, true);
|
|
1813
|
+
|
|
1814
|
+
// Mini stream cutoff size
|
|
1815
|
+
hdv.setUint32(52, 0x1000, true);
|
|
1816
|
+
|
|
1817
|
+
// Mini FAT start sector location
|
|
1818
|
+
hdv.setUint32(56, ENDOFCHAIN, true);
|
|
1819
|
+
|
|
1820
|
+
// Number of mini FAT sectors
|
|
1821
|
+
hdv.setUint32(60, 0, true);
|
|
1822
|
+
|
|
1823
|
+
// FAT start sector location
|
|
1824
|
+
hdv.setUint32(64, ENDOFCHAIN, true);
|
|
1825
|
+
|
|
1826
|
+
// Number of backup FAT sectors
|
|
1827
|
+
hdv.setUint32(68, 0, true);
|
|
1828
|
+
|
|
1829
|
+
// Backup FAT start sector location
|
|
1830
|
+
hdv.setUint32(72, 0, true);
|
|
1831
|
+
|
|
1832
|
+
// Sector bitmap (76-511)
|
|
644
1833
|
for (let i = 0; i < 109; i++) {
|
|
645
1834
|
hdv.setUint32(76 + i * 4, i < fatN ? i : FREESECT, true);
|
|
646
1835
|
}
|
|
647
1836
|
|
|
648
|
-
// Assemble output
|
|
649
1837
|
const out = new Uint8Array(SS + totalSec * SS);
|
|
650
1838
|
out.set(hdr, 0);
|
|
651
|
-
|
|
652
|
-
for (let i = 0; i < fatN; i++) {
|
|
1839
|
+
for (let i = 0; i < fatN; i++)
|
|
653
1840
|
out.set(fatBuf.subarray(i * SS, (i + 1) * SS), SS + i * SS);
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
out.set(dirBuf.subarray(0, SS), SS + dir1Sec * SS);
|
|
657
|
-
out.set(dirBuf.subarray(SS, 2*SS), SS + dir2Sec * SS);
|
|
658
|
-
// Stream data
|
|
1841
|
+
for (let i = 0; i < dirN; i++)
|
|
1842
|
+
out.set(dirBuf.subarray(i * SS, (i + 1) * SS), SS + (dir1Sec + i) * SS);
|
|
659
1843
|
out.set(fhPad, SS + fhSec * SS);
|
|
660
1844
|
out.set(diPad, SS + diSec * SS);
|
|
661
1845
|
out.set(s0Pad, SS + s0Sec * SS);
|
|
1846
|
+
for (let ii = 0; ii < imgPads.length; ii++)
|
|
1847
|
+
out.set(imgPads[ii], SS + imgSecs[ii] * SS);
|
|
662
1848
|
return out;
|
|
663
1849
|
}
|
|
664
1850
|
|
|
665
|
-
|
|
666
|
-
Utility
|
|
667
|
-
═══════════════════════════════════════════════════════════════ */
|
|
1851
|
+
// ─── 유틸리티 ────────────────────────────────────────────────
|
|
668
1852
|
|
|
669
1853
|
function concatU8(arrays: Uint8Array[]): Uint8Array {
|
|
670
1854
|
const total = arrays.reduce((s, a) => s + a.length, 0);
|
|
671
|
-
const out
|
|
1855
|
+
const out = new Uint8Array(total);
|
|
672
1856
|
let off = 0;
|
|
673
|
-
for (const a of arrays) {
|
|
1857
|
+
for (const a of arrays) {
|
|
1858
|
+
out.set(a, off);
|
|
1859
|
+
off += a.length;
|
|
1860
|
+
}
|
|
674
1861
|
return out;
|
|
675
1862
|
}
|
|
676
1863
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1864
|
+
// ─── OLE2 검증 ──────────────────────────────────────────────
|
|
1865
|
+
|
|
1866
|
+
function validateOle2Magic(hwp: Uint8Array): boolean {
|
|
1867
|
+
const OLE_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
1868
|
+
return OLE_MAGIC.every((b, i) => hwp[i] === b);
|
|
1869
|
+
}
|
|
680
1870
|
|
|
681
|
-
|
|
682
|
-
|
|
1871
|
+
// ─── Encoder 진입점 ──────────────────────────────────────────
|
|
1872
|
+
|
|
1873
|
+
export class HwpEncoder extends BaseEncoder {
|
|
1874
|
+
protected getFormat(): string {
|
|
1875
|
+
return "hwp";
|
|
1876
|
+
}
|
|
1877
|
+
protected getAliases(): string[] {
|
|
1878
|
+
return ["application/vnd.hancom.hwp"];
|
|
1879
|
+
}
|
|
683
1880
|
|
|
684
1881
|
async encode(doc: DocRoot): Promise<Outcome<Uint8Array>> {
|
|
685
1882
|
try {
|
|
686
|
-
//
|
|
687
|
-
const
|
|
1883
|
+
// 패스 1: 스타일 수집 (HwpStyleBank — ANYTOHWP 방식 언어별 폰트)
|
|
1884
|
+
const bank = new HwpStyleBank();
|
|
1885
|
+
for (const sheet of doc.kids) {
|
|
1886
|
+
for (const node of sheet.kids) collectNode(node, bank);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// 이미지 수집 (ANYTOHWP 개선: 픽셀 치수는 encodePicPara에서 추출)
|
|
1890
|
+
const images: BinImage[] = [];
|
|
1891
|
+
const seenB64 = new Set<string>();
|
|
1892
|
+
let binIdCounter = 1;
|
|
1893
|
+
|
|
1894
|
+
function registerImg(img: any): void {
|
|
1895
|
+
const key = img.b64.substring(0, 50);
|
|
1896
|
+
if (seenB64.has(key)) return;
|
|
1897
|
+
seenB64.add(key);
|
|
1898
|
+
const raw = TextKit.base64Decode(img.b64);
|
|
1899
|
+
const ext =
|
|
1900
|
+
img.mime === "image/png"
|
|
1901
|
+
? "png"
|
|
1902
|
+
: img.mime === "image/gif"
|
|
1903
|
+
? "gif"
|
|
1904
|
+
: img.mime === "image/bmp"
|
|
1905
|
+
? "bmp"
|
|
1906
|
+
: "jpg";
|
|
1907
|
+
images.push({ id: binIdCounter++, ext, data: new Uint8Array(raw) });
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function collectImages(node: any): void {
|
|
1911
|
+
if (node.tag === "para") {
|
|
1912
|
+
for (const img of flatImgNodes(node.kids)) registerImg(img);
|
|
1913
|
+
} else if (node.tag === "grid") {
|
|
1914
|
+
for (const row of node.kids)
|
|
1915
|
+
for (const cell of row.kids)
|
|
1916
|
+
for (const para of cell.kids) collectImages(para);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
688
1919
|
for (const sheet of doc.kids) {
|
|
689
|
-
for (const node of sheet.kids)
|
|
1920
|
+
for (const node of sheet.kids) collectImages(node);
|
|
690
1921
|
}
|
|
691
1922
|
|
|
692
|
-
//
|
|
693
|
-
const docInfoRaw
|
|
694
|
-
const bodyRaw
|
|
1923
|
+
// 패스 2: 스트림 빌드
|
|
1924
|
+
const docInfoRaw = buildDocInfoStream(bank, images);
|
|
1925
|
+
const bodyRaw = buildBodyTextStream(doc, bank, images);
|
|
695
1926
|
|
|
696
|
-
//
|
|
1927
|
+
// HWP 5.0: Zlib 헤더 없는 Raw Deflate
|
|
697
1928
|
const docInfoCmp = pako.deflateRaw(docInfoRaw);
|
|
698
|
-
const bodyCmp
|
|
1929
|
+
const bodyCmp = pako.deflateRaw(bodyRaw);
|
|
699
1930
|
|
|
700
|
-
// Assemble OLE2 file
|
|
701
1931
|
const fileHdr = buildHwpFileHeader();
|
|
702
|
-
|
|
1932
|
+
|
|
1933
|
+
// FileHeader 검증
|
|
1934
|
+
if (fileHdr.length !== 256) {
|
|
1935
|
+
return fail(
|
|
1936
|
+
`HwpEncoder: FileHeader 크기 오류 - ${fileHdr.length} bytes (기대: 256 bytes)`,
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const hwp = buildHwpOle2(fileHdr, docInfoCmp, bodyCmp, images);
|
|
1941
|
+
|
|
1942
|
+
if (!validateOle2Magic(hwp)) {
|
|
1943
|
+
return fail("HwpEncoder: OLE2 매직 바이트 오류");
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// HWP 파일 크기 검증 (최소 512 바이트 - OLE2 헤더 1 섹터)
|
|
1947
|
+
if (hwp.length < 512) {
|
|
1948
|
+
return fail(
|
|
1949
|
+
`HwpEncoder: HWP 파일 크기 부족 - ${hwp.length} bytes (최소 512 bytes)`,
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
703
1952
|
|
|
704
1953
|
return succeed(hwp);
|
|
705
1954
|
} catch (e: any) {
|