hwpkit-dev 0.0.2 → 0.0.3

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