hwpkit-dev 0.0.1

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.
@@ -0,0 +1,106 @@
1
+ import type { Align, StrokeKind, Stroke } from '../model/doc-props';
2
+
3
+ // ─── 단위 변환 ─────────────────────────────────────────────
4
+ export const Metric = {
5
+ // HWP 세계 (1 inch = 7200 HWPUNIT)
6
+ hwpToPt: (v: number) => v / 100,
7
+ ptToHwp: (v: number) => Math.round(v * 100),
8
+ hwpToDxa: (v: number) => Math.round(v / 5),
9
+ dxaToHwp: (v: number) => Math.round(v * 5),
10
+ hwpToEmu: (v: number) => Math.round(v * 127),
11
+ emuToHwp: (v: number) => Math.round(v / 127),
12
+
13
+ // DOCX 세계 (1 inch = 1440 dxa, 1 pt = 20 dxa)
14
+ dxaToPt: (v: number) => v / 20,
15
+ ptToDxa: (v: number) => Math.round(v * 20),
16
+ dxaToEmu: (v: number) => Math.round(v * 635),
17
+ emuToDxa: (v: number) => Math.round(v / 635),
18
+ emuToPt: (v: number) => v / 12700,
19
+ ptToEmu: (v: number) => Math.round(v * 12700),
20
+
21
+ // HWPX charPr height: 1000 = 10pt
22
+ hHeightToPt: (v: number) => v / 100,
23
+ ptToHHeight: (v: number) => Math.round(v * 100),
24
+
25
+ // DOCX half-point: 24 = 12pt
26
+ halfPtToPt: (v: number) => v / 2,
27
+ ptToHalfPt: (v: number) => Math.round(v * 2),
28
+ } as const;
29
+
30
+ // ─── 색상 정규화 ───────────────────────────────────────────
31
+ export function safeHex(raw: string | number | null | undefined): string | undefined {
32
+ if (raw == null) return undefined;
33
+ if (typeof raw === 'number') {
34
+ if (raw <= 0) return '000000';
35
+ if (raw >= 0xFFFFFF) return undefined;
36
+ return raw.toString(16).padStart(6, '0').toUpperCase();
37
+ }
38
+ let s = String(raw).replace(/^#/, '').toUpperCase();
39
+ if (/^[0-9A-F]{3}$/.test(s)) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
40
+ if (/^[0-9A-F]{6}$/.test(s)) return s;
41
+ if (s === 'AUTO' || s === 'NONE' || s === 'TRANSPARENT') return undefined;
42
+ return undefined;
43
+ }
44
+
45
+ // ─── 정렬 정규화 ───────────────────────────────────────────
46
+ const ALIGN_MAP: Record<string, Align> = {
47
+ LEFT: 'left', CENTER: 'center', RIGHT: 'right', JUSTIFY: 'justify',
48
+ BOTH: 'justify', DISTRIBUTE: 'justify',
49
+ left: 'left', center: 'center', right: 'right', both: 'justify',
50
+ start: 'left', end: 'right',
51
+ };
52
+ export function safeAlign(raw?: string): Align {
53
+ return ALIGN_MAP[raw ?? ''] ?? 'justify';
54
+ }
55
+
56
+ // ─── 테두리 정규화 ─────────────────────────────────────────
57
+ const HWPX_STROKE: Record<string, StrokeKind> = {
58
+ SOLID: 'solid', NONE: 'none', DASH: 'dash', DOT: 'dot',
59
+ DOUBLE: 'double', LONG_DASH: 'dash', DASH_DOT: 'dash',
60
+ };
61
+ const DOCX_STROKE: Record<string, StrokeKind> = {
62
+ single: 'solid', none: 'none', nil: 'none', dashed: 'dash',
63
+ dotted: 'dot', double: 'double', wave: 'solid',
64
+ };
65
+
66
+ export function safeStrokeHwpx(type?: string, w?: number, c?: string): Stroke {
67
+ return {
68
+ kind: HWPX_STROKE[type ?? ''] ?? 'solid',
69
+ pt: w != null ? Metric.hwpToPt(w) : 0.5,
70
+ color: safeHex(c) ?? '000000',
71
+ };
72
+ }
73
+
74
+ export function safeStrokeDocx(val?: string, sz?: number, c?: string): Stroke {
75
+ return {
76
+ kind: DOCX_STROKE[val ?? ''] ?? 'solid',
77
+ pt: sz != null ? sz / 8 : 0.5,
78
+ color: safeHex(c) ?? '000000',
79
+ };
80
+ }
81
+
82
+ // ─── 폰트 정규화 ───────────────────────────────────────────
83
+ const FONT_MAP: Record<string, string> = {
84
+ '맑은 고딕': 'Malgun Gothic',
85
+ '바탕': 'Batang',
86
+ '돋움': 'Dotum',
87
+ '굴림': 'Gulim',
88
+ '한컴바탕': 'Batang',
89
+ '한컴돋움': 'Malgun Gothic',
90
+ '함초롬바탕': 'Batang',
91
+ '함초롬돋움': 'Malgun Gothic',
92
+ };
93
+ export function safeFont(raw?: string): string {
94
+ return FONT_MAP[raw ?? ''] ?? raw ?? 'Malgun Gothic';
95
+ }
96
+
97
+ // Reverse mapping: English → Korean (for HWPX encoding)
98
+ const FONT_MAP_KR: Record<string, string> = {
99
+ 'Malgun Gothic': '맑은 고딕',
100
+ 'Batang': '바탕',
101
+ 'Dotum': '돋움',
102
+ 'Gulim': '굴림',
103
+ };
104
+ export function safeFontToKr(raw?: string): string {
105
+ return FONT_MAP_KR[raw ?? ''] ?? raw ?? '맑은 고딕';
106
+ }
@@ -0,0 +1,150 @@
1
+ import pako from 'pako';
2
+
3
+ interface ZipEntry {
4
+ name: string;
5
+ data: Uint8Array;
6
+ }
7
+
8
+ export const ArchiveKit = {
9
+ async inflate(compressed: Uint8Array): Promise<Uint8Array> {
10
+ return pako.inflate(compressed);
11
+ },
12
+
13
+ async deflate(data: Uint8Array): Promise<Uint8Array> {
14
+ return pako.deflate(data, { level: 6 });
15
+ },
16
+
17
+ async unzip(zipData: Uint8Array): Promise<Map<string, Uint8Array>> {
18
+ const files = new Map<string, Uint8Array>();
19
+ const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
20
+
21
+ let offset = 0;
22
+ while (offset < zipData.length - 4) {
23
+ const sig = view.getUint32(offset, true);
24
+
25
+ if (sig === 0x04034b50) {
26
+ const compressionMethod = view.getUint16(offset + 8, true);
27
+ const compressedSize = view.getUint32(offset + 18, true);
28
+ const uncompressedSize = view.getUint32(offset + 22, true);
29
+ const fileNameLength = view.getUint16(offset + 26, true);
30
+ const extraLength = view.getUint16(offset + 28, true);
31
+
32
+ const nameBytes = zipData.subarray(offset + 30, offset + 30 + fileNameLength);
33
+ const name = new TextDecoder('utf-8').decode(nameBytes);
34
+
35
+ const dataOffset = offset + 30 + fileNameLength + extraLength;
36
+ let fileData: Uint8Array;
37
+
38
+ if (compressionMethod === 0) {
39
+ fileData = zipData.subarray(dataOffset, dataOffset + uncompressedSize);
40
+ } else if (compressionMethod === 8) {
41
+ const compressed = zipData.subarray(dataOffset, dataOffset + compressedSize);
42
+ fileData = pako.inflateRaw(compressed);
43
+ } else {
44
+ throw new Error(`Unsupported ZIP compression method: ${compressionMethod}`);
45
+ }
46
+
47
+ files.set(name, new Uint8Array(fileData));
48
+ offset = dataOffset + compressedSize;
49
+ } else if (sig === 0x02014b50 || sig === 0x06054b50) {
50
+ break;
51
+ } else {
52
+ offset++;
53
+ }
54
+ }
55
+
56
+ return files;
57
+ },
58
+
59
+ async zip(entries: ZipEntry[]): Promise<Uint8Array> {
60
+ const localHeaders: Uint8Array[] = [];
61
+ const centralHeaders: Uint8Array[] = [];
62
+ let localOffset = 0;
63
+
64
+ for (const entry of entries) {
65
+ const nameBytes = new TextEncoder().encode(entry.name);
66
+ const crc = crc32(entry.data);
67
+
68
+ // 'mimetype' and 'version.xml' must be stored uncompressed per HWPX spec
69
+ const store = entry.name === 'mimetype' || entry.name === 'version.xml';
70
+ const method = store ? 0 : 8;
71
+ const payload = store ? entry.data : pako.deflateRaw(entry.data, { level: 6 });
72
+
73
+ // Local file header (30 bytes + name + data)
74
+ const local = new Uint8Array(30 + nameBytes.length + payload.length);
75
+ const lv = new DataView(local.buffer);
76
+ lv.setUint32(0, 0x04034b50, true);
77
+ lv.setUint16(4, 20, true);
78
+ lv.setUint16(6, 0, true);
79
+ lv.setUint16(8, method, true);
80
+ lv.setUint16(10, 0, true);
81
+ lv.setUint16(12, 0x0021, true); // date (1980-01-01)
82
+ lv.setUint32(14, crc, true);
83
+ lv.setUint32(18, payload.length, true);
84
+ lv.setUint32(22, entry.data.length, true);
85
+ lv.setUint16(26, nameBytes.length, true);
86
+ lv.setUint16(28, 0, true);
87
+ local.set(nameBytes, 30);
88
+ local.set(payload, 30 + nameBytes.length);
89
+
90
+ // Central directory header (46 bytes + name)
91
+ const central = new Uint8Array(46 + nameBytes.length);
92
+ const cv = new DataView(central.buffer);
93
+ cv.setUint32(0, 0x02014b50, true);
94
+ cv.setUint16(4, 20, true);
95
+ cv.setUint16(6, 20, true);
96
+ cv.setUint16(8, 0, true);
97
+ cv.setUint16(10, method, true);
98
+ cv.setUint16(12, 0, true); // mod time
99
+ cv.setUint16(14, 0x0021, true); // mod date (1980-01-01)
100
+ cv.setUint32(16, crc, true);
101
+ cv.setUint32(20, payload.length, true);
102
+ cv.setUint32(24, entry.data.length, true);
103
+ cv.setUint16(28, nameBytes.length, true);
104
+ cv.setUint16(30, 0, true);
105
+ cv.setUint16(32, 0, true);
106
+ cv.setUint16(34, 0, true);
107
+ cv.setUint16(36, 0, true);
108
+ cv.setUint32(38, 0, true);
109
+ cv.setUint32(42, localOffset, true);
110
+ central.set(nameBytes, 46);
111
+
112
+ localHeaders.push(local);
113
+ centralHeaders.push(central);
114
+ localOffset += local.length;
115
+ }
116
+
117
+ const centralDir = concat(centralHeaders);
118
+ const eocd = new Uint8Array(22);
119
+ const ev = new DataView(eocd.buffer);
120
+ ev.setUint32(0, 0x06054b50, true);
121
+ ev.setUint16(4, 0, true);
122
+ ev.setUint16(6, 0, true);
123
+ ev.setUint16(8, entries.length, true);
124
+ ev.setUint16(10, entries.length, true);
125
+ ev.setUint32(12, centralDir.length, true);
126
+ ev.setUint32(16, localOffset, true);
127
+ ev.setUint16(20, 0, true);
128
+
129
+ return concat([...localHeaders, centralDir, eocd]);
130
+ },
131
+ };
132
+
133
+ function concat(arrays: Uint8Array[]): Uint8Array {
134
+ const total = arrays.reduce((s, a) => s + a.length, 0);
135
+ const out = new Uint8Array(total);
136
+ let offset = 0;
137
+ for (const a of arrays) { out.set(a, offset); offset += a.length; }
138
+ return out;
139
+ }
140
+
141
+ function crc32(data: Uint8Array): number {
142
+ let crc = 0xFFFFFFFF;
143
+ for (const byte of data) {
144
+ crc ^= byte;
145
+ for (let i = 0; i < 8; i++) {
146
+ crc = (crc & 1) ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
147
+ }
148
+ }
149
+ return (crc ^ 0xFFFFFFFF) >>> 0;
150
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * OLE2 Compound File Binary Format (CFB) parser.
3
+ * Used for legacy HWP 5.0 files.
4
+ */
5
+
6
+ export const BinaryKit = {
7
+ readU16LE(buf: Uint8Array, offset: number): number {
8
+ return buf[offset] | (buf[offset + 1] << 8);
9
+ },
10
+
11
+ readU32LE(buf: Uint8Array, offset: number): number {
12
+ return (
13
+ (buf[offset] | (buf[offset + 1] << 8) | (buf[offset + 2] << 16)) >>> 0
14
+ ) + buf[offset + 3] * 0x1000000;
15
+ },
16
+
17
+ isOle2(data: Uint8Array): boolean {
18
+ return (
19
+ data.length >= 8 &&
20
+ data[0] === 0xD0 && data[1] === 0xCF &&
21
+ data[2] === 0x11 && data[3] === 0xE0 &&
22
+ data[4] === 0xA1 && data[5] === 0xB1 &&
23
+ data[6] === 0x1A && data[7] === 0xE1
24
+ );
25
+ },
26
+
27
+ parseCfb(data: Uint8Array): Map<string, Uint8Array> {
28
+ const streams = new Map<string, Uint8Array>();
29
+
30
+ if (!this.isOle2(data)) {
31
+ throw new Error('Not a valid OLE2 file');
32
+ }
33
+
34
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
35
+ const sectorSize = 1 << view.getUint16(30, true);
36
+ const miniSectorSz = 1 << view.getUint16(32, true);
37
+ const dirFirstSec = view.getUint32(48, true);
38
+ const miniStreamCutoff = view.getUint32(56, true);
39
+ const miniFatFirst = view.getUint32(60, true);
40
+ const miniFatCnt = view.getUint32(64, true);
41
+ const difatFirst = view.getUint32(68, true);
42
+
43
+ const ENDOFCHAIN = 0xFFFFFFFE;
44
+ const FREESECT = 0xFFFFFFFF;
45
+
46
+ const sectorAt = (sec: number): Uint8Array =>
47
+ data.subarray(512 + sec * sectorSize, 512 + (sec + 1) * sectorSize);
48
+
49
+ // Build FAT from DIFAT
50
+ const fatSecNums: number[] = [];
51
+ for (let i = 0; i < 109; i++) {
52
+ const s = view.getUint32(76 + i * 4, true);
53
+ if (s === FREESECT || s === ENDOFCHAIN) break;
54
+ fatSecNums.push(s);
55
+ }
56
+ if (difatFirst !== ENDOFCHAIN && difatFirst !== FREESECT) {
57
+ let difSec = difatFirst;
58
+ while (difSec !== ENDOFCHAIN && difSec !== FREESECT) {
59
+ const sec = sectorAt(difSec);
60
+ const sv = new DataView(sec.buffer, sec.byteOffset, sec.byteLength);
61
+ for (let i = 0; i < (sectorSize / 4) - 1; i++) {
62
+ const s = sv.getUint32(i * 4, true);
63
+ if (s === FREESECT || s === ENDOFCHAIN) break;
64
+ fatSecNums.push(s);
65
+ }
66
+ difSec = sv.getUint32(sectorSize - 4, true);
67
+ }
68
+ }
69
+
70
+ const fat: number[] = [];
71
+ for (const sec of fatSecNums) {
72
+ const s = sectorAt(sec);
73
+ const sv = new DataView(s.buffer, s.byteOffset, s.byteLength);
74
+ for (let i = 0; i < sectorSize / 4; i++) {
75
+ fat.push(sv.getUint32(i * 4, true));
76
+ }
77
+ }
78
+
79
+ const readChain = (startSec: number): Uint8Array => {
80
+ const chunks: Uint8Array[] = [];
81
+ let sec = startSec;
82
+ while (sec !== ENDOFCHAIN && sec !== FREESECT && sec < fat.length) {
83
+ chunks.push(sectorAt(sec));
84
+ sec = fat[sec];
85
+ }
86
+ return concatUint8(chunks);
87
+ };
88
+
89
+ // Directory entries
90
+ const dirData = readChain(dirFirstSec);
91
+ const dirView = new DataView(dirData.buffer, dirData.byteOffset, dirData.byteLength);
92
+ const dirCount = dirData.length / 128;
93
+
94
+ interface DirEntry {
95
+ name: string;
96
+ type: number;
97
+ startSec: number;
98
+ size: number;
99
+ childId: number;
100
+ siblingLeftId: number;
101
+ siblingRightId: number;
102
+ }
103
+
104
+ const dirEntries: DirEntry[] = [];
105
+ for (let i = 0; i < dirCount; i++) {
106
+ const base = i * 128;
107
+ const nameLen = dirView.getUint16(base + 64, true);
108
+ const nameBytes = dirData.subarray(base, base + Math.max(0, nameLen - 2));
109
+ const name = new TextDecoder('utf-16le').decode(nameBytes);
110
+ const type = dirData[base + 66];
111
+ const childId = dirView.getInt32(base + 76, true);
112
+ const sibLeft = dirView.getInt32(base + 68, true);
113
+ const sibRight = dirView.getInt32(base + 72, true);
114
+ const startSec = dirView.getUint32(base + 116, true);
115
+ const size = dirView.getUint32(base + 120, true);
116
+ dirEntries.push({ name, type, startSec, size, childId, siblingLeftId: sibLeft, siblingRightId: sibRight });
117
+ }
118
+
119
+ // Mini stream
120
+ const rootEntry = dirEntries[0];
121
+ let miniStreamData: Uint8Array | null = null;
122
+ let miniFat: number[] = [];
123
+
124
+ if (rootEntry && rootEntry.startSec !== ENDOFCHAIN && rootEntry.startSec !== FREESECT) {
125
+ miniStreamData = readChain(rootEntry.startSec);
126
+ }
127
+
128
+ if (miniFatCnt > 0 && miniFatFirst !== ENDOFCHAIN && miniFatFirst !== FREESECT) {
129
+ const mfData = readChain(miniFatFirst);
130
+ const mfv = new DataView(mfData.buffer, mfData.byteOffset, mfData.byteLength);
131
+ for (let i = 0; i < mfData.length / 4; i++) {
132
+ miniFat.push(mfv.getUint32(i * 4, true));
133
+ }
134
+ }
135
+
136
+ const readMiniChain = (startSec: number, size: number): Uint8Array => {
137
+ if (!miniStreamData) return new Uint8Array(0);
138
+ const chunks: Uint8Array[] = [];
139
+ let sec = startSec;
140
+ let remaining = size;
141
+ while (sec !== ENDOFCHAIN && sec !== FREESECT && sec < miniFat.length && remaining > 0) {
142
+ const off = sec * miniSectorSz;
143
+ const chunk = miniStreamData.subarray(off, off + Math.min(miniSectorSz, remaining));
144
+ chunks.push(chunk);
145
+ remaining -= chunk.length;
146
+ sec = miniFat[sec];
147
+ }
148
+ return concatUint8(chunks).subarray(0, size);
149
+ };
150
+
151
+ // DFS traversal
152
+ const visit = (id: number, path: string): void => {
153
+ if (id < 0 || id >= dirEntries.length) return;
154
+ const entry = dirEntries[id];
155
+ const fullPath = path ? `${path}/${entry.name}` : entry.name;
156
+
157
+ if (entry.type === 2) {
158
+ let streamData: Uint8Array;
159
+ if (entry.size < miniStreamCutoff && miniStreamData) {
160
+ streamData = readMiniChain(entry.startSec, entry.size);
161
+ } else {
162
+ streamData = readChain(entry.startSec).subarray(0, entry.size);
163
+ }
164
+ streams.set(fullPath, streamData);
165
+ streams.set(entry.name, streamData);
166
+ }
167
+
168
+ if (entry.childId >= 0) visit(entry.childId, fullPath);
169
+ if (entry.siblingLeftId >= 0) visit(entry.siblingLeftId, path);
170
+ if (entry.siblingRightId >= 0) visit(entry.siblingRightId, path);
171
+ };
172
+
173
+ if (dirEntries.length > 0 && dirEntries[0].childId >= 0) {
174
+ visit(dirEntries[0].childId, '');
175
+ }
176
+
177
+ return streams;
178
+ },
179
+ };
180
+
181
+ function concatUint8(arrays: Uint8Array[]): Uint8Array {
182
+ const total = arrays.reduce((s, a) => s + a.length, 0);
183
+ const out = new Uint8Array(total);
184
+ let off = 0;
185
+ for (const a of arrays) { out.set(a, off); off += a.length; }
186
+ return out;
187
+ }
@@ -0,0 +1,57 @@
1
+ export const TextKit = {
2
+ decode(data: Uint8Array, encoding = 'utf-8'): string {
3
+ try {
4
+ return new TextDecoder(encoding, { fatal: true }).decode(data);
5
+ } catch {
6
+ return new TextDecoder('utf-8', { fatal: false }).decode(data);
7
+ }
8
+ },
9
+
10
+ encode(text: string): Uint8Array {
11
+ return new TextEncoder().encode(text);
12
+ },
13
+
14
+ escapeXml(s: string): string {
15
+ return s
16
+ .replace(/&/g, '&amp;')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/'/g, '&apos;');
21
+ },
22
+
23
+ unescapeXml(s: string): string {
24
+ return s
25
+ .replace(/&amp;/g, '&')
26
+ .replace(/&lt;/g, '<')
27
+ .replace(/&gt;/g, '>')
28
+ .replace(/&quot;/g, '"')
29
+ .replace(/&apos;/g, "'");
30
+ },
31
+
32
+ normalizeWhitespace(s: string): string {
33
+ return s.replace(/\s+/g, ' ').trim();
34
+ },
35
+
36
+ stripControl(s: string): string {
37
+ // eslint-disable-next-line no-control-regex
38
+ return s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
39
+ },
40
+
41
+ base64Encode(data: Uint8Array): string {
42
+ let binary = '';
43
+ for (let i = 0; i < data.length; i++) {
44
+ binary += String.fromCharCode(data[i]);
45
+ }
46
+ return btoa(binary);
47
+ },
48
+
49
+ base64Decode(b64: string): Uint8Array {
50
+ const binary = atob(b64);
51
+ const bytes = new Uint8Array(binary.length);
52
+ for (let i = 0; i < binary.length; i++) {
53
+ bytes[i] = binary.charCodeAt(i);
54
+ }
55
+ return bytes;
56
+ },
57
+ };
@@ -0,0 +1,91 @@
1
+ import { SaxesParser } from 'saxes';
2
+
3
+ // Parses XML into an xml2js-compatible object structure:
4
+ // { tagName: [{ _attr: { k: v }, _text: '...', childTag: [...] }] }
5
+ function parseXmlStrict(xml: string): Promise<unknown> {
6
+ return new Promise((resolve, reject) => {
7
+ const parser = new SaxesParser({ xmlns: false });
8
+
9
+ interface Frame { tag: string; obj: Record<string, unknown> }
10
+ const stack: Frame[] = [];
11
+ let result: unknown = null;
12
+
13
+ parser.on('error', (err: Error) => reject(err));
14
+
15
+ parser.on('opentag', (node: any) => {
16
+ const obj: Record<string, unknown> = {};
17
+ const attrs = node.attributes as Record<string, string>;
18
+ if (attrs && Object.keys(attrs).length > 0) {
19
+ obj['_attr'] = { ...attrs };
20
+ }
21
+ stack.push({ tag: node.name as string, obj });
22
+ });
23
+
24
+ const appendText = (text: string) => {
25
+ if (stack.length > 0 && text) {
26
+ const frame = stack[stack.length - 1];
27
+ const cur = frame.obj['_text'];
28
+ frame.obj['_text'] = typeof cur === 'string' ? cur + text : text;
29
+ }
30
+ };
31
+
32
+ parser.on('text', (text: string) => appendText(text));
33
+ parser.on('cdata', (cdata: string) => appendText(cdata));
34
+
35
+ parser.on('closetag', () => {
36
+ const frame = stack.pop();
37
+ if (!frame) return;
38
+ const { tag, obj } = frame;
39
+
40
+ // Drop whitespace-only _text
41
+ if (typeof obj['_text'] === 'string' && !(obj['_text'] as string).trim()) {
42
+ delete obj['_text'];
43
+ }
44
+
45
+ if (stack.length === 0) {
46
+ result = { [tag]: [obj] };
47
+ } else {
48
+ const parent = stack[stack.length - 1].obj;
49
+ const existing = parent[tag];
50
+ if (Array.isArray(existing)) {
51
+ (existing as unknown[]).push(obj);
52
+ } else {
53
+ parent[tag] = [obj];
54
+ }
55
+ // Track child order for document-order iteration
56
+ if (!parent['_childOrder']) parent['_childOrder'] = [];
57
+ (parent['_childOrder'] as string[]).push(tag);
58
+ }
59
+ });
60
+
61
+ try {
62
+ parser.write(xml).close();
63
+ resolve(result);
64
+ } catch (e) {
65
+ reject(e);
66
+ }
67
+ });
68
+ }
69
+
70
+ export const XmlKit = {
71
+ /** @deprecated Use parseStrict instead */
72
+ async parse(xml: string): Promise<unknown> {
73
+ return parseXmlStrict(xml);
74
+ },
75
+
76
+ async parseStrict(xml: string): Promise<unknown> {
77
+ return parseXmlStrict(xml);
78
+ },
79
+
80
+ attr(node: Record<string, unknown>, key: string): string | undefined {
81
+ const a = node['_attr'] as Record<string, string> | undefined;
82
+ return a?.[key];
83
+ },
84
+
85
+ text(node: Record<string, unknown> | string | undefined): string {
86
+ if (node == null) return '';
87
+ if (typeof node === 'string') return node;
88
+ const t = node['_text'];
89
+ return typeof t === 'string' ? t : '';
90
+ },
91
+ };
@@ -0,0 +1,42 @@
1
+ import type { AnyNode, DocRoot } from '../model/doc-tree';
2
+
3
+ export type WalkCallback = (node: AnyNode, parent: AnyNode | null, depth: number) => void | 'stop';
4
+
5
+ export function walkNode(
6
+ node: AnyNode,
7
+ cb: WalkCallback,
8
+ parent: AnyNode | null = null,
9
+ depth = 0,
10
+ ): boolean {
11
+ const result = cb(node, parent, depth);
12
+ if (result === 'stop') return false;
13
+
14
+ if ('kids' in node && Array.isArray((node as any).kids)) {
15
+ for (const kid of (node as any).kids) {
16
+ if (!walkNode(kid as AnyNode, cb, node, depth + 1)) return false;
17
+ }
18
+ }
19
+ return true;
20
+ }
21
+
22
+ export class TreeWalker {
23
+ walk(root: DocRoot, cb: WalkCallback): void {
24
+ walkNode(root, cb);
25
+ }
26
+
27
+ findAll<T extends AnyNode>(root: DocRoot, predicate: (n: AnyNode) => n is T): T[] {
28
+ const results: T[] = [];
29
+ walkNode(root, (n) => { if (predicate(n)) results.push(n); });
30
+ return results;
31
+ }
32
+
33
+ extractText(root: DocRoot): string {
34
+ const parts: string[] = [];
35
+ walkNode(root, (n) => {
36
+ if (n.tag === 'txt') parts.push(n.content);
37
+ if (n.tag === 'br') parts.push('\n');
38
+ if (n.tag === 'pb') parts.push('\n\n');
39
+ });
40
+ return parts.join('');
41
+ }
42
+ }
@@ -0,0 +1,26 @@
1
+ import type { DocRoot, GridNode } from '../model/doc-tree';
2
+ import { walkNode } from './TreeWalker';
3
+
4
+ export function countNodes(root: DocRoot): Record<string, number> {
5
+ const counts: Record<string, number> = {};
6
+ walkNode(root, (n) => { counts[n.tag] = (counts[n.tag] ?? 0) + 1; });
7
+ return counts;
8
+ }
9
+
10
+ export function validateRoot(root: DocRoot): string[] {
11
+ const errors: string[] = [];
12
+ if (root.tag !== 'root') errors.push('Root node must have tag "root"');
13
+ if (!Array.isArray(root.kids)) errors.push('Root.kids must be an array');
14
+ if (root.kids.length === 0) errors.push('Document has no sheets');
15
+
16
+ walkNode(root, (n) => {
17
+ if (n.tag === 'cell' && n.kids.length === 0) {
18
+ errors.push('CellNode must have at least one ParaNode child');
19
+ }
20
+ if (n.tag === 'grid' && (n as GridNode).kids.length === 0) {
21
+ errors.push('GridNode must have at least one RowNode');
22
+ }
23
+ });
24
+
25
+ return errors;
26
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2020", "DOM"],
7
+ "strict": true,
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "outDir": "dist",
12
+ "rootDir": "src",
13
+ "skipLibCheck": true,
14
+ "esModuleInterop": true,
15
+ "allowSyntheticDefaultImports": true,
16
+ "resolveJsonModule": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false
20
+ },
21
+ "include": ["src/**/*"],
22
+ "exclude": ["node_modules", "dist", "tests"]
23
+ }