hwpkit-dev 0.0.1 → 0.0.2
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 +1 -0
- package/dist/index.d.mts +34 -3
- package/dist/index.d.ts +30 -3
- package/dist/index.js +2138 -245
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2134 -245
- package/dist/index.mjs.map +1 -1
- package/hwp-analyze.ts +90 -0
- package/inspect-doc.ts +57 -0
- package/output_test.hwp +0 -0
- package/package.json +3 -1
- package/src/decoders/docx/DocxDecoder.ts +155 -30
- package/src/decoders/hwp/HwpScanner.ts +258 -37
- package/src/decoders/hwpx/HwpxDecoder.ts +9 -1
- package/src/encoders/docx/DocxEncoder.ts +199 -158
- package/src/encoders/html/HtmlEncoder.ts +205 -0
- package/src/encoders/hwp/HwpEncoder.ts +864 -222
- package/src/encoders/hwpx/HwpxEncoder.ts +119 -59
- package/src/encoders/md/MdEncoder.ts +98 -16
- package/src/index.ts +1 -0
- package/src/model/builders.ts +4 -2
- package/src/model/doc-tree.ts +1 -1
- package/src/pipeline/Pipeline.ts +14 -1
- package/src/safety/StyleBridge.ts +1 -1
- package/test-docx-to-hwp.ts +45 -0
|
@@ -17,37 +17,59 @@ import { Metric } from '../../safety/StyleBridge';
|
|
|
17
17
|
import { registry } from '../../pipeline/registry';
|
|
18
18
|
import { A4 } from '../../model/doc-props';
|
|
19
19
|
import pako from 'pako';
|
|
20
|
+
import { TextKit } from '../../toolkit/TextKit';
|
|
20
21
|
|
|
21
22
|
/* ═══════════════════════════════════════════════════════════════
|
|
22
|
-
HWP 5.0 tag IDs
|
|
23
|
+
HWP 5.0 tag IDs (HWP 5.0 spec 표 13, 표 57)
|
|
24
|
+
HWPTAG_BEGIN = 16 (0x10)
|
|
23
25
|
═══════════════════════════════════════════════════════════════ */
|
|
24
26
|
|
|
25
27
|
const T = 16; // HWPTAG_BEGIN
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
|
|
29
|
+
// DocInfo tags (표 13)
|
|
30
|
+
const TAG_DOCUMENT_PROPERTIES = T + 0; // 16 - HWPTAG_DOCUMENT_PROPERTIES
|
|
31
|
+
const TAG_ID_MAPPINGS = T + 1; // 17 - HWPTAG_ID_MAPPINGS
|
|
32
|
+
const TAG_FACE_NAME = T + 3; // 19 - HWPTAG_FACE_NAME
|
|
33
|
+
const TAG_BORDER_FILL = T + 4; // 20 - HWPTAG_BORDER_FILL
|
|
34
|
+
const TAG_CHAR_SHAPE = T + 5; // 21 - HWPTAG_CHAR_SHAPE
|
|
35
|
+
const TAG_PARA_SHAPE = T + 9; // 25 - HWPTAG_PARA_SHAPE
|
|
36
|
+
|
|
37
|
+
// DocInfo tags (additional)
|
|
38
|
+
const TAG_BIN_DATA = T + 2; // 18 - HWPTAG_BIN_DATA
|
|
39
|
+
|
|
40
|
+
// BodyText tags (표 57)
|
|
41
|
+
const TAG_PARA_HEADER = T + 50; // 66 - HWPTAG_PARA_HEADER
|
|
42
|
+
const TAG_PARA_TEXT = T + 51; // 67 - HWPTAG_PARA_TEXT
|
|
43
|
+
const TAG_PARA_CHAR_SHAPE = T + 52; // 68 - HWPTAG_PARA_CHAR_SHAPE
|
|
44
|
+
const TAG_PARA_LINE_SEG = T + 53; // 69 - HWPTAG_PARA_LINE_SEG
|
|
45
|
+
const TAG_CTRL_HEADER = T + 55; // 71 - HWPTAG_CTRL_HEADER
|
|
46
|
+
const TAG_LIST_HEADER = T + 56; // 72 - HWPTAG_LIST_HEADER
|
|
47
|
+
const TAG_PAGE_DEF = T + 57; // 73 - HWPTAG_PAGE_DEF
|
|
48
|
+
const TAG_FOOTNOTE_SHAPE = T + 58; // 74 - HWPTAG_FOOTNOTE_SHAPE
|
|
49
|
+
const TAG_TABLE = T + 61; // 77 - HWPTAG_TABLE (표 개체)
|
|
50
|
+
const TAG_SHAPE_COMPONENT_PICTURE = T + 69; // 85 - HWPTAG_SHAPE_COMPONENT_PICTURE
|
|
51
|
+
|
|
52
|
+
// Control IDs (stored as LE UINT32, MAKE_4CHID order)
|
|
53
|
+
const CTRL_TABLE = 0x74626C20; // 'tbl ' table
|
|
54
|
+
const CTRL_SECD = 0x73656364; // 'secd' section definition
|
|
55
|
+
const CTRL_PIC = 0x24706963; // '$pic' picture/image
|
|
56
|
+
|
|
57
|
+
/** Border width index table (points) — 표 26 테두리선 굵기 (mm→pt conversion) */
|
|
42
58
|
const BORDER_W_PT = [
|
|
43
59
|
0.28, 0.34, 0.43, 0.57, 0.71, 0.85,
|
|
44
60
|
1.13, 1.42, 1.70, 1.98, 2.84, 4.25,
|
|
45
61
|
5.67, 8.50, 11.34, 14.17,
|
|
46
62
|
];
|
|
47
63
|
|
|
64
|
+
/** 표 25 테두리선 종류: 0=실선(solid), 2=점선(dash), 3=dash-dot, 7=2중선(double) */
|
|
48
65
|
const BORDER_KIND_IDX: Record<string, number> = {
|
|
49
|
-
|
|
66
|
+
solid: 0, dash: 2, dot: 3, double: 7, none: 0,
|
|
50
67
|
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 표 44 문단 모양 속성1:
|
|
71
|
+
* bits 2-4 = 정렬 방식 (0=양쪽, 1=왼쪽, 2=오른쪽, 3=가운데)
|
|
72
|
+
*/
|
|
51
73
|
const ALIGN_CODE: Record<string, number> = {
|
|
52
74
|
justify: 0, left: 1, right: 2, center: 3,
|
|
53
75
|
};
|
|
@@ -87,6 +109,8 @@ class BufWriter {
|
|
|
87
109
|
|
|
88
110
|
i32(v: number): this { return this.u32(v < 0 ? v + 0x100000000 : v); }
|
|
89
111
|
|
|
112
|
+
i16(v: number): this { return this.u16(v < 0 ? v + 0x10000 : v); }
|
|
113
|
+
|
|
90
114
|
bytes(d: Uint8Array): this { this.chunks.push(d); this._sz += d.length; return this; }
|
|
91
115
|
zeros(n: number): this { this.chunks.push(new Uint8Array(n)); this._sz += n; return this; }
|
|
92
116
|
|
|
@@ -145,6 +169,13 @@ function psKey(p: ParaProps): string {
|
|
|
145
169
|
function bfKey(s: Stroke, bg?: string): string {
|
|
146
170
|
return `${s.kind}|${s.pt}|${s.color}|${bg ?? ''}`;
|
|
147
171
|
}
|
|
172
|
+
function bfPerSideKey(l: Stroke, r: Stroke, t: Stroke, b: Stroke, bg?: string): string {
|
|
173
|
+
return `${bfKey(l)}/${bfKey(r)}/${bfKey(t)}/${bfKey(b)}/${bg ?? ''}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type BfEntry =
|
|
177
|
+
| { uniform: true; s: Stroke; bg?: string }
|
|
178
|
+
| { uniform: false; l: Stroke; r: Stroke; t: Stroke; b: Stroke; bg?: string };
|
|
148
179
|
|
|
149
180
|
class StyleCollector {
|
|
150
181
|
readonly DEF_STROKE: Stroke = { kind: 'solid', pt: 0.5, color: '000000' };
|
|
@@ -158,7 +189,7 @@ class StyleCollector {
|
|
|
158
189
|
psProps: ParaProps[] = [{}];
|
|
159
190
|
private psIdx = new Map<string, number>([[psKey({}), 0]]);
|
|
160
191
|
|
|
161
|
-
bfData:
|
|
192
|
+
bfData: BfEntry[] = [];
|
|
162
193
|
private bfIdx = new Map<string, number>();
|
|
163
194
|
|
|
164
195
|
constructor() {
|
|
@@ -193,12 +224,21 @@ class StyleCollector {
|
|
|
193
224
|
return id;
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
/** Returns 1-based border fill ID
|
|
227
|
+
/** Returns 1-based border fill ID */
|
|
197
228
|
addBorderFill(s: Stroke, bg?: string): number {
|
|
198
229
|
const k = bfKey(s, bg);
|
|
199
230
|
if (this.bfIdx.has(k)) return this.bfIdx.get(k)!;
|
|
200
231
|
const id = this.bfData.length + 1;
|
|
201
|
-
this.bfData.push({ s, bg });
|
|
232
|
+
this.bfData.push({ uniform: true, s, bg });
|
|
233
|
+
this.bfIdx.set(k, id);
|
|
234
|
+
return id;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
addBorderFillPerSide(l: Stroke, r: Stroke, t: Stroke, b: Stroke, bg?: string): number {
|
|
238
|
+
const k = bfPerSideKey(l, r, t, b, bg);
|
|
239
|
+
if (this.bfIdx.has(k)) return this.bfIdx.get(k)!;
|
|
240
|
+
const id = this.bfData.length + 1;
|
|
241
|
+
this.bfData.push({ uniform: false, l, r, t, b, bg });
|
|
202
242
|
this.bfIdx.set(k, id);
|
|
203
243
|
return id;
|
|
204
244
|
}
|
|
@@ -214,7 +254,17 @@ function collectNode(node: ContentNode, col: StyleCollector): void {
|
|
|
214
254
|
if (node.props.defaultStroke) col.addBorderFill(node.props.defaultStroke);
|
|
215
255
|
for (const row of node.kids) {
|
|
216
256
|
for (const cell of row.kids) {
|
|
217
|
-
|
|
257
|
+
const defStroke = node.props.defaultStroke ?? col.DEF_STROKE;
|
|
258
|
+
const cp = cell.props;
|
|
259
|
+
if (cp.top || cp.bot || cp.left || cp.right) {
|
|
260
|
+
col.addBorderFillPerSide(
|
|
261
|
+
cp.left ?? defStroke, cp.right ?? defStroke,
|
|
262
|
+
cp.top ?? defStroke, cp.bot ?? defStroke,
|
|
263
|
+
cp.bg,
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
col.addBorderFill(defStroke, cp.bg);
|
|
267
|
+
}
|
|
218
268
|
for (const para of cell.kids) collectNode(para, col);
|
|
219
269
|
}
|
|
220
270
|
}
|
|
@@ -225,24 +275,67 @@ function collectNode(node: ContentNode, col: StyleCollector): void {
|
|
|
225
275
|
DocInfo record builders
|
|
226
276
|
═══════════════════════════════════════════════════════════════ */
|
|
227
277
|
|
|
228
|
-
|
|
278
|
+
/**
|
|
279
|
+
* HWPTAG_DOCUMENT_PROPERTIES (표 14 문서 속성) — 26 bytes
|
|
280
|
+
* level 0 in DocInfo
|
|
281
|
+
*/
|
|
282
|
+
function mkDocumentProperties(): Uint8Array {
|
|
229
283
|
return new BufWriter()
|
|
230
|
-
.
|
|
231
|
-
.
|
|
232
|
-
.
|
|
233
|
-
.
|
|
234
|
-
.
|
|
235
|
-
.
|
|
236
|
-
.
|
|
237
|
-
.u32(0)
|
|
238
|
-
.
|
|
284
|
+
.u16(1) // nSection (구역 개수)
|
|
285
|
+
.u16(1) // pageStart (페이지 시작 번호)
|
|
286
|
+
.u16(1) // footnoteStart (각주 시작 번호)
|
|
287
|
+
.u16(1) // endnoteStart (미주 시작 번호)
|
|
288
|
+
.u16(1) // pictureStart (그림 시작 번호)
|
|
289
|
+
.u16(1) // tableStart (표 시작 번호)
|
|
290
|
+
.u16(1) // formulaStart (수식 시작 번호)
|
|
291
|
+
.u32(0) // listId (캐럿 위치: 리스트 아이디)
|
|
292
|
+
.u32(0) // paraId (캐럿 위치: 문단 아이디)
|
|
293
|
+
.u32(0) // charUnitPos (캐럿 위치: 글자 단위 위치)
|
|
294
|
+
.build(); // 26 bytes
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* HWPTAG_ID_MAPPINGS (표 15 아이디 매핑 헤더) — 72 bytes (INT32 array[18])
|
|
299
|
+
* 표 16 아이디 매핑 개수 인덱스:
|
|
300
|
+
* [0]=binData, [1]=한글글꼴, [2]=영어글꼴, [3]=한자글꼴, [4]=일어글꼴,
|
|
301
|
+
* [5]=기타글꼴, [6]=기호글꼴, [7]=사용자글꼴,
|
|
302
|
+
* [8]=테두리/배경, [9]=글자모양, [10]=탭정의, [11]=문단번호,
|
|
303
|
+
* [12]=글머리표, [13]=문단모양, [14]=스타일, [15]=메모모양,
|
|
304
|
+
* [16]=변경추적, [17]=변경추적사용자
|
|
305
|
+
* level 1 in DocInfo
|
|
306
|
+
*/
|
|
307
|
+
function mkIdMappings(col: StyleCollector, nBinData = 0): Uint8Array {
|
|
308
|
+
const w = new BufWriter();
|
|
309
|
+
w.u32(nBinData); // [0] nBinData
|
|
310
|
+
// [1-7]: 7 font language categories — all use same font list
|
|
311
|
+
for (let i = 0; i < 7; i++) w.u32(col.fonts.length);
|
|
312
|
+
w.u32(col.bfData.length); // [8] nBorderFill
|
|
313
|
+
w.u32(col.csProps.length); // [9] nCharShape
|
|
314
|
+
w.u32(0); // [10] nTabDef
|
|
315
|
+
w.u32(0); // [11] nNumbering
|
|
316
|
+
w.u32(0); // [12] nBullet
|
|
317
|
+
w.u32(col.psProps.length); // [13] nParaShape
|
|
318
|
+
w.u32(0); // [14] nStyle
|
|
319
|
+
w.u32(0); // [15] nMemoShape (5.0.2.1+)
|
|
320
|
+
w.u32(0); // [16] nTrackChange (5.0.3.2+)
|
|
321
|
+
w.u32(0); // [17] nTrackChangeAuthor (5.0.3.2+)
|
|
322
|
+
return w.build(); // 18 × 4 = 72 bytes
|
|
239
323
|
}
|
|
240
324
|
|
|
325
|
+
/**
|
|
326
|
+
* HWPTAG_FACE_NAME (표 19 글꼴) — variable length
|
|
327
|
+
* Complete structure: attr + name + altFontType + altLen + fontTypeInfo[10] + basicLen
|
|
328
|
+
* level 1 in DocInfo
|
|
329
|
+
*/
|
|
241
330
|
function mkFaceName(name: string): Uint8Array {
|
|
242
331
|
return new BufWriter()
|
|
243
|
-
.u8(0)
|
|
244
|
-
.u16(name.length)
|
|
245
|
-
.utf16(name)
|
|
332
|
+
.u8(0) // attr (0 = no alt/base font available)
|
|
333
|
+
.u16(name.length) // len1: 글꼴 이름 길이
|
|
334
|
+
.utf16(name) // 글꼴 이름 (UTF-16LE)
|
|
335
|
+
.u8(0) // altFontType: 대체 글꼴 유형 (0)
|
|
336
|
+
.u16(0) // altFontLen (0 = no alt font name)
|
|
337
|
+
.zeros(10) // fontTypeInfo[10]: 글꼴 유형 정보 (표 22)
|
|
338
|
+
.u16(0) // basicFontLen (0 = no basic font name)
|
|
246
339
|
.build();
|
|
247
340
|
}
|
|
248
341
|
|
|
@@ -254,63 +347,259 @@ function borderWidthIdx(pt: number): number {
|
|
|
254
347
|
return best;
|
|
255
348
|
}
|
|
256
349
|
|
|
350
|
+
/**
|
|
351
|
+
* HWPTAG_BORDER_FILL (표 23 테두리/배경) — 32+n bytes
|
|
352
|
+
* Field layout (GROUPED, not interleaved):
|
|
353
|
+
* offset 0: UINT16 attr
|
|
354
|
+
* offset 2: UINT8[4] border types [left, right, top, bottom]
|
|
355
|
+
* offset 6: UINT8[4] border widths [left, right, top, bottom]
|
|
356
|
+
* offset 10: COLORREF[4] border colors [left, right, top, bottom]
|
|
357
|
+
* offset 26: UINT8 diagonal type
|
|
358
|
+
* offset 27: UINT8 diagonal width
|
|
359
|
+
* offset 28: COLORREF diagonal color
|
|
360
|
+
* offset 32: fill info (채우기 정보, 표 28)
|
|
361
|
+
* level 1 in DocInfo
|
|
362
|
+
*/
|
|
257
363
|
function mkBorderFill(s: Stroke, bg?: string): Uint8Array {
|
|
258
364
|
const w = new BufWriter();
|
|
259
|
-
|
|
260
|
-
const t = BORDER_KIND_IDX[s.kind] ?? 1;
|
|
365
|
+
const t = BORDER_KIND_IDX[s.kind] ?? 0;
|
|
261
366
|
const wi = borderWidthIdx(s.pt);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
367
|
+
const col = s.color || '000000';
|
|
368
|
+
|
|
369
|
+
w.u16(0); // attr (UINT16)
|
|
370
|
+
// 4 border types: [left, right, top, bottom]
|
|
371
|
+
for (let i = 0; i < 4; i++) w.u8(t);
|
|
372
|
+
// 4 border widths: [left, right, top, bottom]
|
|
373
|
+
for (let i = 0; i < 4; i++) w.u8(wi);
|
|
374
|
+
// 4 border colors: [left, right, top, bottom]
|
|
375
|
+
for (let i = 0; i < 4; i++) w.colorRef(col);
|
|
376
|
+
// diagonal: type, width, color
|
|
377
|
+
w.u8(0).u8(0).colorRef('000000');
|
|
378
|
+
// fill info (채우기 정보, 표 28): type + optional color data
|
|
379
|
+
if (bg) {
|
|
380
|
+
w.u32(1); // type = 0x01 (단색 채우기)
|
|
381
|
+
w.colorRef(bg); // 배경색
|
|
382
|
+
w.colorRef('FFFFFF'); // 무늬색 (no pattern = white)
|
|
383
|
+
w.u32(0); // 무늬 종류 (0 = none)
|
|
384
|
+
} else {
|
|
385
|
+
w.u32(0); // type = 0x00 (채우기 없음)
|
|
386
|
+
}
|
|
387
|
+
return w.build(); // 36 bytes (no fill) or 48 bytes (solid fill)
|
|
268
388
|
}
|
|
269
389
|
|
|
390
|
+
function mkBorderFillPerSide(
|
|
391
|
+
left: Stroke, right: Stroke, top: Stroke, bottom: Stroke, bg?: string,
|
|
392
|
+
): Uint8Array {
|
|
393
|
+
const w = new BufWriter();
|
|
394
|
+
w.u16(0); // attr
|
|
395
|
+
// 4 border types [left, right, top, bottom]
|
|
396
|
+
w.u8(BORDER_KIND_IDX[left.kind] ?? 0);
|
|
397
|
+
w.u8(BORDER_KIND_IDX[right.kind] ?? 0);
|
|
398
|
+
w.u8(BORDER_KIND_IDX[top.kind] ?? 0);
|
|
399
|
+
w.u8(BORDER_KIND_IDX[bottom.kind] ?? 0);
|
|
400
|
+
// 4 border widths
|
|
401
|
+
w.u8(borderWidthIdx(left.pt));
|
|
402
|
+
w.u8(borderWidthIdx(right.pt));
|
|
403
|
+
w.u8(borderWidthIdx(top.pt));
|
|
404
|
+
w.u8(borderWidthIdx(bottom.pt));
|
|
405
|
+
// 4 border colors
|
|
406
|
+
w.colorRef(left.color || '000000');
|
|
407
|
+
w.colorRef(right.color || '000000');
|
|
408
|
+
w.colorRef(top.color || '000000');
|
|
409
|
+
w.colorRef(bottom.color || '000000');
|
|
410
|
+
// diagonal
|
|
411
|
+
w.u8(0).u8(0).colorRef('000000');
|
|
412
|
+
// fill info
|
|
413
|
+
if (bg) {
|
|
414
|
+
w.u32(1).colorRef(bg).colorRef('FFFFFF').u32(0);
|
|
415
|
+
} else {
|
|
416
|
+
w.u32(0);
|
|
417
|
+
}
|
|
418
|
+
return w.build();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* HWPTAG_CHAR_SHAPE (표 33 글자 모양) — 72+ bytes
|
|
423
|
+
* Field layout:
|
|
424
|
+
* offset 0: WORD[7] faceId (언어별 글꼴 ID) — 14 bytes
|
|
425
|
+
* offset 14: UINT8[7] ratio (장평 50~200%) — 7 bytes
|
|
426
|
+
* offset 21: INT8[7] spacing(자간 -50~50%) — 7 bytes
|
|
427
|
+
* offset 28: UINT8[7] relSize(상대크기 10~250%)— 7 bytes
|
|
428
|
+
* offset 35: INT8[7] offset (글자위치) — 7 bytes
|
|
429
|
+
* offset 42: INT32 height (기준크기, HWP단위 = pt×100)
|
|
430
|
+
* offset 46: UINT32 attr (표 35 글자 모양 속성)
|
|
431
|
+
* offset 50: INT8 shadowX
|
|
432
|
+
* offset 51: INT8 shadowY
|
|
433
|
+
* offset 52: COLORREF textColor (글자 색)
|
|
434
|
+
* offset 56: COLORREF underlineColor (밑줄 색)
|
|
435
|
+
* offset 60: COLORREF shadeColor (음영 색)
|
|
436
|
+
* offset 64: COLORREF shadowColor (그림자 색)
|
|
437
|
+
* offset 68: UINT16 borderFillId (5.0.2.1+)
|
|
438
|
+
* offset 70: COLORREF strikeColor (취소선 색, 5.0.3.0+)
|
|
439
|
+
* level 1 in DocInfo
|
|
440
|
+
*/
|
|
270
441
|
function mkCharShape(p: TextProps, col: StyleCollector): Uint8Array {
|
|
271
442
|
const fontId = p.font ? col.font(p.font) : 0;
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
w.u32(Math.round((p.pt ?? 10) * 100));
|
|
280
|
-
// attr @ offset 46
|
|
443
|
+
const height = Math.round((p.pt ?? 10) * 100); // HWP단위: pt×100
|
|
444
|
+
|
|
445
|
+
// 표 35 글자 모양 속성:
|
|
446
|
+
// bit 0: 기울임(italic), bit 1: 진하게(bold)
|
|
447
|
+
// bits 2-4: 밑줄 종류 (1=글자아래), bits 5-8: 밑줄 모양
|
|
448
|
+
// bits 16-17: 위/아래 첨자 (0=없음, 1=위첨자, 2=아래첨자)
|
|
449
|
+
// bits 18-20: 취소선 종류 (1=실선)
|
|
281
450
|
let attr = 0;
|
|
282
|
-
if (p.i) attr |= 1
|
|
283
|
-
if (p.b) attr |=
|
|
284
|
-
if (p.u) attr |= (1 << 2); //
|
|
285
|
-
if (p.s) attr |= (1 << 18); //
|
|
286
|
-
if (p.sup) attr |= (1 << 16); //
|
|
287
|
-
if (p.sub) attr |= (2 << 16); //
|
|
451
|
+
if (p.i) attr |= (1 << 0);
|
|
452
|
+
if (p.b) attr |= (1 << 1);
|
|
453
|
+
if (p.u) attr |= (1 << 2); // 밑줄 종류 = 1 (글자 아래)
|
|
454
|
+
if (p.s) attr |= (1 << 18); // 취소선 종류 = 1
|
|
455
|
+
if (p.sup) attr |= (1 << 16); // 위 첨자
|
|
456
|
+
if (p.sub) attr |= (2 << 16); // 아래 첨자
|
|
457
|
+
|
|
458
|
+
const textColor = p.color ?? '000000';
|
|
459
|
+
|
|
460
|
+
const w = new BufWriter();
|
|
461
|
+
// faceId[7]: WORD[7] — 언어별 글꼴 ID (한글, 영어, 한자, 일어, 기타, 기호, 사용자)
|
|
462
|
+
for (let i = 0; i < 7; i++) w.u16(fontId);
|
|
463
|
+
// ratio[7]: UINT8[7] = 100 (100%)
|
|
464
|
+
for (let i = 0; i < 7; i++) w.u8(100);
|
|
465
|
+
// spacing[7]: INT8[7] = 0 (자간 0%)
|
|
466
|
+
for (let i = 0; i < 7; i++) w.u8(0);
|
|
467
|
+
// relSize[7]: UINT8[7] = 100 (100%)
|
|
468
|
+
for (let i = 0; i < 7; i++) w.u8(100);
|
|
469
|
+
// offset[7]: INT8[7] = 0 (글자 위치 0%)
|
|
470
|
+
for (let i = 0; i < 7; i++) w.u8(0);
|
|
471
|
+
// height: INT32
|
|
472
|
+
w.i32(height);
|
|
473
|
+
// attr: UINT32
|
|
288
474
|
w.u32(attr);
|
|
289
|
-
|
|
290
|
-
w.
|
|
291
|
-
|
|
475
|
+
// shadowX, shadowY: INT8
|
|
476
|
+
w.u8(0).u8(0);
|
|
477
|
+
// textColor: COLORREF
|
|
478
|
+
w.colorRef(textColor);
|
|
479
|
+
// underlineColor: COLORREF (밑줄 색)
|
|
480
|
+
w.colorRef('000000');
|
|
481
|
+
// shadeColor: COLORREF (음영 색) — FFFFFF = no shade
|
|
482
|
+
w.colorRef('FFFFFF');
|
|
483
|
+
// shadowColor: COLORREF (그림자 색)
|
|
484
|
+
w.colorRef('000000');
|
|
485
|
+
// borderFillId: UINT16 (글자 테두리/배경 ID, 5.0.2.1+)
|
|
486
|
+
w.u16(0);
|
|
487
|
+
// strikeColor: COLORREF (취소선 색, 5.0.3.0+)
|
|
488
|
+
w.colorRef('000000');
|
|
489
|
+
return w.build(); // 42 + 4 + 4 + 1 + 1 + 4×4 + 2 + 4 = 74 bytes
|
|
292
490
|
}
|
|
293
491
|
|
|
492
|
+
/**
|
|
493
|
+
* HWPTAG_PARA_SHAPE (표 43 문단 모양) — 54 bytes
|
|
494
|
+
* Field layout:
|
|
495
|
+
* offset 0: UINT32 attr1 (표 44 문단 모양 속성1, bits 2-4 = 정렬 방식)
|
|
496
|
+
* offset 4: INT32 leftMargin
|
|
497
|
+
* offset 8: INT32 rightMargin
|
|
498
|
+
* offset 12: INT32 indent (들여/내어 쓰기)
|
|
499
|
+
* offset 16: INT32 spaceBefore (문단 간격 위)
|
|
500
|
+
* offset 20: INT32 spaceAfter (문단 간격 아래)
|
|
501
|
+
* offset 24: INT32 lineSpacing (줄 간격, 한글 2007 이하, 5.0.2.5 미만)
|
|
502
|
+
* offset 28: UINT16 tabDefId
|
|
503
|
+
* offset 30: UINT16 numberingId/bulletId
|
|
504
|
+
* offset 32: UINT16 borderFillId
|
|
505
|
+
* offset 34: INT16 borderLeft
|
|
506
|
+
* offset 36: INT16 borderRight
|
|
507
|
+
* offset 38: INT16 borderTop
|
|
508
|
+
* offset 40: INT16 borderBottom
|
|
509
|
+
* offset 42: UINT32 attr2 (5.0.1.7+)
|
|
510
|
+
* offset 46: UINT32 attr3 (5.0.2.5+, bits 0-4 = 줄 간격 종류)
|
|
511
|
+
* offset 50: UINT32 lineSpacing2 (5.0.2.5+, 실제 줄 간격)
|
|
512
|
+
* level 1 in DocInfo
|
|
513
|
+
*/
|
|
294
514
|
function mkParaShape(p: ParaProps): Uint8Array {
|
|
515
|
+
// 표 44 bits 2-4: 정렬 방식 (0=양쪽, 1=왼쪽, 2=오른쪽, 3=가운데)
|
|
516
|
+
const alignVal = ALIGN_CODE[p.align ?? 'left'] ?? 1;
|
|
517
|
+
const attr1 = (alignVal & 0x7) << 2; // alignment at bits 2-4
|
|
518
|
+
|
|
519
|
+
// 줄 간격: 비율(%) 표현, 160 = 160%
|
|
520
|
+
const lineSpacePct = p.lineHeight ? Math.round(p.lineHeight * 100) : 160;
|
|
521
|
+
|
|
295
522
|
return new BufWriter()
|
|
296
|
-
.u32(
|
|
297
|
-
.i32(Metric.ptToHwp(p.indentPt ?? 0))
|
|
298
|
-
.i32(0)
|
|
299
|
-
.i32(0)
|
|
300
|
-
.i32(Metric.ptToHwp(p.spaceBefore ?? 0))
|
|
301
|
-
.i32(Metric.ptToHwp(p.spaceAfter ?? 0))
|
|
302
|
-
.i32(
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
523
|
+
.u32(attr1) // 0: attr1
|
|
524
|
+
.i32(Metric.ptToHwp(p.indentPt ?? 0)) // 4: leftMargin (HWPUNIT)
|
|
525
|
+
.i32(0) // 8: rightMargin
|
|
526
|
+
.i32(0) // 12: indent
|
|
527
|
+
.i32(Metric.ptToHwp(p.spaceBefore ?? 0)) // 16: spaceBefore
|
|
528
|
+
.i32(Metric.ptToHwp(p.spaceAfter ?? 0)) // 20: spaceAfter
|
|
529
|
+
.i32(lineSpacePct) // 24: lineSpacing (old format)
|
|
530
|
+
.u16(0) // 28: tabDefId
|
|
531
|
+
.u16(0) // 30: numberingId
|
|
532
|
+
.u16(0) // 32: borderFillId
|
|
533
|
+
.i16(0) // 34: borderLeft
|
|
534
|
+
.i16(0) // 36: borderRight
|
|
535
|
+
.i16(0) // 38: borderTop
|
|
536
|
+
.i16(0) // 40: borderBottom
|
|
537
|
+
.u32(0) // 42: attr2 (5.0.1.7+)
|
|
538
|
+
.u32(0) // 46: attr3 (줄 간격 종류 bits 0-4 = 0: 글자에 따라)
|
|
539
|
+
.u32(lineSpacePct) // 50: lineSpacing2 (5.0.2.5+)
|
|
540
|
+
.build(); // 54 bytes
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* HWPTAG_BIN_DATA (표 17 바이너리 데이터) — variable
|
|
545
|
+
* Storage type 2 = embedded in BinData storage
|
|
546
|
+
* level 1 in DocInfo
|
|
547
|
+
*/
|
|
548
|
+
function mkBinData(id: number, ext: string): Uint8Array {
|
|
549
|
+
const w = new BufWriter();
|
|
550
|
+
w.u16(0x0002); // attr: storage type = 2 (내장 파일)
|
|
551
|
+
w.u16(id); // binDataId (1-based)
|
|
552
|
+
w.u16(ext.length); // extLen (number of UTF-16LE chars)
|
|
553
|
+
w.utf16(ext); // extension string (e.g. "jpg", "png")
|
|
554
|
+
return w.build();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
interface BinImage {
|
|
558
|
+
id: number; // 1-based BIN ID
|
|
559
|
+
ext: string; // file extension without dot
|
|
560
|
+
data: Uint8Array; // raw image bytes
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function buildDocInfoStream(col: StyleCollector, images: BinImage[] = []): Uint8Array {
|
|
564
|
+
const chunks: Uint8Array[] = [];
|
|
565
|
+
|
|
566
|
+
// HWPTAG_DOCUMENT_PROPERTIES at level 0 (required first record)
|
|
567
|
+
chunks.push(mkRec(TAG_DOCUMENT_PROPERTIES, 0, mkDocumentProperties()));
|
|
568
|
+
|
|
569
|
+
// HWPTAG_ID_MAPPINGS at level 1 (child of DOCUMENT_PROPERTIES)
|
|
570
|
+
chunks.push(mkRec(TAG_ID_MAPPINGS, 1, mkIdMappings(col, images.length)));
|
|
571
|
+
|
|
572
|
+
// HWPTAG_BIN_DATA at level 1: one record per image
|
|
573
|
+
for (const img of images) {
|
|
574
|
+
chunks.push(mkRec(TAG_BIN_DATA, 1, mkBinData(img.id, img.ext)));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// HWPTAG_FACE_NAME at level 1: 7 language categories × nFonts records
|
|
578
|
+
// Order: all hangul fonts, then all english fonts, ..., then all user fonts
|
|
579
|
+
for (let cat = 0; cat < 7; cat++) {
|
|
580
|
+
for (const name of col.fonts) {
|
|
581
|
+
chunks.push(mkRec(TAG_FACE_NAME, 1, mkFaceName(name)));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// HWPTAG_BORDER_FILL at level 1
|
|
586
|
+
for (const entry of col.bfData) {
|
|
587
|
+
chunks.push(mkRec(TAG_BORDER_FILL, 1,
|
|
588
|
+
entry.uniform
|
|
589
|
+
? mkBorderFill(entry.s, entry.bg)
|
|
590
|
+
: mkBorderFillPerSide(entry.l, entry.r, entry.t, entry.b, entry.bg)));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// HWPTAG_CHAR_SHAPE at level 1
|
|
594
|
+
for (const p of col.csProps) {
|
|
595
|
+
chunks.push(mkRec(TAG_CHAR_SHAPE, 1, mkCharShape(p, col)));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// HWPTAG_PARA_SHAPE at level 1
|
|
599
|
+
for (const p of col.psProps) {
|
|
600
|
+
chunks.push(mkRec(TAG_PARA_SHAPE, 1, mkParaShape(p)));
|
|
601
|
+
}
|
|
602
|
+
|
|
314
603
|
return concatU8(chunks);
|
|
315
604
|
}
|
|
316
605
|
|
|
@@ -331,27 +620,48 @@ function mkPageDef(dims: PageDims): Uint8Array {
|
|
|
331
620
|
.build(); // 40 bytes
|
|
332
621
|
}
|
|
333
622
|
|
|
334
|
-
|
|
623
|
+
/**
|
|
624
|
+
* HWPTAG_PARA_HEADER (표 58 문단 헤더) — 24 bytes
|
|
625
|
+
* offset 0: UINT32 nchars (문단 내 글자 수, 컨트롤 포함)
|
|
626
|
+
* offset 4: UINT32 ctrlMask (컨트롤 마스크: 1<<ctrlCode)
|
|
627
|
+
* offset 8: UINT16 paraShapeId (문단 모양 아이디)
|
|
628
|
+
* offset 10: UINT8 styleId (문단 스타일 아이디, 0=기본)
|
|
629
|
+
* offset 11: UINT8 columnBreak (단 나누기 종류)
|
|
630
|
+
* offset 12: UINT16 csCount (글자 모양 정보 수)
|
|
631
|
+
* offset 14: UINT16 rangeTagCount(range tag 정보 수)
|
|
632
|
+
* offset 16: UINT16 lineAlignCount(각 줄 align 정보 수)
|
|
633
|
+
* offset 18: UINT32 instanceId (문단 Instance ID, unique)
|
|
634
|
+
* offset 22: UINT16 trackChange (변경추적 병합 여부, 5.0.3.2+)
|
|
635
|
+
*/
|
|
636
|
+
function mkParaHeader(
|
|
637
|
+
nchars: number,
|
|
638
|
+
ctrlMask: number,
|
|
639
|
+
psId: number,
|
|
640
|
+
csCount: number,
|
|
641
|
+
lineAlignCount: number = 0,
|
|
642
|
+
instanceId: number = 0,
|
|
643
|
+
): Uint8Array {
|
|
335
644
|
return new BufWriter()
|
|
336
|
-
.u32(
|
|
337
|
-
.
|
|
338
|
-
.
|
|
339
|
-
.u8(0)
|
|
340
|
-
.
|
|
341
|
-
.u16(csCount)
|
|
342
|
-
.u16(0)
|
|
343
|
-
.u16(
|
|
344
|
-
.
|
|
345
|
-
.
|
|
645
|
+
.u32(nchars) // 0: nchars
|
|
646
|
+
.u32(ctrlMask) // 4: ctrlMask
|
|
647
|
+
.u16(psId) // 8: paraShapeId
|
|
648
|
+
.u8(0) // 10: styleId (0 = default)
|
|
649
|
+
.u8(0) // 11: columnBreak (0 = none)
|
|
650
|
+
.u16(csCount) // 12: charShapeCount
|
|
651
|
+
.u16(0) // 14: rangeTagCount
|
|
652
|
+
.u16(lineAlignCount)// 16: lineAlignCount
|
|
653
|
+
.u32(instanceId) // 18: instanceId (unique)
|
|
654
|
+
.u16(0) // 22: trackChange
|
|
655
|
+
.build(); // 24 bytes
|
|
346
656
|
}
|
|
347
657
|
|
|
348
658
|
function mkParaText(text: string): Uint8Array {
|
|
349
659
|
const w = new BufWriter();
|
|
350
660
|
for (let i = 0; i < text.length; i++) {
|
|
351
661
|
const c = text.charCodeAt(i);
|
|
352
|
-
w.u16(c < 32 ? 0 : c);
|
|
662
|
+
w.u16(c < 32 ? 0 : c);
|
|
353
663
|
}
|
|
354
|
-
w.u16(13); // paragraph terminator (0x000D)
|
|
664
|
+
w.u16(13); // paragraph terminator (0x000D = para break)
|
|
355
665
|
return w.build();
|
|
356
666
|
}
|
|
357
667
|
|
|
@@ -361,48 +671,207 @@ function mkParaCharShape(pairs: [pos: number, id: number][]): Uint8Array {
|
|
|
361
671
|
return w.build();
|
|
362
672
|
}
|
|
363
673
|
|
|
364
|
-
|
|
674
|
+
/**
|
|
675
|
+
* PARA_TEXT for section definition paragraph.
|
|
676
|
+
* 확장 컨트롤(size=8): ctrl_code(1) + ctrlId_lo(1) + ctrlId_hi(1) + ptr[4] + ctrl_code(1)
|
|
677
|
+
* + para terminator = 9 WCHAR total → nchars = 9
|
|
678
|
+
*/
|
|
679
|
+
function mkSecdParaText(): Uint8Array {
|
|
680
|
+
const lo = CTRL_SECD & 0xFFFF;
|
|
681
|
+
const hi = (CTRL_SECD >>> 16) & 0xFFFF;
|
|
682
|
+
return new BufWriter()
|
|
683
|
+
.u16(0x0002).u16(lo).u16(hi).u16(0).u16(0).u16(0).u16(0).u16(0x0002) // 8 WCHAR ctrl ref
|
|
684
|
+
.u16(0x000D) // para terminator
|
|
685
|
+
.build(); // 9 WCHAR = 18 bytes
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* PARA_TEXT for table container paragraph.
|
|
690
|
+
* ctrl code for 그리기 개체/표 = 11 (0x000B)
|
|
691
|
+
* 확장 컨트롤(size=8): 0x000B + tbl_lo + tbl_hi + 4×0 + 0x000B + terminator = 9 WCHAR
|
|
692
|
+
*/
|
|
693
|
+
function mkTableParaText(): Uint8Array {
|
|
694
|
+
const lo = CTRL_TABLE & 0xFFFF;
|
|
695
|
+
const hi = (CTRL_TABLE >>> 16) & 0xFFFF;
|
|
696
|
+
return new BufWriter()
|
|
697
|
+
.u16(0x000B).u16(lo).u16(hi).u16(0).u16(0).u16(0).u16(0).u16(0x000B) // 8 WCHAR ctrl ref
|
|
698
|
+
.u16(0x000D) // para terminator
|
|
699
|
+
.build(); // 9 WCHAR = 18 bytes
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* PARA_TEXT for picture container paragraph.
|
|
704
|
+
* ctrl code for 그리기 개체/표/수식 등 = 11 (0x000B)
|
|
705
|
+
* 확장 컨트롤(size=8): 0x000B + pic_lo + pic_hi + 4×0 + 0x000B + terminator = 9 WCHAR
|
|
706
|
+
*/
|
|
707
|
+
function mkPicParaText(): Uint8Array {
|
|
708
|
+
const lo = CTRL_PIC & 0xFFFF;
|
|
709
|
+
const hi = (CTRL_PIC >>> 16) & 0xFFFF;
|
|
710
|
+
return new BufWriter()
|
|
711
|
+
.u16(0x000B).u16(lo).u16(hi).u16(0).u16(0).u16(0).u16(0).u16(0x000B)
|
|
712
|
+
.u16(0x000D) // para terminator
|
|
713
|
+
.build(); // 9 WCHAR = 18 bytes
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* HWPTAG_SHAPE_COMPONENT_PICTURE (tag 85) — 97 bytes
|
|
718
|
+
* Contains drawing common attrs + picture-specific attrs (표 107)
|
|
719
|
+
* Critical field: BINDATA_ID at offset 87 (2 bytes)
|
|
720
|
+
*/
|
|
721
|
+
function mkShapeComponentPicture(binDataId: number, wHwp: number, hHwp: number): Uint8Array {
|
|
722
|
+
const w = new BufWriter();
|
|
723
|
+
// Drawing common attributes (19 bytes)
|
|
724
|
+
w.u32(CTRL_PIC); // ctrlId: '$pic' (4)
|
|
725
|
+
w.zeros(15); // border style, fill type (all default/none)
|
|
726
|
+
// Picture attributes (표 107, 78 bytes total = up to offset 97)
|
|
727
|
+
// Geometric region (HWPUNIT bounds)
|
|
728
|
+
w.u32(0); // left (4)
|
|
729
|
+
w.u32(0); // top (4)
|
|
730
|
+
w.u32(wHwp); // right = width (4)
|
|
731
|
+
w.u32(hHwp); // bottom = height (4)
|
|
732
|
+
// Drawing bounding box
|
|
733
|
+
w.u32(0); // left (4)
|
|
734
|
+
w.u32(0); // top (4)
|
|
735
|
+
w.u32(wHwp); // right (4)
|
|
736
|
+
w.u32(hHwp); // bottom (4)
|
|
737
|
+
// Rotation / effect (zeros = no rotation, no effect)
|
|
738
|
+
w.zeros(20); // rotate angle, rotation center X/Y, effect flags (20)
|
|
739
|
+
// imageInfo (5 bytes at offset 87 from start)
|
|
740
|
+
// Currently at offset: 19 + 4+4+4+4 + 4+4+4+4 + 20 = 19+16+16+20 = 71... need 87
|
|
741
|
+
// Gap: 87 - 71 = 16 more bytes of zeros before binDataId
|
|
742
|
+
w.zeros(16); // padding to reach offset 87
|
|
743
|
+
w.u16(binDataId); // BINDATA_ID (2) — which BIN stream to use
|
|
744
|
+
w.u8(0); // image type (0 = stored in BinData storage)
|
|
745
|
+
w.u8(0); // flags
|
|
746
|
+
w.u8(0); // padding
|
|
747
|
+
w.zeros(5); // remaining (to reach 97 bytes total)
|
|
748
|
+
// Total: 19+16+16+20+16+2+1+1+1+5 = 97
|
|
749
|
+
return w.build(); // 97 bytes
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Encode an image node as a picture paragraph (level lv) */
|
|
753
|
+
function encodePicPara(
|
|
754
|
+
imgNode: { b64: string; mime: string; w: number; h: number },
|
|
755
|
+
binDataId: number,
|
|
756
|
+
col: StyleCollector,
|
|
757
|
+
lv: number,
|
|
758
|
+
idGen: () => number,
|
|
759
|
+
): Uint8Array[] {
|
|
760
|
+
// imgNode.w / imgNode.h are in pt (set by all decoders)
|
|
761
|
+
const wHwp = Metric.ptToHwp(Math.max(imgNode.w, 10));
|
|
762
|
+
const hHwp = Metric.ptToHwp(Math.max(imgNode.h, 10));
|
|
763
|
+
|
|
764
|
+
const TABLE_CTRL_MASK = 1 << 11;
|
|
765
|
+
const instanceId = idGen();
|
|
766
|
+
const psId = col.addParaShape({});
|
|
767
|
+
|
|
768
|
+
return [
|
|
769
|
+
mkRec(TAG_PARA_HEADER, lv, mkParaHeader(9, TABLE_CTRL_MASK, psId, 1, 0, instanceId)),
|
|
770
|
+
mkRec(TAG_PARA_TEXT, lv + 1, mkPicParaText()),
|
|
771
|
+
mkRec(TAG_PARA_CHAR_SHAPE, lv + 1, mkParaCharShape([[0, 0]])),
|
|
772
|
+
// $pic CTRL_HEADER (GHDR) at level lv+1
|
|
773
|
+
mkRec(TAG_CTRL_HEADER, lv + 1, mkPicCtrl(wHwp, hHwp, idGen())),
|
|
774
|
+
// SHAPE_COMPONENT_PICTURE at level lv+2
|
|
775
|
+
mkRec(TAG_SHAPE_COMPONENT_PICTURE, lv + 2, mkShapeComponentPicture(binDataId, wHwp, hHwp)),
|
|
776
|
+
];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function encodePara(para: ParaNode, col: StyleCollector, lv: number, instanceId: number): Uint8Array[] {
|
|
365
780
|
let text = '';
|
|
366
781
|
const csPairs: [number, number][] = [];
|
|
367
782
|
let pos = 0;
|
|
368
783
|
|
|
369
784
|
for (const kid of para.kids) {
|
|
370
|
-
if (kid.tag
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
785
|
+
if (kid.tag === 'span') {
|
|
786
|
+
const span = kid as SpanNode;
|
|
787
|
+
const csId = col.addCharShape(span.props);
|
|
788
|
+
if (csPairs.length === 0 || csPairs[csPairs.length - 1][1] !== csId) {
|
|
789
|
+
csPairs.push([pos, csId]);
|
|
790
|
+
}
|
|
791
|
+
for (const t of span.kids) {
|
|
792
|
+
if (t.tag === 'txt') { text += t.content; pos += t.content.length; }
|
|
793
|
+
}
|
|
379
794
|
}
|
|
380
795
|
}
|
|
381
796
|
|
|
382
797
|
if (csPairs.length === 0) csPairs.push([0, 0]);
|
|
383
798
|
|
|
384
|
-
const psId
|
|
799
|
+
const psId = col.addParaShape(para.props);
|
|
800
|
+
const nchars = text.length + 1; // text + para terminator
|
|
801
|
+
|
|
385
802
|
return [
|
|
386
|
-
mkRec(TAG_PARA_HEADER,
|
|
387
|
-
mkRec(TAG_PARA_TEXT,
|
|
803
|
+
mkRec(TAG_PARA_HEADER, lv, mkParaHeader(nchars, 0, psId, csPairs.length, 0, instanceId)),
|
|
804
|
+
mkRec(TAG_PARA_TEXT, lv + 1, mkParaText(text)),
|
|
388
805
|
mkRec(TAG_PARA_CHAR_SHAPE, lv + 1, mkParaCharShape(csPairs)),
|
|
389
806
|
];
|
|
390
807
|
}
|
|
391
808
|
|
|
392
809
|
/* ── Table encoding ─────────────────────────────────────────── */
|
|
393
810
|
|
|
394
|
-
|
|
395
|
-
|
|
811
|
+
/**
|
|
812
|
+
* CTRL_HEADER for table ctrl — 46-byte GHDR (개체 공통 속성, 표 68)
|
|
813
|
+
* Layout: ctrlId(4)+attr(4)+vOff(4)+hOff(4)+w(4)+h(4)+z(4)+margins(8)+instanceId(4)+pageBreak(4)+captionLen(2)
|
|
814
|
+
*/
|
|
815
|
+
function mkTableCtrl(wHwp: number, hHwp: number, instanceId: number): Uint8Array {
|
|
816
|
+
return new BufWriter()
|
|
817
|
+
.u32(CTRL_TABLE) // ctrlId: 'tbl ' (4)
|
|
818
|
+
.u32(0x082a2211) // attr — value from real file (4)
|
|
819
|
+
.i32(0) // vOff (4)
|
|
820
|
+
.i32(0) // hOff (4)
|
|
821
|
+
.u32(wHwp) // width in HWPUNIT (4)
|
|
822
|
+
.u32(hHwp) // height in HWPUNIT (4)
|
|
823
|
+
.i32(7) // z-order — value from real file (4)
|
|
824
|
+
.u16(140).u16(140).u16(140).u16(140) // outer margins left/right/top/bottom (8)
|
|
825
|
+
.u32(instanceId) // instanceId (4)
|
|
826
|
+
.i32(0) // pageBreak (4)
|
|
827
|
+
.u16(0) // captionLen (2)
|
|
828
|
+
.build(); // 46 bytes
|
|
396
829
|
}
|
|
397
830
|
|
|
398
|
-
|
|
831
|
+
/**
|
|
832
|
+
* CTRL_HEADER for picture ctrl — 46-byte GHDR ($pic)
|
|
833
|
+
*/
|
|
834
|
+
function mkPicCtrl(wHwp: number, hHwp: number, instanceId: number): Uint8Array {
|
|
835
|
+
return new BufWriter()
|
|
836
|
+
.u32(CTRL_PIC) // ctrlId: '$pic' (4)
|
|
837
|
+
.u32(0x082a2211) // attr — same flags as table (4)
|
|
838
|
+
.i32(0) // vOff (4)
|
|
839
|
+
.i32(0) // hOff (4)
|
|
840
|
+
.u32(wHwp) // width in HWPUNIT (4)
|
|
841
|
+
.u32(hHwp) // height in HWPUNIT (4)
|
|
842
|
+
.i32(0) // z-order (4)
|
|
843
|
+
.u16(0).u16(0).u16(0).u16(0) // outer margins (8)
|
|
844
|
+
.u32(instanceId) // instanceId (4)
|
|
845
|
+
.i32(0) // pageBreak (4)
|
|
846
|
+
.u16(0) // captionLen (2)
|
|
847
|
+
.build(); // 46 bytes
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* HWPTAG_TABLE (표 75) — variable
|
|
852
|
+
* Layout (verified from real HWP file):
|
|
853
|
+
* UINT32 attr (0x04000006)
|
|
854
|
+
* UINT16 rowCnt
|
|
855
|
+
* UINT16 colCnt
|
|
856
|
+
* UINT16 cellSpacing
|
|
857
|
+
* UINT16[4] innerMargins: left=510, right=510, top=141, bottom=141 (HWPUNIT16)
|
|
858
|
+
* UINT16[rowCnt] rowSizes (nominal, actual height from cell LIST_HEADER)
|
|
859
|
+
* UINT16 borderFillId
|
|
860
|
+
* UINT16 validZoneCount
|
|
861
|
+
*/
|
|
862
|
+
function mkTableRecord(rowCnt: number, colCnt: number, rowHwp: number[], bfId: number): Uint8Array {
|
|
399
863
|
const w = new BufWriter();
|
|
400
|
-
w.u32(
|
|
864
|
+
w.u32(0x04000006); // attr — from real file
|
|
401
865
|
w.u16(rowCnt);
|
|
402
866
|
w.u16(colCnt);
|
|
403
|
-
w.
|
|
404
|
-
|
|
405
|
-
w.u16(
|
|
867
|
+
w.u16(0); // cellSpacing
|
|
868
|
+
w.u16(510); // inner margin left (0x01fe HWPUNIT16) — from real file
|
|
869
|
+
w.u16(510); // inner margin right
|
|
870
|
+
w.u16(141); // inner margin top (0x008d HWPUNIT16) — from real file
|
|
871
|
+
w.u16(141); // inner margin bottom
|
|
872
|
+
for (const h of rowHwp) w.u16(Math.max(1, h & 0xFFFF)); // row sizes as UINT16
|
|
873
|
+
w.u16(bfId); // borderFillId
|
|
874
|
+
w.u16(0); // validZoneCount
|
|
406
875
|
return w.build();
|
|
407
876
|
}
|
|
408
877
|
|
|
@@ -413,81 +882,199 @@ function mkCellListHeader(
|
|
|
413
882
|
wHwp: number, hHwp: number,
|
|
414
883
|
bfId: number,
|
|
415
884
|
): Uint8Array {
|
|
416
|
-
//
|
|
417
|
-
//
|
|
885
|
+
// 표 65 문단 리스트 헤더 (cell variant) — 47 bytes from real file:
|
|
886
|
+
// offset 0: UINT16 paraCount
|
|
887
|
+
// offset 2: UINT32 attr
|
|
888
|
+
// offset 6: UINT16 (extended attr)
|
|
889
|
+
// offset 8: UINT16 colAddr
|
|
890
|
+
// offset 10: UINT16 rowAddr
|
|
891
|
+
// offset 12: UINT16 rowSpan
|
|
892
|
+
// offset 14: UINT16 colSpan
|
|
893
|
+
// offset 16: UINT32 width
|
|
894
|
+
// offset 20: UINT32 height
|
|
895
|
+
// offset 24: UINT16 margin left (510 = 0x01fe HWPUNIT16)
|
|
896
|
+
// offset 26: UINT16 margin right
|
|
897
|
+
// offset 28: UINT16 margin top (141 = 0x008d HWPUNIT16)
|
|
898
|
+
// offset 30: UINT16 margin bottom
|
|
899
|
+
// offset 32: UINT16 borderFillId
|
|
900
|
+
// offset 34-46: extended (13 bytes, zeros)
|
|
418
901
|
return new BufWriter()
|
|
419
|
-
.u16(paraCount) //
|
|
420
|
-
.u32(0) //
|
|
421
|
-
.u16(0) //
|
|
422
|
-
.u16(col) //
|
|
423
|
-
.u16(row) // 10
|
|
424
|
-
.u16(rs) // 12
|
|
425
|
-
.u16(cs) // 14
|
|
426
|
-
.u32(wHwp) // 16
|
|
427
|
-
.u32(hHwp) // 20
|
|
428
|
-
.
|
|
429
|
-
.u16(
|
|
430
|
-
.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
902
|
+
.u16(paraCount) // 0: paraCount
|
|
903
|
+
.u32(0) // 2: attr
|
|
904
|
+
.u16(0) // 6: extended attr
|
|
905
|
+
.u16(col) // 8: colAddr
|
|
906
|
+
.u16(row) // 10: rowAddr
|
|
907
|
+
.u16(rs) // 12: rowSpan
|
|
908
|
+
.u16(cs) // 14: colSpan
|
|
909
|
+
.u32(wHwp) // 16: width
|
|
910
|
+
.u32(hHwp) // 20: height
|
|
911
|
+
.u16(510) // 24: margin left (from real file)
|
|
912
|
+
.u16(510) // 26: margin right
|
|
913
|
+
.u16(141) // 28: margin top
|
|
914
|
+
.u16(141) // 30: margin bottom
|
|
915
|
+
.u16(bfId) // 32: borderFillId
|
|
916
|
+
.zeros(13) // 34-46: extended fields
|
|
917
|
+
.build(); // 47 bytes
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const DEFAULT_ROW_HEIGHT_PT = 14;
|
|
921
|
+
|
|
922
|
+
function encodeGrid(grid: GridNode, col: StyleCollector, lv: number, idGen: () => number): Uint8Array[] {
|
|
436
923
|
const records: Uint8Array[] = [];
|
|
437
924
|
const rowCnt = grid.kids.length;
|
|
438
925
|
const colCnt = Math.max(1, grid.kids[0]?.kids.length ?? 1);
|
|
439
926
|
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const totalPt = cwPt.reduce((s, w) => s + w, 0) || 453; // ~A4 content width
|
|
927
|
+
const cwPt = (grid.props as any).colWidths ?? [];
|
|
928
|
+
const totalPt = cwPt.reduce((s: number, w: number) => s + w, 0) || 453;
|
|
443
929
|
const defColPt = totalPt / colCnt;
|
|
444
930
|
|
|
445
931
|
const defStroke = grid.props.defaultStroke ?? col.DEF_STROKE;
|
|
446
932
|
const defBfId = col.addBorderFill(defStroke);
|
|
447
|
-
const rowHwp = Array.from({ length: rowCnt }, () => Metric.ptToHwp(DEFAULT_ROW_HEIGHT_PT));
|
|
448
933
|
|
|
449
|
-
|
|
450
|
-
|
|
934
|
+
const rowHwp = grid.kids.map((row: any) =>
|
|
935
|
+
row.heightPt != null && row.heightPt > 0
|
|
936
|
+
? Metric.ptToHwp(row.heightPt)
|
|
937
|
+
: Metric.ptToHwp(DEFAULT_ROW_HEIGHT_PT));
|
|
938
|
+
|
|
939
|
+
// Compute total table dimensions for GHDR
|
|
940
|
+
const totalWPt = cwPt.length > 0 ? cwPt.reduce((s: number, w: number) => s + w, 0) : totalPt;
|
|
941
|
+
const totalHPt = grid.kids.reduce((s: number, row: any) =>
|
|
942
|
+
s + (row.heightPt != null && row.heightPt > 0 ? row.heightPt : DEFAULT_ROW_HEIGHT_PT), 0);
|
|
943
|
+
const tblWHwp = Metric.ptToHwp(totalWPt);
|
|
944
|
+
const tblHHwp = Metric.ptToHwp(totalHPt);
|
|
945
|
+
const tblInstanceId = idGen();
|
|
946
|
+
|
|
947
|
+
// Table ctrl header at level lv (CTRL_HEADER for table — full 46-byte GHDR)
|
|
948
|
+
records.push(mkRec(TAG_CTRL_HEADER, lv, mkTableCtrl(tblWHwp, tblHHwp, tblInstanceId)));
|
|
949
|
+
|
|
950
|
+
// HWPTAG_TABLE at level lv+1 (child of ctrl header)
|
|
951
|
+
records.push(mkRec(TAG_TABLE, lv + 1, mkTableRecord(rowCnt, colCnt, rowHwp, defBfId)));
|
|
451
952
|
|
|
452
953
|
for (let r = 0; r < grid.kids.length; r++) {
|
|
453
954
|
for (let c = 0; c < grid.kids[r].kids.length; c++) {
|
|
454
955
|
const cell = grid.kids[r].kids[c];
|
|
455
956
|
const wHwp = Metric.ptToHwp(cwPt[c] ?? defColPt);
|
|
456
957
|
const hHwp = rowHwp[r];
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
958
|
+
const cp = cell.props;
|
|
959
|
+
const hasPerSide = cp.top || cp.bot || cp.left || cp.right;
|
|
960
|
+
const bfId = hasPerSide
|
|
961
|
+
? col.addBorderFillPerSide(
|
|
962
|
+
cp.left ?? defStroke, cp.right ?? defStroke,
|
|
963
|
+
cp.top ?? defStroke, cp.bot ?? defStroke,
|
|
964
|
+
cp.bg,
|
|
965
|
+
)
|
|
966
|
+
: col.addBorderFill(defStroke, cp.bg);
|
|
967
|
+
|
|
968
|
+
const paras = cell.kids.length > 0
|
|
969
|
+
? cell.kids
|
|
970
|
+
: [{ tag: 'para' as const, props: {}, kids: [] }];
|
|
971
|
+
|
|
972
|
+
// LIST_HEADER at level lv+1 (sibling of TABLE)
|
|
461
973
|
records.push(mkRec(TAG_LIST_HEADER, lv + 1,
|
|
462
974
|
mkCellListHeader(paras.length, r, c, cell.rs, cell.cs, wHwp, hHwp, bfId)));
|
|
463
975
|
|
|
464
|
-
// Cell paragraphs
|
|
465
|
-
|
|
466
|
-
|
|
976
|
+
// Cell paragraphs at level lv+2 (children of LIST_HEADER)
|
|
977
|
+
for (const para of paras) {
|
|
978
|
+
records.push(...encodePara(para as ParaNode, col, lv + 2, idGen()));
|
|
979
|
+
}
|
|
467
980
|
}
|
|
468
981
|
}
|
|
469
982
|
|
|
470
983
|
return records;
|
|
471
984
|
}
|
|
472
985
|
|
|
473
|
-
|
|
986
|
+
/**
|
|
987
|
+
* CTRL_HEADER for section definition — 47 bytes (matched from real HWP files)
|
|
988
|
+
* Layout: ctrlId(4) + attr(4) + colGap(4) + u16+u16(4) + zeros(31)
|
|
989
|
+
*/
|
|
990
|
+
function mkSectionCtrl(): Uint8Array {
|
|
991
|
+
return new BufWriter()
|
|
992
|
+
.u32(CTRL_SECD) // ctrlId: 'secd' (4)
|
|
993
|
+
.u32(0) // attr (4)
|
|
994
|
+
.u32(1134) // column gap in HWPUNIT — value from real file (4)
|
|
995
|
+
.u16(0x4000) // (2) — from real file
|
|
996
|
+
.u16(0x001f) // (2) — from real file
|
|
997
|
+
.zeros(31) // padding to reach 47 bytes total
|
|
998
|
+
.build(); // 4+4+4+2+2+31 = 47 bytes
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Section definition paragraph structure:
|
|
1003
|
+
* Level 0: PARA_HEADER (nchars=9, ctrlMask=1<<2, lineAlignCount=1)
|
|
1004
|
+
* Level 1: PARA_TEXT (secd ctrl reference + terminator, 9 WCHAR)
|
|
1005
|
+
* Level 1: PARA_CHAR_SHAPE
|
|
1006
|
+
* Level 1: PARA_LINE_SEG (36 bytes for 1 line)
|
|
1007
|
+
* Level 1: CTRL_HEADER ('secd', 4 bytes)
|
|
1008
|
+
* Level 2: PAGE_DEF (40 bytes)
|
|
1009
|
+
* Level 2: FOOTNOTE_SHAPE (28 bytes, for footnotes)
|
|
1010
|
+
* Level 2: FOOTNOTE_SHAPE (28 bytes, for endnotes)
|
|
1011
|
+
*/
|
|
1012
|
+
function buildSectionParagraph(dims: PageDims, instanceId: number): Uint8Array[] {
|
|
1013
|
+
const SECD_CTRL_MASK = 1 << 2; // code 2 = 구역 정의/단 정의
|
|
1014
|
+
const nchars = 9; // 8 ctrl wchars + 1 terminator
|
|
1015
|
+
|
|
1016
|
+
return [
|
|
1017
|
+
mkRec(TAG_PARA_HEADER, 0, mkParaHeader(nchars, SECD_CTRL_MASK, 0, 1, 1, instanceId)),
|
|
1018
|
+
mkRec(TAG_PARA_TEXT, 1, mkSecdParaText()),
|
|
1019
|
+
mkRec(TAG_PARA_CHAR_SHAPE, 1, mkParaCharShape([[0, 0]])),
|
|
1020
|
+
mkRec(TAG_PARA_LINE_SEG, 1, new Uint8Array(36)), // 1 line × 36 bytes = 36 bytes
|
|
1021
|
+
mkRec(TAG_CTRL_HEADER, 1, mkSectionCtrl()), // 'secd' at level 1
|
|
1022
|
+
mkRec(TAG_PAGE_DEF, 2, mkPageDef(dims)), // page def at level 2
|
|
1023
|
+
mkRec(TAG_FOOTNOTE_SHAPE, 2, new Uint8Array(28)), // footnote shape (defaults)
|
|
1024
|
+
mkRec(TAG_FOOTNOTE_SHAPE, 2, new Uint8Array(28)), // endnote shape (defaults)
|
|
1025
|
+
];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function buildBodyTextStream(
|
|
1029
|
+
doc: DocRoot,
|
|
1030
|
+
col: StyleCollector,
|
|
1031
|
+
images: BinImage[],
|
|
1032
|
+
): Uint8Array {
|
|
474
1033
|
const chunks: Uint8Array[] = [];
|
|
475
1034
|
const dims = doc.kids[0]?.dims ?? A4;
|
|
476
|
-
|
|
1035
|
+
let instanceIdCounter = 1;
|
|
1036
|
+
const idGen = () => instanceIdCounter++;
|
|
1037
|
+
|
|
1038
|
+
// Section definition paragraph
|
|
1039
|
+
for (const r of buildSectionParagraph(dims, idGen())) chunks.push(r);
|
|
1040
|
+
|
|
1041
|
+
const TABLE_CTRL_MASK = 1 << 11; // code 11 = 그리기 개체/표 (확장 컨트롤)
|
|
477
1042
|
|
|
478
1043
|
for (const sheet of doc.kids) {
|
|
479
1044
|
for (const node of sheet.kids) {
|
|
480
1045
|
if (node.tag === 'para') {
|
|
481
|
-
|
|
1046
|
+
const para = node as ParaNode;
|
|
1047
|
+
// Check if the paragraph contains images
|
|
1048
|
+
const hasImg = para.kids.some((k: any) => k.tag === 'img');
|
|
1049
|
+
if (hasImg) {
|
|
1050
|
+
// Emit one image paragraph per img node found
|
|
1051
|
+
for (const kid of para.kids) {
|
|
1052
|
+
if ((kid as any).tag === 'img') {
|
|
1053
|
+
const img = kid as any;
|
|
1054
|
+
// Find matching BinImage
|
|
1055
|
+
const binImg = images.find(b => b64Matches(b, img.b64));
|
|
1056
|
+
if (binImg) {
|
|
1057
|
+
for (const r of encodePicPara(img, binImg.id, col, 0, idGen)) chunks.push(r);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// Also emit any text in the paragraph (spans)
|
|
1062
|
+
const textKids = para.kids.filter((k: any) => k.tag !== 'img');
|
|
1063
|
+
if (textKids.length > 0) {
|
|
1064
|
+
const textPara: ParaNode = { tag: 'para', props: para.props, kids: textKids as any };
|
|
1065
|
+
for (const r of encodePara(textPara, col, 0, idGen())) chunks.push(r);
|
|
1066
|
+
}
|
|
1067
|
+
} else {
|
|
1068
|
+
for (const r of encodePara(para, col, 0, idGen())) chunks.push(r);
|
|
1069
|
+
}
|
|
482
1070
|
} else if (node.tag === 'grid') {
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
chunks.push(mkRec(TAG_PARA_HEADER, 0, mkParaHeader(0, 1)));
|
|
488
|
-
chunks.push(mkRec(TAG_PARA_TEXT, 1, mkParaText('')));
|
|
1071
|
+
// Table container paragraph at level 0
|
|
1072
|
+
// nchars=9 (8 ctrl wchars + terminator), ctrlMask has bit 11 set for table ctrl
|
|
1073
|
+
chunks.push(mkRec(TAG_PARA_HEADER, 0, mkParaHeader(9, TABLE_CTRL_MASK, 0, 1, 0, idGen())));
|
|
1074
|
+
chunks.push(mkRec(TAG_PARA_TEXT, 1, mkTableParaText()));
|
|
489
1075
|
chunks.push(mkRec(TAG_PARA_CHAR_SHAPE, 1, mkParaCharShape([[0, 0]])));
|
|
490
|
-
|
|
1076
|
+
// Table ctrl at level 1, TABLE record and cells at levels 2/3
|
|
1077
|
+
for (const r of encodeGrid(node as GridNode, col, 1, idGen)) chunks.push(r);
|
|
491
1078
|
}
|
|
492
1079
|
}
|
|
493
1080
|
}
|
|
@@ -495,35 +1082,36 @@ function buildBodyTextStream(doc: DocRoot, col: StyleCollector): Uint8Array {
|
|
|
495
1082
|
return concatU8(chunks);
|
|
496
1083
|
}
|
|
497
1084
|
|
|
1085
|
+
function b64Matches(binImg: BinImage, b64: string): boolean {
|
|
1086
|
+
const a = TextKit.base64Encode(binImg.data).replace(/\s/g, '');
|
|
1087
|
+
const b = b64.replace(/\s/g, '');
|
|
1088
|
+
return a === b;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
498
1091
|
/* ═══════════════════════════════════════════════════════════════
|
|
499
1092
|
HWP FileHeader stream (256 bytes)
|
|
500
1093
|
═══════════════════════════════════════════════════════════════ */
|
|
501
1094
|
|
|
502
1095
|
function buildHwpFileHeader(): Uint8Array {
|
|
503
1096
|
const buf = new Uint8Array(256);
|
|
1097
|
+
// Signature: "HWP Document File\x00" padded to 32 bytes
|
|
504
1098
|
const sig = 'HWP Document File';
|
|
505
1099
|
for (let i = 0; i < sig.length; i++) buf[i] = sig.charCodeAt(i);
|
|
506
1100
|
const dv = new DataView(buf.buffer);
|
|
507
1101
|
dv.setUint32(32, 0x05000300, true); // version 5.0.3.0
|
|
508
|
-
dv.setUint32(36, 0x00000001, true); // flags: bit 0 = compressed
|
|
1102
|
+
dv.setUint32(36, 0x00000001, true); // flags: bit 0 = compressed (zlib)
|
|
509
1103
|
return buf;
|
|
510
1104
|
}
|
|
511
1105
|
|
|
512
1106
|
/* ═══════════════════════════════════════════════════════════════
|
|
513
1107
|
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
1108
|
═══════════════════════════════════════════════════════════════ */
|
|
522
1109
|
|
|
523
1110
|
function buildHwpOle2(
|
|
524
1111
|
fileHeaderData: Uint8Array,
|
|
525
1112
|
docInfoData: Uint8Array,
|
|
526
1113
|
section0Data: Uint8Array,
|
|
1114
|
+
binImages: BinImage[] = [],
|
|
527
1115
|
): Uint8Array {
|
|
528
1116
|
const SS = 512;
|
|
529
1117
|
const ENDOFCHAIN = 0xFFFFFFFE;
|
|
@@ -538,33 +1126,45 @@ function buildHwpOle2(
|
|
|
538
1126
|
return out;
|
|
539
1127
|
}
|
|
540
1128
|
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
const
|
|
1129
|
+
// Pad all data to sector boundaries
|
|
1130
|
+
const fhPad = padSector(fileHeaderData);
|
|
1131
|
+
const diPad = padSector(docInfoData);
|
|
1132
|
+
const s0Pad = padSector(section0Data);
|
|
1133
|
+
const imgPads = binImages.map(img => padSector(img.data));
|
|
1134
|
+
|
|
1135
|
+
const fhN = fhPad.length / SS;
|
|
1136
|
+
const diN = diPad.length / SS;
|
|
1137
|
+
const s0N = s0Pad.length / SS;
|
|
1138
|
+
const imgNs = imgPads.map(p => p.length / SS);
|
|
1139
|
+
const totalImgN = imgNs.reduce((s, n) => s + n, 0);
|
|
1140
|
+
|
|
1141
|
+
// Directory sectors: 4 entries per sector
|
|
1142
|
+
// Entries: Root, FileHeader, DocInfo, BodyText, Section0 [+ BinData + images]
|
|
1143
|
+
const numDirEntries = 5 + (binImages.length > 0 ? 1 + binImages.length : 0);
|
|
1144
|
+
const dirN = Math.max(2, Math.ceil(numDirEntries / 4));
|
|
548
1145
|
|
|
549
1146
|
// Compute FAT sector count iteratively
|
|
550
1147
|
let fatN = 1;
|
|
551
1148
|
for (let iter = 0; iter < 10; iter++) {
|
|
552
|
-
const total = fatN + dirN + fhN + diN + s0N;
|
|
1149
|
+
const total = fatN + dirN + fhN + diN + s0N + totalImgN;
|
|
553
1150
|
const needed = Math.ceil(total / 128);
|
|
554
1151
|
if (needed <= fatN) break;
|
|
555
1152
|
fatN = needed;
|
|
556
1153
|
}
|
|
557
1154
|
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
const
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const
|
|
1155
|
+
const dir1Sec = fatN;
|
|
1156
|
+
const fhSec = fatN + dirN;
|
|
1157
|
+
const diSec = fhSec + fhN;
|
|
1158
|
+
const s0Sec = diSec + diN;
|
|
1159
|
+
|
|
1160
|
+
// Image sectors come after section0
|
|
1161
|
+
const imgSecs: number[] = [];
|
|
1162
|
+
let curSec = s0Sec + s0N;
|
|
1163
|
+
for (const n of imgNs) { imgSecs.push(curSec); curSec += n; }
|
|
1164
|
+
const totalSec = curSec;
|
|
565
1165
|
|
|
566
|
-
// Build FAT
|
|
567
|
-
const fatBuf = new Uint8Array(fatN * SS).fill(0xFF);
|
|
1166
|
+
// Build FAT
|
|
1167
|
+
const fatBuf = new Uint8Array(fatN * SS).fill(0xFF);
|
|
568
1168
|
const setFat = (i: number, v: number) => {
|
|
569
1169
|
fatBuf[i * 4] = v & 0xFF;
|
|
570
1170
|
fatBuf[i * 4 + 1] = (v >>> 8) & 0xFF;
|
|
@@ -573,13 +1173,17 @@ function buildHwpOle2(
|
|
|
573
1173
|
};
|
|
574
1174
|
|
|
575
1175
|
for (let i = 0; i < fatN; i++) setFat(i, FATSECT);
|
|
576
|
-
setFat(dir1Sec,
|
|
577
|
-
setFat(
|
|
578
|
-
for (let i = 0; i <
|
|
579
|
-
for (let i = 0; i <
|
|
580
|
-
for (let
|
|
1176
|
+
for (let i = 0; i < dirN; i++) setFat(dir1Sec + i, i + 1 < dirN ? dir1Sec + i + 1 : ENDOFCHAIN);
|
|
1177
|
+
for (let i = 0; i < fhN; i++) setFat(fhSec + i, i + 1 < fhN ? fhSec + i + 1 : ENDOFCHAIN);
|
|
1178
|
+
for (let i = 0; i < diN; i++) setFat(diSec + i, i + 1 < diN ? diSec + i + 1 : ENDOFCHAIN);
|
|
1179
|
+
for (let i = 0; i < s0N; i++) setFat(s0Sec + i, i + 1 < s0N ? s0Sec + i + 1 : ENDOFCHAIN);
|
|
1180
|
+
for (let ii = 0; ii < imgNs.length; ii++) {
|
|
1181
|
+
const start = imgSecs[ii];
|
|
1182
|
+
const n = imgNs[ii];
|
|
1183
|
+
for (let i = 0; i < n; i++) setFat(start + i, i + 1 < n ? start + i + 1 : ENDOFCHAIN);
|
|
1184
|
+
}
|
|
581
1185
|
|
|
582
|
-
// Build directory
|
|
1186
|
+
// Build directory
|
|
583
1187
|
const dirBuf = new Uint8Array(dirN * SS);
|
|
584
1188
|
const dv = new DataView(dirBuf.buffer);
|
|
585
1189
|
|
|
@@ -591,25 +1195,40 @@ function buildHwpOle2(
|
|
|
591
1195
|
const base = idx * 128;
|
|
592
1196
|
const nl = Math.min(name.length, 31);
|
|
593
1197
|
for (let i = 0; i < nl; i++) dv.setUint16(base + i * 2, name.charCodeAt(i), true);
|
|
594
|
-
dv.setUint16(base + 64, (nl + 1) * 2, true);
|
|
1198
|
+
dv.setUint16(base + 64, (nl + 1) * 2, true);
|
|
595
1199
|
dirBuf[base + 66] = type;
|
|
596
1200
|
dirBuf[base + 67] = 1; // color = black
|
|
597
|
-
dv.setInt32(base + 68, left, true);
|
|
598
|
-
dv.setInt32(base + 72, right, true);
|
|
599
|
-
dv.setInt32(base + 76, child, true);
|
|
1201
|
+
dv.setInt32(base + 68, left, true);
|
|
1202
|
+
dv.setInt32(base + 72, right, true);
|
|
1203
|
+
dv.setInt32(base + 76, child, true);
|
|
600
1204
|
dv.setUint32(base + 116, startSec >>> 0, true);
|
|
601
1205
|
dv.setUint32(base + 120, size >>> 0, true);
|
|
602
1206
|
}
|
|
603
1207
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1208
|
+
if (binImages.length > 0) {
|
|
1209
|
+
// Tree: Root(0)→FileHeader(1)→DocInfo(2)→BodyText(3)→Section0(4)
|
|
1210
|
+
// BodyText sibling=BinData(5)
|
|
1211
|
+
// BinData(5)→BIN0001.xxx(6)→BIN0002.xxx(7)→...
|
|
1212
|
+
writeDirEntry(0, 'Root Entry', 5, -1, -1, 1, ENDOFCHAIN, 0);
|
|
1213
|
+
writeDirEntry(1, 'FileHeader', 2, -1, 2, -1, fhSec, fileHeaderData.length);
|
|
1214
|
+
writeDirEntry(2, 'DocInfo', 2, -1, 3, -1, diSec, docInfoData.length);
|
|
1215
|
+
writeDirEntry(3, 'BodyText', 1, -1, 5, 4, ENDOFCHAIN, 0); // sibling=BinData(5)
|
|
1216
|
+
writeDirEntry(4, 'Section0', 2, -1, -1, -1, s0Sec, section0Data.length);
|
|
1217
|
+
writeDirEntry(5, 'BinData', 1, -1, -1, 6, ENDOFCHAIN, 0); // child=first image(6)
|
|
1218
|
+
for (let ii = 0; ii < binImages.length; ii++) {
|
|
1219
|
+
const img = binImages[ii];
|
|
1220
|
+
const streamName = `BIN${String(img.id).padStart(4, '0')}.${img.ext}`;
|
|
1221
|
+
const sibling = ii + 1 < binImages.length ? 7 + ii : -1;
|
|
1222
|
+
writeDirEntry(6 + ii, streamName, 2, -1, sibling, -1, imgSecs[ii], img.data.length);
|
|
1223
|
+
}
|
|
1224
|
+
} else {
|
|
1225
|
+
// No images — original 5-entry structure
|
|
1226
|
+
writeDirEntry(0, 'Root Entry', 5, -1, -1, 1, ENDOFCHAIN, 0);
|
|
1227
|
+
writeDirEntry(1, 'FileHeader', 2, -1, 2, -1, fhSec, fileHeaderData.length);
|
|
1228
|
+
writeDirEntry(2, 'DocInfo', 2, -1, 3, -1, diSec, docInfoData.length);
|
|
1229
|
+
writeDirEntry(3, 'BodyText', 1, -1, -1, 4, ENDOFCHAIN, 0);
|
|
1230
|
+
writeDirEntry(4, 'Section0', 2, -1, -1, -1, s0Sec, section0Data.length);
|
|
1231
|
+
}
|
|
613
1232
|
|
|
614
1233
|
// Build OLE2 file header (512 bytes)
|
|
615
1234
|
const hdr = new Uint8Array(SS);
|
|
@@ -621,16 +1240,6 @@ function buildHwpOle2(
|
|
|
621
1240
|
hdv.setUint16(28, 0xFFFE, true); // byte order (LE)
|
|
622
1241
|
hdv.setUint16(30, 9, true); // sector size = 2^9 = 512
|
|
623
1242
|
hdv.setUint16(32, 6, true); // mini sector size = 2^6 = 64
|
|
624
|
-
// OLE2 v3 header field layout (see ECMA-376 or MS-CFB spec):
|
|
625
|
-
// 40-43: num dir sectors (must be 0 for v3)
|
|
626
|
-
// 44-47: num FAT sectors
|
|
627
|
-
// 48-51: first dir sector
|
|
628
|
-
// 52-55: transaction sig (0)
|
|
629
|
-
// 56-59: mini stream cutoff (4096)
|
|
630
|
-
// 60-63: first mini FAT (ENDOFCHAIN if none)
|
|
631
|
-
// 64-67: num mini FAT (0)
|
|
632
|
-
// 68-71: first DIFAT ext (ENDOFCHAIN if none)
|
|
633
|
-
// 72-75: num DIFAT ext (0)
|
|
634
1243
|
hdv.setUint32(40, 0, true); // num dir sectors (0 for v3)
|
|
635
1244
|
hdv.setUint32(44, fatN, true); // num FAT sectors
|
|
636
1245
|
hdv.setUint32(48, dir1Sec, true); // first directory sector
|
|
@@ -640,7 +1249,6 @@ function buildHwpOle2(
|
|
|
640
1249
|
hdv.setUint32(64, 0, true); // num mini FAT sectors (0)
|
|
641
1250
|
hdv.setUint32(68, ENDOFCHAIN, true); // first DIFAT extension (none)
|
|
642
1251
|
hdv.setUint32(72, 0, true); // num DIFAT extensions (0)
|
|
643
|
-
// DIFAT[0..108]: first fatN entries = FAT sector numbers
|
|
644
1252
|
for (let i = 0; i < 109; i++) {
|
|
645
1253
|
hdv.setUint32(76 + i * 4, i < fatN ? i : FREESECT, true);
|
|
646
1254
|
}
|
|
@@ -648,17 +1256,18 @@ function buildHwpOle2(
|
|
|
648
1256
|
// Assemble output
|
|
649
1257
|
const out = new Uint8Array(SS + totalSec * SS);
|
|
650
1258
|
out.set(hdr, 0);
|
|
651
|
-
// FAT sectors
|
|
652
1259
|
for (let i = 0; i < fatN; i++) {
|
|
653
1260
|
out.set(fatBuf.subarray(i * SS, (i + 1) * SS), SS + i * SS);
|
|
654
1261
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
// Stream data
|
|
1262
|
+
for (let i = 0; i < dirN; i++) {
|
|
1263
|
+
out.set(dirBuf.subarray(i * SS, (i + 1) * SS), SS + (dir1Sec + i) * SS);
|
|
1264
|
+
}
|
|
659
1265
|
out.set(fhPad, SS + fhSec * SS);
|
|
660
1266
|
out.set(diPad, SS + diSec * SS);
|
|
661
1267
|
out.set(s0Pad, SS + s0Sec * SS);
|
|
1268
|
+
for (let ii = 0; ii < imgPads.length; ii++) {
|
|
1269
|
+
out.set(imgPads[ii], SS + imgSecs[ii] * SS);
|
|
1270
|
+
}
|
|
662
1271
|
return out;
|
|
663
1272
|
}
|
|
664
1273
|
|
|
@@ -689,17 +1298,50 @@ export class HwpEncoder implements Encoder {
|
|
|
689
1298
|
for (const node of sheet.kids) collectNode(node, col);
|
|
690
1299
|
}
|
|
691
1300
|
|
|
692
|
-
//
|
|
693
|
-
const
|
|
694
|
-
const
|
|
1301
|
+
// Collect images from all paragraphs
|
|
1302
|
+
const images: BinImage[] = [];
|
|
1303
|
+
const seenB64 = new Set<string>();
|
|
1304
|
+
let binIdCounter = 1;
|
|
1305
|
+
|
|
1306
|
+
function collectImages(node: any): void {
|
|
1307
|
+
if (node.tag === 'para') {
|
|
1308
|
+
for (const kid of node.kids) {
|
|
1309
|
+
if (kid.tag === 'img') {
|
|
1310
|
+
const key = kid.b64.substring(0, 50);
|
|
1311
|
+
if (!seenB64.has(key)) {
|
|
1312
|
+
seenB64.add(key);
|
|
1313
|
+
const raw = TextKit.base64Decode(kid.b64);
|
|
1314
|
+
let ext = 'jpg';
|
|
1315
|
+
if (kid.mime === 'image/png') ext = 'png';
|
|
1316
|
+
else if (kid.mime === 'image/gif') ext = 'gif';
|
|
1317
|
+
else if (kid.mime === 'image/bmp') ext = 'bmp';
|
|
1318
|
+
images.push({ id: binIdCounter++, ext, data: new Uint8Array(raw) });
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
} else if (node.tag === 'grid') {
|
|
1323
|
+
for (const row of node.kids) {
|
|
1324
|
+
for (const cell of row.kids) {
|
|
1325
|
+
for (const para of cell.kids) collectImages(para);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
for (const sheet of doc.kids) {
|
|
1331
|
+
for (const node of sheet.kids) collectImages(node);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Build streams (raw, uncompressed)
|
|
1335
|
+
const docInfoRaw = buildDocInfoStream(col, images);
|
|
1336
|
+
const bodyRaw = buildBodyTextStream(doc, col, images);
|
|
695
1337
|
|
|
696
|
-
// Compress (HWP
|
|
697
|
-
const docInfoCmp = pako.
|
|
698
|
-
const bodyCmp = pako.
|
|
1338
|
+
// Compress with zlib (HWP uses zlib deflate for DocInfo and BodyText streams)
|
|
1339
|
+
const docInfoCmp = pako.deflate(docInfoRaw);
|
|
1340
|
+
const bodyCmp = pako.deflate(bodyRaw);
|
|
699
1341
|
|
|
700
|
-
// Assemble OLE2 file
|
|
1342
|
+
// Assemble OLE2 file (images go as raw streams in BinData storage, NOT compressed)
|
|
701
1343
|
const fileHdr = buildHwpFileHeader();
|
|
702
|
-
const hwp = buildHwpOle2(fileHdr, docInfoCmp, bodyCmp);
|
|
1344
|
+
const hwp = buildHwpOle2(fileHdr, docInfoCmp, bodyCmp, images);
|
|
703
1345
|
|
|
704
1346
|
return succeed(hwp);
|
|
705
1347
|
} catch (e: any) {
|