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.
@@ -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
- 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
-
41
- /** Border width index table (points) matches BORDER_W_PT in HwpScanner */
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
- none: 0, solid: 1, dash: 2, dot: 3, double: 8,
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: { s: Stroke; bg?: string }[] = [];
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 (HWP uses 1-based IDs for border fills) */
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
- col.addBorderFill(cell.props.top ?? node.props.defaultStroke ?? col.DEF_STROKE, cell.props.bg);
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
- function mkIdMappings(col: StyleCollector): Uint8Array {
278
+ /**
279
+ * HWPTAG_DOCUMENT_PROPERTIES (표 14 문서 속성) — 26 bytes
280
+ * level 0 in DocInfo
281
+ */
282
+ function mkDocumentProperties(): Uint8Array {
229
283
  return new BufWriter()
230
- .u32(col.fonts.length)
231
- .u32(col.bfData.length)
232
- .u32(col.csProps.length)
233
- .u32(0) // tabDef count
234
- .u32(0) // numbering count
235
- .u32(0) // bullet count
236
- .u32(col.psProps.length)
237
- .u32(0) // style count
238
- .build();
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) // substType
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
- w.u16(0); // attr
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
- // 5 borders: left, right, top, bottom, diagonal
263
- for (let i = 0; i < 5; i++) w.u8(t).u8(wi).colorRef(s.color || '000000');
264
- // fill: type(4) + faceColor(4) + reserved(4)
265
- if (bg) { w.u32(1).colorRef(bg).u32(0); }
266
- else { w.u32(0).u32(0).u32(0); }
267
- return w.build(); // 40 bytes
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 w = new BufWriter();
273
- for (let i = 0; i < 7; i++) w.u16(fontId); // faceId[7]
274
- for (let i = 0; i < 7; i++) w.u8(100); // ratio[7]
275
- for (let i = 0; i < 7; i++) w.u8(0); // spacing[7]
276
- for (let i = 0; i < 7; i++) w.u8(100); // relSize[7]
277
- for (let i = 0; i < 7; i++) w.u8(0); // offset[7]
278
- // height @ offset 42 (HWPUNIT: pt × 100)
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; // italic = bit 0
283
- if (p.b) attr |= 2; // bold = bit 1
284
- if (p.u) attr |= (1 << 2); // ulType = bits 2-4, set to 1
285
- if (p.s) attr |= (1 << 18); // skType = bits 18-20, set to 1
286
- if (p.sup) attr |= (1 << 16); // suType = bits 16-17, value 1
287
- if (p.sub) attr |= (2 << 16); // suType = bits 16-17, value 2
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
- w.u8(0).u8(0); // shadowX, shadowY @ 50-51
290
- w.colorRef(p.color ?? '000000'); // textColor @ 52 (4 bytes)
291
- return w.build(); // 56 bytes
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(ALIGN_CODE[p.align ?? 'left'] ?? 1) // attr (bits 0-2 = align)
297
- .i32(Metric.ptToHwp(p.indentPt ?? 0)) // leftMargin
298
- .i32(0) // rightMargin
299
- .i32(0) // indent (first-line)
300
- .i32(Metric.ptToHwp(p.spaceBefore ?? 0))
301
- .i32(Metric.ptToHwp(p.spaceAfter ?? 0))
302
- .i32(p.lineHeight ? Math.round(p.lineHeight * 100) : 160) // lineSpacing
303
- .build(); // 28 bytes
304
- }
305
-
306
- function buildDocInfoStream(col: StyleCollector): Uint8Array {
307
- const chunks: Uint8Array[] = [
308
- mkRec(TAG_ID_MAPPINGS, 0, mkIdMappings(col)),
309
- ...col.fonts.map(n => mkRec(TAG_FACE_NAME, 0, mkFaceName(n))),
310
- ...col.bfData.map(({ s, bg }) => mkRec(TAG_BORDER_FILL, 0, mkBorderFill(s, bg))),
311
- ...col.csProps.map(p => mkRec(TAG_CHAR_SHAPE, 0, mkCharShape(p, col))),
312
- ...col.psProps.map(p => mkRec(TAG_PARA_SHAPE, 0, mkParaShape(p))),
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
- function mkParaHeader(psId: number, csCount: number): Uint8Array {
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(0) // paragraphControlMask
337
- .u16(0) // styleId
338
- .u8(0) // divideAttr
339
- .u8(0)
340
- .u16(psId) // paraShapeId @ offset 8
341
- .u16(csCount) // charShapeCount @ offset 10
342
- .u16(0) // rangeTagCount
343
- .u16(0) // memoCount
344
- .i32(0) // paraChangeId
345
- .build(); // 20 bytes
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); // replace control chars
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
- function encodePara(para: ParaNode, col: StyleCollector, lv: number): Uint8Array[] {
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 !== 'span') continue;
371
- const span = kid as SpanNode;
372
- const csId = col.addCharShape(span.props);
373
- // Only add a new pair when shape changes
374
- if (csPairs.length === 0 || csPairs[csPairs.length - 1][1] !== csId) {
375
- csPairs.push([pos, csId]);
376
- }
377
- for (const t of span.kids) {
378
- if (t.tag === 'txt') { text += t.content; pos += t.content.length; }
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 = col.addParaShape(para.props);
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, lv, mkParaHeader(psId, csPairs.length)),
387
- mkRec(TAG_PARA_TEXT, lv + 1, mkParaText(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
- function mkTableCtrl(): Uint8Array {
395
- return new BufWriter().u32(CTRL_TABLE).zeros(12).build();
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
- function mkTableB(rowCnt: number, colCnt: number, rowHwp: number[], bfId: number): Uint8Array {
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(0); // attr
864
+ w.u32(0x04000006); // attr — from real file
401
865
  w.u16(rowCnt);
402
866
  w.u16(colCnt);
403
- w.zeros(10); // bytes 8-17: cell spacing / zone info
404
- for (const h of rowHwp) w.u16(h);
405
- w.u16(bfId);
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
- // Scanner reads: col = readU16LE(d, 8), row = readU16LE(d, 10)
417
- // (HWP 5.0 spec: offset 8 = colAddr, offset 10 = rowAddr)
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) // 0-1: paraCount
420
- .u32(0) // 2-5: attr
421
- .u16(0) // 6-7: unknown
422
- .u16(col) // 8-9: colAddr ← col first!
423
- .u16(row) // 10-11: rowAddr ← then row
424
- .u16(rs) // 12-13: rowSpan
425
- .u16(cs) // 14-15: colSpan
426
- .u32(wHwp) // 16-19: width
427
- .u32(hHwp) // 20-23: height
428
- .zeros(8) // 24-31: padding[4]
429
- .u16(bfId) // 32-33: borderFillId
430
- .build(); // 34 bytes
431
- }
432
-
433
- const DEFAULT_ROW_HEIGHT_PT = 14; // reasonable row height
434
-
435
- function encodeGrid(grid: GridNode, col: StyleCollector, lv: number): Uint8Array[] {
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
- // Column widths
441
- const cwPt = grid.props.colWidths ?? [];
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
- records.push(mkRec(TAG_CTRL_HEADER, lv, mkTableCtrl()));
450
- records.push(mkRec(TAG_TABLE_B, lv + 1, mkTableB(rowCnt, colCnt, rowHwp, defBfId)));
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 stroke = cell.props.top ?? defStroke;
458
- const bfId = col.addBorderFill(stroke, cell.props.bg);
459
- const paras = cell.kids.length > 0 ? cell.kids : [{ tag: 'para' as const, props: {}, kids: [] }];
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 are at same level as LIST_HEADER (lv+1);
465
- // their children (PARA_TEXT, PARA_CHAR_SHAPE) go to lv+2.
466
- for (const para of paras) records.push(...encodePara(para, col, lv + 1));
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
- function buildBodyTextStream(doc: DocRoot, col: StyleCollector): Uint8Array {
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
- chunks.push(mkRec(TAG_PAGE_DEF, 0, mkPageDef(dims)));
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
- for (const r of encodePara(node as ParaNode, col, 0)) chunks.push(r);
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
- // In HWP, a table is embedded inside a "container paragraph" at level 0.
484
- // CTRL_HEADER goes at level 1 (child of that paragraph).
485
- // TABLE_B / LIST_HEADER / cell PARA_HEADERs go at level 2.
486
- // Cell PARA_TEXT / PARA_CHAR_SHAPE go at level 3.
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
- for (const r of encodeGrid(node as GridNode, col, 1)) chunks.push(r);
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
- const fhPad = padSector(fileHeaderData);
542
- const diPad = padSector(docInfoData);
543
- const s0Pad = padSector(section0Data);
544
- const fhN = fhPad.length / SS;
545
- const diN = diPad.length / SS;
546
- const s0N = s0Pad.length / SS;
547
- const dirN = 2; // always 2 dir sectors (holds 8 dir entries)
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
- // Assign sector indices
559
- const dir1Sec = fatN;
560
- const dir2Sec = fatN + 1;
561
- const fhSec = fatN + dirN;
562
- const diSec = fhSec + fhN;
563
- const s0Sec = diSec + diN;
564
- const totalSec = s0Sec + s0N;
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 (fatN × 128 entries × 4 bytes = fatN × 512 bytes)
567
- const fatBuf = new Uint8Array(fatN * SS).fill(0xFF); // FREESECT
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, dir2Sec);
577
- setFat(dir2Sec, ENDOFCHAIN);
578
- for (let i = 0; i < fhN; i++) setFat(fhSec + i, i + 1 < fhN ? fhSec + i + 1 : ENDOFCHAIN);
579
- for (let i = 0; i < diN; i++) setFat(diSec + i, i + 1 < diN ? diSec + i + 1 : ENDOFCHAIN);
580
- for (let i = 0; i < s0N; i++) setFat(s0Sec + i, i + 1 < s0N ? s0Sec + i + 1 : ENDOFCHAIN);
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 (8 entries × 128 bytes = dirN × SS)
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); // name size (incl. null)
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); // left sibling
598
- dv.setInt32(base + 72, right, true); // right sibling
599
- dv.setInt32(base + 76, child, true); // child
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
- // Use right-skewed sibling chain (no left siblings) to avoid cycles in CFB parsers.
605
- // Root.child FileHeader DocInfo BodyText (via sibRight).
606
- // BodyText.child → Section0.
607
- writeDirEntry(0, 'Root Entry', 5, -1, -1, 1, ENDOFCHAIN, 0);
608
- writeDirEntry(1, 'FileHeader', 2, -1, 2, -1, fhSec, fileHeaderData.length);
609
- writeDirEntry(2, 'DocInfo', 2, -1, 3, -1, diSec, docInfoData.length);
610
- writeDirEntry(3, 'BodyText', 1, -1, -1, 4, ENDOFCHAIN, 0);
611
- writeDirEntry(4, 'Section0', 2, -1, -1, -1, s0Sec, section0Data.length);
612
- // Entries 5-7: type=0 (empty), everything else zeroed
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
- // Directory sectors
656
- out.set(dirBuf.subarray(0, SS), SS + dir1Sec * SS);
657
- out.set(dirBuf.subarray(SS, 2*SS), SS + dir2Sec * SS);
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
- // Build streams
693
- const docInfoRaw = buildDocInfoStream(col);
694
- const bodyRaw = buildBodyTextStream(doc, col);
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 flags bit 0 = compressed)
697
- const docInfoCmp = pako.deflateRaw(docInfoRaw);
698
- const bodyCmp = pako.deflateRaw(bodyRaw);
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) {