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