@simplysm/excel 13.0.0-beta.45 → 13.0.0-beta.47
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/dist/excel-cell.js.map +0 -1
- package/dist/excel-col.js.map +0 -1
- package/dist/excel-row.js.map +0 -1
- package/dist/excel-workbook.js.map +0 -1
- package/dist/excel-worksheet.js.map +0 -1
- package/dist/excel-wrapper.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/utils/excel-utils.js.map +0 -1
- package/dist/utils/zip-cache.js.map +0 -1
- package/dist/xml/excel-xml-content-type.js.map +0 -1
- package/dist/xml/excel-xml-drawing.js.map +0 -1
- package/dist/xml/excel-xml-relationship.js.map +0 -1
- package/dist/xml/excel-xml-shared-string.js.map +0 -1
- package/dist/xml/excel-xml-style.js.map +0 -1
- package/dist/xml/excel-xml-unknown.js.map +0 -1
- package/dist/xml/excel-xml-workbook.js.map +0 -1
- package/dist/xml/excel-xml-worksheet.js.map +0 -1
- package/package.json +4 -3
- package/src/excel-cell.ts +326 -0
- package/src/excel-col.ts +43 -0
- package/src/excel-row.ts +37 -0
- package/src/excel-workbook.ts +206 -0
- package/src/excel-worksheet.ts +380 -0
- package/src/excel-wrapper.ts +219 -0
- package/src/index.ts +13 -0
- package/src/types.ts +396 -0
- package/src/utils/excel-utils.ts +201 -0
- package/src/utils/zip-cache.ts +103 -0
- package/src/xml/excel-xml-content-type.ts +64 -0
- package/src/xml/excel-xml-drawing.ts +87 -0
- package/src/xml/excel-xml-relationship.ts +86 -0
- package/src/xml/excel-xml-shared-string.ts +80 -0
- package/src/xml/excel-xml-style.ts +393 -0
- package/src/xml/excel-xml-unknown.ts +11 -0
- package/src/xml/excel-xml-workbook.ts +112 -0
- package/src/xml/excel-xml-worksheet.ts +544 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ExcelXmlWorksheet } from "./xml/excel-xml-worksheet";
|
|
2
|
+
import type { ExcelXmlContentType } from "./xml/excel-xml-content-type";
|
|
3
|
+
import type { ExcelXmlRelationship } from "./xml/excel-xml-relationship";
|
|
4
|
+
import type { ExcelXmlStyle, ExcelStyle } from "./xml/excel-xml-style";
|
|
5
|
+
import type { ExcelXmlSharedString } from "./xml/excel-xml-shared-string";
|
|
6
|
+
import type { ZipCache } from "./utils/zip-cache";
|
|
7
|
+
import type { ExcelAddressPoint, ExcelStyleOptions, ExcelValueType } from "./types";
|
|
8
|
+
import { DateOnly, DateTime, numParseFloat, numParseInt, strIsNullOrEmpty, Time } from "@simplysm/core-common";
|
|
9
|
+
import { ExcelXmlSharedString as ExcelXmlSharedStringClass } from "./xml/excel-xml-shared-string";
|
|
10
|
+
import { ExcelXmlStyle as ExcelXmlStyleClass } from "./xml/excel-xml-style";
|
|
11
|
+
import { ExcelUtils } from "./utils/excel-utils";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Excel 셀을 나타내는 클래스.
|
|
15
|
+
* 값 읽기/쓰기, 수식 설정, 스타일 설정, 셀 병합 등의 기능을 제공한다.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* ## 비동기 메서드 설계
|
|
19
|
+
*
|
|
20
|
+
* `getVal()`, `setVal()` 등 모든 셀 메서드가 `async`인 이유:
|
|
21
|
+
* - 셀 타입에 따라 필요한 XML만 선택적으로 로드한다
|
|
22
|
+
* - 문자열 셀: SharedStrings.xml 로드
|
|
23
|
+
* - 숫자 셀: SharedStrings 로드 안함
|
|
24
|
+
* - 스타일이 있는 셀: Styles.xml 로드
|
|
25
|
+
*
|
|
26
|
+
* 어떤 셀을 읽을지 미리 알 수 없기 때문에 동기 구조로는 구현할 수 없다.
|
|
27
|
+
* 동기 구조로 만들려면 모든 XML을 미리 로드해야 하므로 대용량 파일에서 메모리 문제가 발생한다.
|
|
28
|
+
*/
|
|
29
|
+
export class ExcelCell {
|
|
30
|
+
/** 셀 주소 (0-based 행/열 인덱스) */
|
|
31
|
+
readonly addr: ExcelAddressPoint;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly _zipCache: ZipCache,
|
|
35
|
+
private readonly _targetFileName: string,
|
|
36
|
+
private readonly _r: number,
|
|
37
|
+
private readonly _c: number,
|
|
38
|
+
) {
|
|
39
|
+
this.addr = { r: this._r, c: this._c };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//#region Value Methods
|
|
43
|
+
|
|
44
|
+
/** 셀에 수식 설정 (undefined: 수식 삭제) */
|
|
45
|
+
async setFormula(val: string | undefined): Promise<void> {
|
|
46
|
+
if (val === undefined) {
|
|
47
|
+
await this._deleteCell(this.addr);
|
|
48
|
+
} else {
|
|
49
|
+
const wsData = await this._getWsData();
|
|
50
|
+
wsData.setCellType(this.addr, "str");
|
|
51
|
+
wsData.setCellVal(this.addr, undefined);
|
|
52
|
+
wsData.setCellFormula(this.addr, val);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 셀의 수식 반환 */
|
|
57
|
+
async getFormula(): Promise<string | undefined> {
|
|
58
|
+
const wsData = await this._getWsData();
|
|
59
|
+
return wsData.getCellFormula(this.addr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 셀 값 설정 (undefined: 셀 삭제) */
|
|
63
|
+
async setVal(val: ExcelValueType): Promise<void> {
|
|
64
|
+
if (val === undefined) {
|
|
65
|
+
await this._deleteCell(this.addr);
|
|
66
|
+
} else if (typeof val === "string") {
|
|
67
|
+
const wsData = await this._getWsData();
|
|
68
|
+
const ssData = await this._getOrCreateSsData();
|
|
69
|
+
const ssId = ssData.getIdByString(val);
|
|
70
|
+
if (ssId !== undefined) {
|
|
71
|
+
wsData.setCellType(this.addr, "s");
|
|
72
|
+
wsData.setCellVal(this.addr, ssId.toString());
|
|
73
|
+
} else {
|
|
74
|
+
const newSsId = ssData.add(val);
|
|
75
|
+
wsData.setCellType(this.addr, "s");
|
|
76
|
+
wsData.setCellVal(this.addr, newSsId.toString());
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof val === "boolean") {
|
|
79
|
+
const wsData = await this._getWsData();
|
|
80
|
+
wsData.setCellType(this.addr, "b");
|
|
81
|
+
wsData.setCellVal(this.addr, val ? "1" : "0");
|
|
82
|
+
} else if (typeof val === "number") {
|
|
83
|
+
const wsData = await this._getWsData();
|
|
84
|
+
wsData.setCellType(this.addr, undefined);
|
|
85
|
+
wsData.setCellVal(this.addr, val.toString());
|
|
86
|
+
} else if (val instanceof DateOnly || val instanceof DateTime || val instanceof Time) {
|
|
87
|
+
const wsData = await this._getWsData();
|
|
88
|
+
wsData.setCellType(this.addr, undefined);
|
|
89
|
+
wsData.setCellVal(this.addr, ExcelUtils.convertTimeTickToNumber(val.tick).toString());
|
|
90
|
+
|
|
91
|
+
const numFmtName = val instanceof DateOnly ? "DateOnly" : val instanceof DateTime ? "DateTime" : "Time";
|
|
92
|
+
await this._setStyleInternal({ numFmtId: ExcelUtils.convertNumFmtNameToId(numFmtName).toString() });
|
|
93
|
+
} else {
|
|
94
|
+
throw new Error(`[${ExcelUtils.stringifyAddr(this.addr)}] 지원되지 않는 타입입니다: ${typeof val}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 셀 값 반환 */
|
|
99
|
+
async getVal(): Promise<ExcelValueType> {
|
|
100
|
+
const wsData = await this._getWsData();
|
|
101
|
+
const cellVal = wsData.getCellVal(this.addr);
|
|
102
|
+
if (cellVal === undefined || strIsNullOrEmpty(cellVal)) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cellType = wsData.getCellType(this.addr);
|
|
107
|
+
if (cellType === "s") {
|
|
108
|
+
const ssData = await this._getOrCreateSsData();
|
|
109
|
+
const ssId = numParseInt(cellVal);
|
|
110
|
+
if (ssId == null) {
|
|
111
|
+
throw new Error(`[${ExcelUtils.stringifyAddr(this.addr)}] SharedString ID 파싱 실패: ${cellVal}`);
|
|
112
|
+
}
|
|
113
|
+
return ssData.getStringById(ssId);
|
|
114
|
+
} else if (cellType === "str") {
|
|
115
|
+
return cellVal;
|
|
116
|
+
} else if (cellType === "inlineStr") {
|
|
117
|
+
return cellVal;
|
|
118
|
+
} else if (cellType === "b") {
|
|
119
|
+
return cellVal === "1";
|
|
120
|
+
} else if (cellType === "n") {
|
|
121
|
+
return parseFloat(cellVal);
|
|
122
|
+
} else if (cellType === "e") {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[${ExcelUtils.stringifyAddr(this.addr)}] 셀 타입 분석 실패: 셀에 에러 값이 포함되어 있습니다 (${cellVal})`,
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
// cellType === undefined: 숫자 또는 날짜/시간 타입
|
|
128
|
+
const cellStyleId = wsData.getCellStyleId(this.addr);
|
|
129
|
+
if (cellStyleId === undefined) {
|
|
130
|
+
return parseFloat(cellVal);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const styleData = await this._getStyleData();
|
|
134
|
+
if (styleData == null) {
|
|
135
|
+
return parseFloat(cellVal);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const numFmtId = styleData.get(cellStyleId).numFmtId;
|
|
139
|
+
if (numFmtId === undefined) {
|
|
140
|
+
return parseFloat(cellVal);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const numFmtCode = styleData.getNumFmtCode(numFmtId);
|
|
144
|
+
let numFmt;
|
|
145
|
+
if (numFmtCode !== undefined) {
|
|
146
|
+
numFmt = ExcelUtils.convertNumFmtCodeToName(numFmtCode);
|
|
147
|
+
} else {
|
|
148
|
+
const numFmtIdNum = numParseInt(numFmtId);
|
|
149
|
+
if (numFmtIdNum == null) {
|
|
150
|
+
throw new Error(`[${ExcelUtils.stringifyAddr(this.addr)}] numFmtId 파싱 실패: ${numFmtId}`);
|
|
151
|
+
}
|
|
152
|
+
numFmt = ExcelUtils.convertNumFmtIdToName(numFmtIdNum);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (numFmt === "number") {
|
|
156
|
+
return parseFloat(cellVal);
|
|
157
|
+
} else if (numFmt === "string") {
|
|
158
|
+
return cellVal;
|
|
159
|
+
} else {
|
|
160
|
+
// DateOnly, DateTime, Time
|
|
161
|
+
const dateNum = numParseFloat(cellVal);
|
|
162
|
+
if (dateNum == null) {
|
|
163
|
+
throw new Error(`[${ExcelUtils.stringifyAddr(this.addr)}] 날짜 숫자 파싱 실패: ${cellVal}`);
|
|
164
|
+
}
|
|
165
|
+
const tick = ExcelUtils.convertNumberToTimeTick(dateNum);
|
|
166
|
+
if (numFmt === "DateOnly") {
|
|
167
|
+
return new DateOnly(tick);
|
|
168
|
+
} else if (numFmt === "DateTime") {
|
|
169
|
+
return new DateTime(tick);
|
|
170
|
+
} else {
|
|
171
|
+
return new Time(tick);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
//#endregion
|
|
178
|
+
|
|
179
|
+
//#region Merge Methods
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 현재 셀부터 지정된 끝 좌표까지 셀 병합
|
|
183
|
+
* @param r 병합 끝 행 인덱스 (0-based)
|
|
184
|
+
* @param c 병합 끝 열 인덱스 (0-based)
|
|
185
|
+
* @example
|
|
186
|
+
* // A1 셀에서 호출하면 A1:C3 범위 (3행 x 3열)를 병합
|
|
187
|
+
* await ws.cell(0, 0).merge(2, 2);
|
|
188
|
+
*/
|
|
189
|
+
async merge(r: number, c: number): Promise<void> {
|
|
190
|
+
const wsData = await this._getWsData();
|
|
191
|
+
wsData.setMergeCells(this.addr, { r, c });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
|
|
196
|
+
//#region Style Methods
|
|
197
|
+
|
|
198
|
+
/** 셀의 스타일 ID 반환 */
|
|
199
|
+
async getStyleId(): Promise<string | undefined> {
|
|
200
|
+
const wsData = await this._getWsData();
|
|
201
|
+
return wsData.getCellStyleId(this.addr);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 셀의 스타일 ID 설정 */
|
|
205
|
+
async setStyleId(styleId: string | undefined): Promise<void> {
|
|
206
|
+
const wsData = await this._getWsData();
|
|
207
|
+
wsData.setCellStyleId(this.addr, styleId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 셀 스타일 설정
|
|
212
|
+
* @param opts 스타일 옵션
|
|
213
|
+
* @param opts.background 배경색 (ARGB 형식, 8자리 16진수. 예: "FFFF0000")
|
|
214
|
+
* @param opts.border 테두리 위치 배열 (예: ["left", "right", "top", "bottom"])
|
|
215
|
+
* @param opts.horizontalAlign 가로 정렬 ("left", "center", "right")
|
|
216
|
+
* @param opts.verticalAlign 세로 정렬 ("top", "center", "bottom")
|
|
217
|
+
* @param opts.numberFormat 숫자 형식 ("number", "DateOnly", "DateTime", "Time", "string")
|
|
218
|
+
*/
|
|
219
|
+
async setStyle(opts: ExcelStyleOptions): Promise<void> {
|
|
220
|
+
const style: ExcelStyle = {};
|
|
221
|
+
|
|
222
|
+
if (opts.background != null) {
|
|
223
|
+
if (!/^[0-9A-F]{8}$/i.test(opts.background)) {
|
|
224
|
+
throw new Error("색상 형식이 잘못되었습니다. (형식: 00000000: alpha(역)+rgb)");
|
|
225
|
+
}
|
|
226
|
+
style.background = opts.background;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (opts.border != null) {
|
|
230
|
+
style.border = opts.border;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (opts.horizontalAlign != null) {
|
|
234
|
+
style.horizontalAlign = opts.horizontalAlign;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (opts.verticalAlign != null) {
|
|
238
|
+
style.verticalAlign = opts.verticalAlign;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (opts.numberFormat != null) {
|
|
242
|
+
style.numFmtId = ExcelUtils.convertNumFmtNameToId(opts.numberFormat).toString();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await this._setStyleInternal(style);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
//#endregion
|
|
249
|
+
|
|
250
|
+
//#region Private Methods
|
|
251
|
+
|
|
252
|
+
private async _deleteCell(addr: ExcelAddressPoint): Promise<void> {
|
|
253
|
+
const wsData = await this._getWsData();
|
|
254
|
+
wsData.deleteCell(addr);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async _getWsData(): Promise<ExcelXmlWorksheet> {
|
|
258
|
+
return (await this._zipCache.get(`xl/worksheets/${this._targetFileName}`)) as ExcelXmlWorksheet;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async _setStyleInternal(style: ExcelStyle): Promise<void> {
|
|
262
|
+
const wsData = await this._getWsData();
|
|
263
|
+
const styleData = await this._getOrCreateStyleData();
|
|
264
|
+
let styleId = wsData.getCellStyleId(this.addr);
|
|
265
|
+
if (styleId == null) {
|
|
266
|
+
styleId = styleData.add(style);
|
|
267
|
+
} else {
|
|
268
|
+
styleId = styleData.addWithClone(styleId, style);
|
|
269
|
+
}
|
|
270
|
+
wsData.setCellStyleId(this.addr, styleId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async _getTypeData(): Promise<ExcelXmlContentType> {
|
|
274
|
+
return (await this._zipCache.get("[Content_Types].xml")) as ExcelXmlContentType;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async _getSsData(): Promise<ExcelXmlSharedString | undefined> {
|
|
278
|
+
return (await this._zipCache.get("xl/sharedStrings.xml")) as ExcelXmlSharedString | undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async _getWbRelData(): Promise<ExcelXmlRelationship> {
|
|
282
|
+
return (await this._zipCache.get("xl/_rels/workbook.xml.rels")) as ExcelXmlRelationship;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async _getStyleData(): Promise<ExcelXmlStyle | undefined> {
|
|
286
|
+
return (await this._zipCache.get("xl/styles.xml")) as ExcelXmlStyle | undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async _getOrCreateSsData(): Promise<ExcelXmlSharedString> {
|
|
290
|
+
let ssData = await this._getSsData();
|
|
291
|
+
if (ssData == null) {
|
|
292
|
+
ssData = new ExcelXmlSharedStringClass();
|
|
293
|
+
this._zipCache.set("xl/sharedStrings.xml", ssData);
|
|
294
|
+
|
|
295
|
+
const typeData = await this._getTypeData();
|
|
296
|
+
typeData.add(
|
|
297
|
+
"/xl/sharedStrings.xml",
|
|
298
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const wbRelData = await this._getWbRelData();
|
|
302
|
+
wbRelData.add(
|
|
303
|
+
"sharedStrings.xml",
|
|
304
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return ssData;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async _getOrCreateStyleData(): Promise<ExcelXmlStyle> {
|
|
311
|
+
let styleData = await this._getStyleData();
|
|
312
|
+
if (styleData == null) {
|
|
313
|
+
styleData = new ExcelXmlStyleClass();
|
|
314
|
+
this._zipCache.set("xl/styles.xml", styleData);
|
|
315
|
+
|
|
316
|
+
const typeData = await this._getTypeData();
|
|
317
|
+
typeData.add("/xl/styles.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml");
|
|
318
|
+
|
|
319
|
+
const wbRelData = await this._getWbRelData();
|
|
320
|
+
wbRelData.add("styles.xml", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles");
|
|
321
|
+
}
|
|
322
|
+
return styleData;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
//#endregion
|
|
326
|
+
}
|
package/src/excel-col.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "@simplysm/core-common";
|
|
2
|
+
import { ExcelCell } from "./excel-cell";
|
|
3
|
+
import type { ExcelXmlWorksheet } from "./xml/excel-xml-worksheet";
|
|
4
|
+
import type { ZipCache } from "./utils/zip-cache";
|
|
5
|
+
|
|
6
|
+
/** Excel 워크시트의 열을 나타내는 클래스. 셀 접근 및 열 너비 설정 기능을 제공한다. */
|
|
7
|
+
export class ExcelCol {
|
|
8
|
+
private readonly _cellMap = new Map<number, ExcelCell>();
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly _zipCache: ZipCache,
|
|
12
|
+
private readonly _targetFileName: string,
|
|
13
|
+
private readonly _c: number,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** 행 인덱스에 해당하는 셀 반환 (0-based) */
|
|
17
|
+
cell(r: number): ExcelCell {
|
|
18
|
+
return this._cellMap.getOrCreate(r, new ExcelCell(this._zipCache, this._targetFileName, r, this._c));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 열의 모든 셀 반환 */
|
|
22
|
+
async getCells(): Promise<ExcelCell[]> {
|
|
23
|
+
const result: ExcelCell[] = [];
|
|
24
|
+
const wsData = await this._getWsData();
|
|
25
|
+
const range = wsData.range;
|
|
26
|
+
|
|
27
|
+
for (let r = range.s.r; r <= range.e.r; r++) {
|
|
28
|
+
result[r] = this.cell(r);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 열 너비 설정 */
|
|
35
|
+
async setWidth(size: number): Promise<void> {
|
|
36
|
+
const wsData = await this._getWsData();
|
|
37
|
+
wsData.setColWidth((this._c + 1).toString(), size.toString());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async _getWsData(): Promise<ExcelXmlWorksheet> {
|
|
41
|
+
return (await this._zipCache.get(`xl/worksheets/${this._targetFileName}`)) as ExcelXmlWorksheet;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/excel-row.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "@simplysm/core-common";
|
|
2
|
+
import { ExcelCell } from "./excel-cell";
|
|
3
|
+
import type { ExcelXmlWorksheet } from "./xml/excel-xml-worksheet";
|
|
4
|
+
import type { ZipCache } from "./utils/zip-cache";
|
|
5
|
+
|
|
6
|
+
/** Excel 워크시트의 행을 나타내는 클래스. 셀 접근 기능을 제공한다. */
|
|
7
|
+
export class ExcelRow {
|
|
8
|
+
private readonly _cellMap = new Map<number, ExcelCell>();
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly _zipCache: ZipCache,
|
|
12
|
+
private readonly _targetFileName: string,
|
|
13
|
+
private readonly _r: number,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** 열 인덱스에 해당하는 셀 반환 (0-based) */
|
|
17
|
+
cell(c: number): ExcelCell {
|
|
18
|
+
return this._cellMap.getOrCreate(c, new ExcelCell(this._zipCache, this._targetFileName, this._r, c));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 행의 모든 셀 반환 */
|
|
22
|
+
async getCells(): Promise<ExcelCell[]> {
|
|
23
|
+
const result: ExcelCell[] = [];
|
|
24
|
+
const wsData = await this._getWsData();
|
|
25
|
+
const range = wsData.range;
|
|
26
|
+
|
|
27
|
+
for (let c = range.s.c; c <= range.e.c; c++) {
|
|
28
|
+
result[c] = this.cell(c);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async _getWsData(): Promise<ExcelXmlWorksheet> {
|
|
35
|
+
return (await this._zipCache.get(`xl/worksheets/${this._targetFileName}`)) as ExcelXmlWorksheet;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Bytes } from "@simplysm/core-common";
|
|
2
|
+
import { ExcelWorksheet } from "./excel-worksheet";
|
|
3
|
+
import { ZipCache } from "./utils/zip-cache";
|
|
4
|
+
import { ExcelXmlContentType } from "./xml/excel-xml-content-type";
|
|
5
|
+
import { ExcelXmlRelationship } from "./xml/excel-xml-relationship";
|
|
6
|
+
import type { ExcelXmlWorkbook } from "./xml/excel-xml-workbook";
|
|
7
|
+
import { ExcelXmlWorkbook as ExcelXmlWorkbookClass } from "./xml/excel-xml-workbook";
|
|
8
|
+
import { ExcelXmlWorksheet as ExcelXmlWorksheetClass } from "./xml/excel-xml-worksheet";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Excel 워크북 처리 클래스
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* 이 클래스는 내부적으로 ZIP 리소스를 관리합니다.
|
|
15
|
+
* 사용 완료 후 반드시 리소스를 해제해야 합니다.
|
|
16
|
+
*
|
|
17
|
+
* ## 비동기 설계
|
|
18
|
+
*
|
|
19
|
+
* 대용량 Excel 파일의 메모리 효율성을 위해 Lazy Loading 구조를 채택합니다:
|
|
20
|
+
* - ZIP 파일 내부의 XML은 접근 시점에만 읽고 파싱한다
|
|
21
|
+
* - SharedStrings, Styles 등 대용량 XML은 필요할 때만 로드한다
|
|
22
|
+
* - 극단적 케이스(예: SharedStrings가 1TB인 파일에서 숫자 셀 하나만 읽기)에서도 메모리 효율적이다
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // await using 사용 (권장)
|
|
27
|
+
* await using wb = new ExcelWorkbook(bytes);
|
|
28
|
+
* const ws = await wb.getWorksheet(0);
|
|
29
|
+
* // ... 작업 수행
|
|
30
|
+
* // 스코프 종료 시 자동으로 리소스 해제
|
|
31
|
+
*
|
|
32
|
+
* // 또는 try-finally 사용
|
|
33
|
+
* const wb = new ExcelWorkbook(bytes);
|
|
34
|
+
* try {
|
|
35
|
+
* const ws = await wb.getWorksheet(0);
|
|
36
|
+
* // ... 작업 수행
|
|
37
|
+
* } finally {
|
|
38
|
+
* await wb.close();
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class ExcelWorkbook {
|
|
43
|
+
readonly zipCache: ZipCache;
|
|
44
|
+
private readonly _wsMap = new Map<number, ExcelWorksheet>();
|
|
45
|
+
private _isClosed = false;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param arg 기존 Excel 파일 데이터 (Blob 또는 Uint8Array). 생략 시 새 워크북을 생성한다.
|
|
49
|
+
*/
|
|
50
|
+
constructor(arg?: Blob | Bytes) {
|
|
51
|
+
if (arg != null) {
|
|
52
|
+
this.zipCache = new ZipCache(arg);
|
|
53
|
+
} else {
|
|
54
|
+
this.zipCache = new ZipCache();
|
|
55
|
+
|
|
56
|
+
// Global ContentTypes
|
|
57
|
+
const typeXml = new ExcelXmlContentType();
|
|
58
|
+
this.zipCache.set("[Content_Types].xml", typeXml);
|
|
59
|
+
|
|
60
|
+
// Global Rels
|
|
61
|
+
this.zipCache.set(
|
|
62
|
+
"_rels/.rels",
|
|
63
|
+
new ExcelXmlRelationship().add(
|
|
64
|
+
"xl/workbook.xml",
|
|
65
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Workbook
|
|
70
|
+
const wbXml = new ExcelXmlWorkbookClass();
|
|
71
|
+
this.zipCache.set("xl/workbook.xml", wbXml);
|
|
72
|
+
|
|
73
|
+
// Workbook Rels
|
|
74
|
+
const wbRelXml = new ExcelXmlRelationship();
|
|
75
|
+
this.zipCache.set("xl/_rels/workbook.xml.rels", wbRelXml);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#region Worksheet Methods
|
|
80
|
+
|
|
81
|
+
private _ensureNotClosed(): void {
|
|
82
|
+
if (this._isClosed) {
|
|
83
|
+
throw new Error("ExcelWorkbook이 이미 닫혔습니다. close() 호출 후에는 사용할 수 없습니다.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 워크북의 모든 워크시트 이름을 반환 */
|
|
88
|
+
async getWorksheetNames(): Promise<string[]> {
|
|
89
|
+
this._ensureNotClosed();
|
|
90
|
+
const wbData = (await this.zipCache.get("xl/workbook.xml")) as ExcelXmlWorkbook;
|
|
91
|
+
return wbData.sheetNames;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 새 워크시트를 생성하고 반환 */
|
|
95
|
+
async createWorksheet(name: string): Promise<ExcelWorksheet> {
|
|
96
|
+
this._ensureNotClosed();
|
|
97
|
+
// Workbook
|
|
98
|
+
const wbXml = (await this.zipCache.get("xl/workbook.xml")) as ExcelXmlWorkbook;
|
|
99
|
+
const newWsRelId = wbXml.addWorksheet(name).lastWsRelId!;
|
|
100
|
+
|
|
101
|
+
// Content Types
|
|
102
|
+
const typeXml = (await this.zipCache.get("[Content_Types].xml")) as ExcelXmlContentType;
|
|
103
|
+
typeXml.add(
|
|
104
|
+
`/xl/worksheets/sheet${newWsRelId}.xml`,
|
|
105
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Workbook Rels
|
|
109
|
+
const wbRelXml = (await this.zipCache.get("xl/_rels/workbook.xml.rels")) as ExcelXmlRelationship;
|
|
110
|
+
wbRelXml.insert(
|
|
111
|
+
newWsRelId,
|
|
112
|
+
`worksheets/sheet${newWsRelId}.xml`,
|
|
113
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Worksheet
|
|
117
|
+
const wsXml = new ExcelXmlWorksheetClass();
|
|
118
|
+
this.zipCache.set(`xl/worksheets/sheet${newWsRelId}.xml`, wsXml);
|
|
119
|
+
|
|
120
|
+
const ws = new ExcelWorksheet(this.zipCache, newWsRelId, `sheet${newWsRelId}.xml`);
|
|
121
|
+
this._wsMap.set(newWsRelId, ws);
|
|
122
|
+
return ws;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 이름 또는 인덱스(0-based)로 워크시트를 조회 */
|
|
126
|
+
async getWorksheet(nameOrIndex: string | number): Promise<ExcelWorksheet> {
|
|
127
|
+
this._ensureNotClosed();
|
|
128
|
+
const wbData = (await this.zipCache.get("xl/workbook.xml")) as ExcelXmlWorkbook;
|
|
129
|
+
const wsId =
|
|
130
|
+
typeof nameOrIndex === "string" ? wbData.getWsRelIdByName(nameOrIndex) : wbData.getWsRelIdByIndex(nameOrIndex);
|
|
131
|
+
|
|
132
|
+
if (wsId === undefined) {
|
|
133
|
+
if (typeof nameOrIndex === "string") {
|
|
134
|
+
throw new Error(`시트명이 '${nameOrIndex}'인 시트를 찾을 수 없습니다.`);
|
|
135
|
+
} else {
|
|
136
|
+
throw new Error(`'${nameOrIndex}'번째 시트를 찾을 수 없습니다.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this._wsMap.has(wsId)) {
|
|
141
|
+
return this._wsMap.get(wsId)!;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const relData = (await this.zipCache.get("xl/_rels/workbook.xml.rels")) as ExcelXmlRelationship;
|
|
145
|
+
const targetFilePath = relData.getTargetByRelId(wsId);
|
|
146
|
+
if (targetFilePath == null) {
|
|
147
|
+
throw new Error(`시트 관계 정보를 찾을 수 없습니다: rId${wsId}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// path.basename 대신 직접 파일명 추출 (브라우저 호환성)
|
|
151
|
+
const fileName = targetFilePath.split("/").pop();
|
|
152
|
+
if (fileName == null) {
|
|
153
|
+
throw new Error(`시트 파일명을 추출할 수 없습니다: ${targetFilePath}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ws = new ExcelWorksheet(this.zipCache, wsId, fileName);
|
|
157
|
+
this._wsMap.set(wsId, ws);
|
|
158
|
+
return ws;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
//#endregion
|
|
162
|
+
|
|
163
|
+
//#region Export Methods
|
|
164
|
+
|
|
165
|
+
/** 워크북을 바이트 배열로 출력 */
|
|
166
|
+
async getBytes(): Promise<Bytes> {
|
|
167
|
+
this._ensureNotClosed();
|
|
168
|
+
return this.zipCache.toBytes();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 워크북을 Blob으로 출력 */
|
|
172
|
+
async getBlob(): Promise<Blob> {
|
|
173
|
+
this._ensureNotClosed();
|
|
174
|
+
const bytes = await this.zipCache.toBytes();
|
|
175
|
+
return new Blob([new Uint8Array(bytes)], {
|
|
176
|
+
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//#endregion
|
|
181
|
+
|
|
182
|
+
//#region Lifecycle Methods
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 워크북 리소스 해제
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* ZIP 리더와 내부 캐시를 정리합니다.
|
|
189
|
+
* 호출 후에는 이 워크북 인스턴스를 사용할 수 없습니다.
|
|
190
|
+
* 이미 닫힌 워크북에 대해 호출해도 안전합니다 (no-op).
|
|
191
|
+
*/
|
|
192
|
+
async close(): Promise<void> {
|
|
193
|
+
if (this._isClosed) {
|
|
194
|
+
return; // 이미 닫힌 경우 무시
|
|
195
|
+
}
|
|
196
|
+
this._isClosed = true;
|
|
197
|
+
this._wsMap.clear();
|
|
198
|
+
await this.zipCache.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
202
|
+
await this.close();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
//#endregion
|
|
206
|
+
}
|