hwpkit-dev 0.0.2 → 0.0.5

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