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