hwpkit-dev 0.0.1 → 0.0.3

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