@thecodecodex/tools 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +46 -4
- package/src/base64.ts +36 -0
- package/src/br/cnpj.ts +55 -0
- package/src/br/cpf.ts +61 -0
- package/src/cli/main.ts +93 -0
- package/src/cli/tools.ts +643 -0
- package/src/compensation-comparator.ts +124 -0
- package/src/css-playground.ts +699 -0
- package/src/dev-ref.ts +295 -0
- package/src/diff.ts +301 -0
- package/src/finance/clt.ts +189 -0
- package/src/finance/compound.ts +78 -0
- package/src/finance/pj.ts +146 -0
- package/src/fov.ts +145 -0
- package/src/hash.ts +13 -0
- package/src/image-to-pdf.ts +299 -0
- package/src/invoice-pdf.ts +134 -0
- package/src/invoice.ts +94 -0
- package/src/jwt.ts +44 -0
- package/src/market.ts +49 -0
- package/src/password.ts +51 -0
- package/src/pdf-studio.ts +453 -0
- package/src/random/index.ts +19 -0
- package/src/regex.ts +426 -0
- package/src/skills.json +60 -0
- package/src/skills.ts +52 -0
- package/src/state-holidays.ts +94 -0
- package/src/table-convert/code.ts +99 -0
- package/src/table-convert/index.ts +42 -0
- package/src/table-convert/model.ts +72 -0
- package/src/table-convert/parse.ts +242 -0
- package/src/table-convert/serialize.ts +435 -0
- package/src/table-convert/transform.ts +133 -0
- package/src/table-convert/xlsx.ts +262 -0
- package/src/table-convert/zip.ts +127 -0
- package/src/timestamp.ts +50 -0
- package/src/url-codec.ts +25 -0
- package/src/uuid.ts +53 -0
- package/src/vacation.ts +171 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Excel (.xlsx) sem dependências: um .xlsx é um ZIP de arquivos XML (SpreadsheetML).
|
|
2
|
+
// Escrita: uma aba, cabeçalho em negrito e congelado, filtro, larguras de coluna e células tipadas.
|
|
3
|
+
// Leitura: todas as abas, textos compartilhados, fórmulas (valor calculado), datas e células mescladas.
|
|
4
|
+
import { uniqueKeys, type Table } from "./model"
|
|
5
|
+
import { decodeEntities } from "./parse"
|
|
6
|
+
import { columnKinds, displayWidth, typedRows, type SerializeOptions } from "./serialize"
|
|
7
|
+
import { unzip, zip, ZipError } from "./zip"
|
|
8
|
+
|
|
9
|
+
const MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
10
|
+
const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
11
|
+
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
12
|
+
|
|
13
|
+
// ---------- escrita ----------
|
|
14
|
+
|
|
15
|
+
export function columnName(index: number): string {
|
|
16
|
+
let name = ""
|
|
17
|
+
for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name
|
|
18
|
+
return name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// caracteres de controle são proibidos em XML; o Excel limita a célula a 32.767 caracteres
|
|
22
|
+
const xmlText = (text: string) =>
|
|
23
|
+
text
|
|
24
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "")
|
|
25
|
+
.slice(0, 32767)
|
|
26
|
+
.replace(/&/g, "&")
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, """)
|
|
30
|
+
|
|
31
|
+
export function sheetName(name: string | undefined): string {
|
|
32
|
+
const clean = (name ?? "").replace(/[[\]:*?/\\]/g, " ").trim().slice(0, 31)
|
|
33
|
+
return clean || "Sheet1"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Datas ISO (2026-10-21 ou 2026-10-21 13:45[:00]) viram datas de verdade no Excel
|
|
37
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
|
|
38
|
+
|
|
39
|
+
export function excelSerial(iso: string): number | undefined {
|
|
40
|
+
const m = ISO_DATE.exec(iso)
|
|
41
|
+
if (!m) return undefined
|
|
42
|
+
const [year, month, day, hour, minute, second] = m.slice(1).map((v) => Number(v ?? 0)) as number[]
|
|
43
|
+
const ms = Date.UTC(year as number, (month as number) - 1, day, hour, minute, second)
|
|
44
|
+
const check = new Date(ms)
|
|
45
|
+
if (check.getUTCMonth() !== (month as number) - 1 || check.getUTCDate() !== day) return undefined
|
|
46
|
+
return ms / 86_400_000 + 25569
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// estilos (índices de cellXfs): 0 normal, 1 cabeçalho em negrito, 2 data, 3 data e hora
|
|
50
|
+
const DATE_STYLE = ' s="2"'
|
|
51
|
+
const DATETIME_STYLE = ' s="3"'
|
|
52
|
+
|
|
53
|
+
export function toXlsx(t: Table, o: SerializeOptions & { sheet?: string } = {}): Uint8Array {
|
|
54
|
+
const header = o.header ?? true
|
|
55
|
+
const kinds = columnKinds(t, o)
|
|
56
|
+
const rows = typedRows(t, o, kinds)
|
|
57
|
+
// coluna de datas: todos os valores preenchidos são datas ISO válidas
|
|
58
|
+
const dateColumns = t.columns.map((_, i) => {
|
|
59
|
+
if (!(o.types ?? true) || kinds[i] !== "text") return undefined
|
|
60
|
+
const values = t.rows.map((r) => r[i] ?? "").filter((c) => c !== "")
|
|
61
|
+
if (!values.length || !values.every((c) => excelSerial(c) !== undefined)) return undefined
|
|
62
|
+
return values.some((c) => c.length > 10) ? "datetime" : "date"
|
|
63
|
+
})
|
|
64
|
+
const cell = (ref: string, value: string | number | boolean | null, style = "", column = -1) => {
|
|
65
|
+
if (value === null || value === "") return ""
|
|
66
|
+
const date = dateColumns[column]
|
|
67
|
+
if (date && typeof value === "string") return `<c r="${ref}"${date === "date" ? DATE_STYLE : DATETIME_STYLE}><v>${excelSerial(value)}</v></c>`
|
|
68
|
+
if (typeof value === "number") return `<c r="${ref}"${style}><v>${value}</v></c>`
|
|
69
|
+
if (typeof value === "boolean") return `<c r="${ref}"${style} t="b"><v>${value ? 1 : 0}</v></c>`
|
|
70
|
+
return `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${xmlText(value)}</t></is></c>`
|
|
71
|
+
}
|
|
72
|
+
const lines: string[] = []
|
|
73
|
+
const offset = header ? 1 : 0
|
|
74
|
+
if (header) lines.push(`<row r="1">${uniqueKeys(t.columns).map((name, c) => cell(`${columnName(c)}1`, name, ' s="1"')).join("")}</row>`)
|
|
75
|
+
rows.forEach((r, i) => lines.push(`<row r="${i + 1 + offset}">${r.map((v, c) => cell(`${columnName(c)}${i + 1 + offset}`, v ?? null, "", c)).join("")}</row>`))
|
|
76
|
+
const widths = t.columns.map((name, c) => Math.min(60, Math.max(8, displayWidth(name) + 2, ...t.rows.slice(0, 500).map((r) => displayWidth(r[c] ?? "") + 2))))
|
|
77
|
+
const last = `${columnName(Math.max(0, t.columns.length - 1))}${Math.max(1, rows.length + offset)}`
|
|
78
|
+
const sheet =
|
|
79
|
+
XML_HEAD +
|
|
80
|
+
`<worksheet xmlns="${MAIN}" xmlns:r="${REL}">` +
|
|
81
|
+
(header ? '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' : "") +
|
|
82
|
+
(widths.length ? `<cols>${widths.map((w, c) => `<col min="${c + 1}" max="${c + 1}" width="${w}" customWidth="1"/>`).join("")}</cols>` : "") +
|
|
83
|
+
`<sheetData>${lines.join("")}</sheetData>` +
|
|
84
|
+
(header && t.columns.length ? `<autoFilter ref="A1:${last}"/>` : "") +
|
|
85
|
+
"</worksheet>"
|
|
86
|
+
const styles =
|
|
87
|
+
XML_HEAD +
|
|
88
|
+
`<styleSheet xmlns="${MAIN}">` +
|
|
89
|
+
'<numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy\\-mm\\-dd\\ hh:mm:ss"/></numFmts>' +
|
|
90
|
+
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
|
91
|
+
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
|
|
92
|
+
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
|
|
93
|
+
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
|
94
|
+
'<cellXfs count="4"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
|
|
95
|
+
'<xf numFmtId="14" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>' +
|
|
96
|
+
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
|
|
97
|
+
"</styleSheet>"
|
|
98
|
+
const files: Record<string, string> = {
|
|
99
|
+
"[Content_Types].xml":
|
|
100
|
+
XML_HEAD +
|
|
101
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
102
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
103
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
104
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
|
105
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
|
106
|
+
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
|
107
|
+
"</Types>",
|
|
108
|
+
"_rels/.rels": XML_HEAD + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
|
|
109
|
+
"xl/workbook.xml": XML_HEAD + `<workbook xmlns="${MAIN}" xmlns:r="${REL}"><sheets><sheet name="${xmlText(sheetName(o.sheet))}" sheetId="1" r:id="rId1"/></sheets>` + (header && t.columns.length ? `<definedNames><definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">'${sheetName(o.sheet).replace(/'/g, "''")}'!$A$1:$${columnName(t.columns.length - 1)}$${Math.max(1, rows.length + 1)}</definedName></definedNames>` : "") + "</workbook>",
|
|
110
|
+
"xl/_rels/workbook.xml.rels": XML_HEAD + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${REL}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="${REL}/styles" Target="styles.xml"/></Relationships>`,
|
|
111
|
+
"xl/worksheets/sheet1.xml": sheet,
|
|
112
|
+
"xl/styles.xml": styles,
|
|
113
|
+
}
|
|
114
|
+
const encoder = new TextEncoder()
|
|
115
|
+
return zip(Object.entries(files).map(([name, text]) => ({ name, data: encoder.encode(text) })))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- leitura ----------
|
|
119
|
+
|
|
120
|
+
export interface Sheet {
|
|
121
|
+
name: string
|
|
122
|
+
rows: string[][]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class XlsxError extends Error {}
|
|
126
|
+
|
|
127
|
+
export const isZip = (bytes: Uint8Array) => bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
|
|
128
|
+
|
|
129
|
+
const attr = (attrs: string, name: string) => {
|
|
130
|
+
const match = new RegExp(`(?:^|\\s)(?:\\w+:)?${name}\\s*=\\s*("([^"]*)"|'([^']*)')`).exec(attrs)
|
|
131
|
+
return match ? decodeEntities(match[2] ?? match[3] ?? "") : undefined
|
|
132
|
+
}
|
|
133
|
+
// OOXML escapa caracteres de controle como _x000D_
|
|
134
|
+
const ooxmlText = (text: string) => decodeEntities(text).replace(/_x([\da-fA-F]{4})_/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)))
|
|
135
|
+
const runsText = (xml: string) => [...xml.replace(/<rPh\b[\s\S]*?<\/rPh>/g, "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>|<t\b[^>]*\/>/g)].map((m) => ooxmlText(m[1] ?? "")).join("")
|
|
136
|
+
|
|
137
|
+
// Formatos numéricos de data/hora embutidos no Excel
|
|
138
|
+
const DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47, 50, 57])
|
|
139
|
+
|
|
140
|
+
function isDateFormat(code: string): boolean {
|
|
141
|
+
const bare = code
|
|
142
|
+
.replace(/"[^"]*"/g, "")
|
|
143
|
+
.replace(/\\./g, "")
|
|
144
|
+
.replace(/\[(?!h\]|m\]|s\])[^\]]*\]/gi, "")
|
|
145
|
+
return /[dmyhs]/i.test(bare) && !/^general$/i.test(code.trim())
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pad2 = (n: number) => String(n).padStart(2, "0")
|
|
149
|
+
|
|
150
|
+
// Número de série do Excel → data ISO (sistema 1900, ou 1904 de planilhas antigas do Mac)
|
|
151
|
+
export function excelDate(serial: number, date1904 = false): string {
|
|
152
|
+
const days = serial + (date1904 ? 1462 : 0)
|
|
153
|
+
const ms = Math.round((days - 25569) * 86_400_000)
|
|
154
|
+
const d = new Date(ms)
|
|
155
|
+
const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
|
|
156
|
+
const time = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
|
|
157
|
+
if (serial < 1 && !date1904) return time
|
|
158
|
+
return Number.isInteger(Math.round(serial * 86400) / 86400) ? date : `${date} ${time}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cellIndex(ref: string | undefined): [number, number] | undefined {
|
|
162
|
+
const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(ref ?? "")
|
|
163
|
+
if (!match) return undefined
|
|
164
|
+
const letters = (match[1] ?? "").toUpperCase()
|
|
165
|
+
let column = 0
|
|
166
|
+
for (const ch of letters) column = column * 26 + (ch.charCodeAt(0) - 64)
|
|
167
|
+
return [Number(match[2]) - 1, column - 1]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolvePath(base: string, target: string): string {
|
|
171
|
+
if (target.startsWith("/")) return target.slice(1)
|
|
172
|
+
const parts = base.split("/").slice(0, -1)
|
|
173
|
+
for (const part of target.split("/")) {
|
|
174
|
+
if (part === "..") parts.pop()
|
|
175
|
+
else if (part !== ".") parts.push(part)
|
|
176
|
+
}
|
|
177
|
+
return parts.join("/")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function readXlsx(bytes: Uint8Array): Promise<Sheet[]> {
|
|
181
|
+
let files: ReturnType<typeof unzip>
|
|
182
|
+
try {
|
|
183
|
+
files = unzip(bytes)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new XlsxError(error instanceof ZipError ? error.message : String(error))
|
|
186
|
+
}
|
|
187
|
+
const decoder = new TextDecoder()
|
|
188
|
+
const text = async (path: string) => {
|
|
189
|
+
const get = files.get(path)
|
|
190
|
+
return get ? decoder.decode(await get()) : undefined
|
|
191
|
+
}
|
|
192
|
+
const rels = async (path: string) => {
|
|
193
|
+
const dir = path.split("/").slice(0, -1).join("/")
|
|
194
|
+
const xml = (await text(`${dir ? `${dir}/` : ""}_rels/${path.split("/").pop()}.rels`)) ?? ""
|
|
195
|
+
return new Map([...xml.matchAll(/<Relationship\b([^>]*)\/?>/g)].map((m) => [attr(m[1] ?? "", "Id") ?? "", { target: attr(m[1] ?? "", "Target") ?? "", type: attr(m[1] ?? "", "Type") ?? "" }]))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const root = [...(await rels("")).values()].find((r) => r.type.endsWith("/officeDocument"))
|
|
199
|
+
const workbookPath = root ? resolvePath("", root.target) : "xl/workbook.xml"
|
|
200
|
+
const workbook = await text(workbookPath)
|
|
201
|
+
if (!workbook) throw new XlsxError(files.has("word/document.xml") ? "this is a Word file, not a spreadsheet" : "workbook.xml not found (is this an .xlsx?)")
|
|
202
|
+
const date1904 = /<workbookPr\b[^>]*date1904\s*=\s*["'](1|true)["']/i.test(workbook)
|
|
203
|
+
const workbookRels = await rels(workbookPath)
|
|
204
|
+
|
|
205
|
+
const sharedTarget = [...workbookRels.values()].find((r) => r.type.endsWith("/sharedStrings"))
|
|
206
|
+
const sharedXml = (await text(sharedTarget ? resolvePath(workbookPath, sharedTarget.target) : "xl/sharedStrings.xml")) ?? ""
|
|
207
|
+
const shared = [...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>|<si\b[^>]*\/>/g)].map((m) => runsText(m[1] ?? ""))
|
|
208
|
+
|
|
209
|
+
const stylesTarget = [...workbookRels.values()].find((r) => r.type.endsWith("/styles"))
|
|
210
|
+
const stylesXml = (await text(stylesTarget ? resolvePath(workbookPath, stylesTarget.target) : "xl/styles.xml")) ?? ""
|
|
211
|
+
const customFormats = new Map([...stylesXml.matchAll(/<numFmt\b([^>]*)\/?>/g)].map((m) => [Number(attr(m[1] ?? "", "numFmtId")), attr(m[1] ?? "", "formatCode") ?? ""]))
|
|
212
|
+
const cellXfs = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(stylesXml)?.[1] ?? ""
|
|
213
|
+
const dateStyles = [...cellXfs.matchAll(/<xf\b([^>]*?)(?:\/>|>)/g)].map((m) => {
|
|
214
|
+
const id = Number(attr(m[1] ?? "", "numFmtId") ?? 0)
|
|
215
|
+
return DATE_FORMATS.has(id) || (customFormats.has(id) && isDateFormat(customFormats.get(id) ?? ""))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const sheets: Sheet[] = []
|
|
219
|
+
for (const m of workbook.matchAll(/<sheet\b([^>]*)\/?>/g)) {
|
|
220
|
+
const attrs = m[1] ?? ""
|
|
221
|
+
const rel = workbookRels.get(attr(attrs, "id") ?? "")
|
|
222
|
+
if (!rel || !rel.type.endsWith("/worksheet")) continue
|
|
223
|
+
const xml = (await text(resolvePath(workbookPath, rel.target))) ?? ""
|
|
224
|
+
const grid: string[][] = []
|
|
225
|
+
let nextRow = 0
|
|
226
|
+
for (const rowMatch of xml.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
|
|
227
|
+
const r = Number(attr(rowMatch[1] ?? "", "r") ?? nextRow + 1) - 1
|
|
228
|
+
nextRow = r + 1
|
|
229
|
+
const row: string[] = []
|
|
230
|
+
let nextColumn = 0
|
|
231
|
+
for (const c of (rowMatch[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
232
|
+
const cAttrs = c[1] ?? ""
|
|
233
|
+
const body = c[2] ?? ""
|
|
234
|
+
const column = cellIndex(attr(cAttrs, "r"))?.[1] ?? nextColumn
|
|
235
|
+
nextColumn = column + 1
|
|
236
|
+
const type = attr(cAttrs, "t") ?? "n"
|
|
237
|
+
const raw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)?.[1]
|
|
238
|
+
let value = ""
|
|
239
|
+
if (type === "s") value = shared[Number(raw)] ?? ""
|
|
240
|
+
else if (type === "inlineStr") value = runsText(/<is\b[^>]*>([\s\S]*?)<\/is>/.exec(body)?.[1] ?? "")
|
|
241
|
+
else if (type === "b") value = raw === "1" ? "true" : raw === "0" ? "false" : ""
|
|
242
|
+
else if (raw !== undefined && type === "n") {
|
|
243
|
+
const n = Number(raw)
|
|
244
|
+
value = !Number.isFinite(n) ? raw : dateStyles[Number(attr(cAttrs, "s") ?? 0)] ? excelDate(n, date1904) : String(n)
|
|
245
|
+
} else if (raw !== undefined) value = ooxmlText(raw)
|
|
246
|
+
row[column] = value
|
|
247
|
+
}
|
|
248
|
+
grid[r] = Array.from(row, (v) => v ?? "")
|
|
249
|
+
}
|
|
250
|
+
// células mescladas: o valor do canto superior esquerdo se repete no intervalo
|
|
251
|
+
for (const merge of xml.matchAll(/<mergeCell\b[^>]*ref\s*=\s*["']([^"']+)["']/g)) {
|
|
252
|
+
const [from, to] = (merge[1] ?? "").split(":").map(cellIndex)
|
|
253
|
+
if (!from || !to) continue
|
|
254
|
+
const value = grid[from[0]]?.[from[1]] ?? ""
|
|
255
|
+
for (let r = from[0]; r <= Math.min(to[0], from[0] + 10_000); r++) for (let c = from[1]; c <= Math.min(to[1], from[1] + 1000); c++) (grid[r] ??= [])[c] = value
|
|
256
|
+
}
|
|
257
|
+
const rows = Array.from(grid, (r) => Array.from(r ?? [], (v) => v ?? "")).filter((r) => r.some((v) => v !== ""))
|
|
258
|
+
sheets.push({ name: attr(attrs, "name") ?? `Sheet${sheets.length + 1}`, rows })
|
|
259
|
+
}
|
|
260
|
+
if (sheets.length === 0) throw new XlsxError("no worksheets found")
|
|
261
|
+
return sheets
|
|
262
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ZIP mínimo para o .xlsx: grava sem compressão (STORE) e lê STORE ou DEFLATE.
|
|
2
|
+
// A descompressão usa o DecompressionStream nativo (navegadores atuais e Node 18+), sem dependências.
|
|
3
|
+
|
|
4
|
+
const CRC_TABLE = (() => {
|
|
5
|
+
const table = new Uint32Array(256)
|
|
6
|
+
for (let n = 0; n < 256; n++) {
|
|
7
|
+
let c = n
|
|
8
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
9
|
+
table[n] = c >>> 0
|
|
10
|
+
}
|
|
11
|
+
return table
|
|
12
|
+
})()
|
|
13
|
+
|
|
14
|
+
export function crc32(data: Uint8Array): number {
|
|
15
|
+
let crc = 0xffffffff
|
|
16
|
+
for (const byte of data) crc = (CRC_TABLE[(crc ^ byte) & 0xff] as number) ^ (crc >>> 8)
|
|
17
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ZipEntry {
|
|
21
|
+
name: string
|
|
22
|
+
data: Uint8Array
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Data fixa (1980-01-01) para o mesmo conteúdo gerar sempre os mesmos bytes
|
|
26
|
+
const DOS_TIME = 0
|
|
27
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1
|
|
28
|
+
|
|
29
|
+
export function zip(entries: ZipEntry[]): Uint8Array {
|
|
30
|
+
const encoder = new TextEncoder()
|
|
31
|
+
const locals: Uint8Array[] = []
|
|
32
|
+
const centrals: Uint8Array[] = []
|
|
33
|
+
let offset = 0
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const name = encoder.encode(entry.name)
|
|
36
|
+
const crc = crc32(entry.data)
|
|
37
|
+
const local = new Uint8Array(30 + name.length)
|
|
38
|
+
const lv = new DataView(local.buffer)
|
|
39
|
+
lv.setUint32(0, 0x04034b50, true)
|
|
40
|
+
lv.setUint16(4, 20, true)
|
|
41
|
+
lv.setUint16(6, 0x0800, true) // nomes em UTF-8
|
|
42
|
+
lv.setUint16(8, 0, true) // STORE
|
|
43
|
+
lv.setUint16(10, DOS_TIME, true)
|
|
44
|
+
lv.setUint16(12, DOS_DATE, true)
|
|
45
|
+
lv.setUint32(14, crc, true)
|
|
46
|
+
lv.setUint32(18, entry.data.length, true)
|
|
47
|
+
lv.setUint32(22, entry.data.length, true)
|
|
48
|
+
lv.setUint16(26, name.length, true)
|
|
49
|
+
local.set(name, 30)
|
|
50
|
+
const central = new Uint8Array(46 + name.length)
|
|
51
|
+
const cv = new DataView(central.buffer)
|
|
52
|
+
cv.setUint32(0, 0x02014b50, true)
|
|
53
|
+
cv.setUint16(4, 20, true)
|
|
54
|
+
cv.setUint16(6, 20, true)
|
|
55
|
+
cv.setUint16(8, 0x0800, true)
|
|
56
|
+
cv.setUint16(10, 0, true)
|
|
57
|
+
cv.setUint16(12, DOS_TIME, true)
|
|
58
|
+
cv.setUint16(14, DOS_DATE, true)
|
|
59
|
+
cv.setUint32(16, crc, true)
|
|
60
|
+
cv.setUint32(20, entry.data.length, true)
|
|
61
|
+
cv.setUint32(24, entry.data.length, true)
|
|
62
|
+
cv.setUint16(28, name.length, true)
|
|
63
|
+
cv.setUint32(42, offset, true)
|
|
64
|
+
central.set(name, 46)
|
|
65
|
+
locals.push(local, entry.data)
|
|
66
|
+
centrals.push(central)
|
|
67
|
+
offset += local.length + entry.data.length
|
|
68
|
+
}
|
|
69
|
+
const centralSize = centrals.reduce((sum, c) => sum + c.length, 0)
|
|
70
|
+
const end = new Uint8Array(22)
|
|
71
|
+
const ev = new DataView(end.buffer)
|
|
72
|
+
ev.setUint32(0, 0x06054b50, true)
|
|
73
|
+
ev.setUint16(8, entries.length, true)
|
|
74
|
+
ev.setUint16(10, entries.length, true)
|
|
75
|
+
ev.setUint32(12, centralSize, true)
|
|
76
|
+
ev.setUint32(16, offset, true)
|
|
77
|
+
const out = new Uint8Array(offset + centralSize + end.length)
|
|
78
|
+
let position = 0
|
|
79
|
+
for (const part of [...locals, ...centrals, end]) {
|
|
80
|
+
out.set(part, position)
|
|
81
|
+
position += part.length
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class ZipError extends Error {}
|
|
87
|
+
|
|
88
|
+
async function inflateRaw(data: Uint8Array): Promise<Uint8Array> {
|
|
89
|
+
const stream = new Blob([data as BlobPart]).stream().pipeThrough(new DecompressionStream("deflate-raw"))
|
|
90
|
+
return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Índice do arquivo: nome → função que devolve o conteúdo (só descompacta o que for pedido)
|
|
94
|
+
export function unzip(bytes: Uint8Array): Map<string, () => Promise<Uint8Array>> {
|
|
95
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
96
|
+
let end = -1
|
|
97
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
|
|
98
|
+
if (view.getUint32(i, true) === 0x06054b50) {
|
|
99
|
+
end = i
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (end < 0) throw new ZipError("not a ZIP file")
|
|
104
|
+
const count = view.getUint16(end + 10, true)
|
|
105
|
+
let position = view.getUint32(end + 16, true)
|
|
106
|
+
const decoder = new TextDecoder()
|
|
107
|
+
const files = new Map<string, () => Promise<Uint8Array>>()
|
|
108
|
+
for (let n = 0; n < count; n++) {
|
|
109
|
+
if (view.getUint32(position, true) !== 0x02014b50) throw new ZipError("corrupted central directory")
|
|
110
|
+
const method = view.getUint16(position + 10, true)
|
|
111
|
+
const compressed = view.getUint32(position + 20, true)
|
|
112
|
+
const nameLength = view.getUint16(position + 28, true)
|
|
113
|
+
const extraLength = view.getUint16(position + 30, true)
|
|
114
|
+
const commentLength = view.getUint16(position + 32, true)
|
|
115
|
+
const local = view.getUint32(position + 42, true)
|
|
116
|
+
const name = decoder.decode(bytes.subarray(position + 46, position + 46 + nameLength))
|
|
117
|
+
const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true)
|
|
118
|
+
const data = bytes.subarray(start, start + compressed)
|
|
119
|
+
files.set(name, async () => {
|
|
120
|
+
if (method === 0) return data
|
|
121
|
+
if (method === 8) return inflateRaw(data)
|
|
122
|
+
throw new ZipError(`unsupported compression method ${method}`)
|
|
123
|
+
})
|
|
124
|
+
position += 46 + nameLength + extraLength + commentLength
|
|
125
|
+
}
|
|
126
|
+
return files
|
|
127
|
+
}
|
package/src/timestamp.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Detecta se o número está em segundos, milissegundos, micro ou nanossegundos pela magnitude
|
|
2
|
+
export type Unit = "s" | "ms" | "us" | "ns"
|
|
3
|
+
|
|
4
|
+
export function detectUnit(value: number): Unit {
|
|
5
|
+
const abs = Math.abs(value)
|
|
6
|
+
if (abs < 1e11) return "s"
|
|
7
|
+
if (abs < 1e14) return "ms"
|
|
8
|
+
if (abs < 1e17) return "us"
|
|
9
|
+
return "ns"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TO_MS: Record<Unit, number> = { s: 1000, ms: 1, us: 1e-3, ns: 1e-6 }
|
|
13
|
+
|
|
14
|
+
export function parseTimestamp(raw: string): { date: Date, unit: Unit } | undefined {
|
|
15
|
+
const trimmed = raw.trim()
|
|
16
|
+
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return undefined
|
|
17
|
+
const value = Number(trimmed)
|
|
18
|
+
const unit = detectUnit(value)
|
|
19
|
+
const date = new Date(value * TO_MS[unit])
|
|
20
|
+
return Number.isNaN(date.getTime()) ? undefined : { date, unit }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toUnix(date: Date): { seconds: number, milliseconds: number } {
|
|
24
|
+
return { seconds: Math.floor(date.getTime() / 1000), milliseconds: date.getTime() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// "há 3 dias" / "in 2 hours" via Intl, escolhendo a maior unidade inteira
|
|
28
|
+
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
29
|
+
["year", 31_536_000_000],
|
|
30
|
+
["month", 2_592_000_000],
|
|
31
|
+
["day", 86_400_000],
|
|
32
|
+
["hour", 3_600_000],
|
|
33
|
+
["minute", 60_000],
|
|
34
|
+
["second", 1000],
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
export function relative(date: Date, locale: string, now: Date = new Date()): string {
|
|
38
|
+
const diff = date.getTime() - now.getTime()
|
|
39
|
+
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
|
|
40
|
+
for (const [unit, ms] of UNITS) {
|
|
41
|
+
if (Math.abs(diff) >= ms || unit === "second") return formatter.format(Math.round(diff / ms), unit)
|
|
42
|
+
}
|
|
43
|
+
return ""
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Valor para <input type="datetime-local"> no fuso local
|
|
47
|
+
export function toLocalInput(date: Date): string {
|
|
48
|
+
const offset = date.getTimezoneOffset() * 60_000
|
|
49
|
+
return new Date(date.getTime() - offset).toISOString().slice(0, 19)
|
|
50
|
+
}
|
package/src/url-codec.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Conversão de parâmetros de URL compartilhada pelo site (useUrlState) e pelo script de terminal (src/cli)
|
|
2
|
+
// Converte um valor de estado de/para texto na URL; `parse` devolve undefined para valores inválidos
|
|
3
|
+
export interface UrlCodec<T> {
|
|
4
|
+
parse: (raw: string) => T | undefined
|
|
5
|
+
format: (value: T) => string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const codec = {
|
|
9
|
+
string: { parse: (raw) => raw, format: (value) => value } satisfies UrlCodec<string>,
|
|
10
|
+
number: { parse: (raw) => (raw !== "" && Number.isFinite(Number(raw)) ? Number(raw) : undefined), format: String } satisfies UrlCodec<number>,
|
|
11
|
+
boolean: { parse: (raw) => (raw === "1" ? true : raw === "0" ? false : undefined), format: (value) => (value ? "1" : "0") } satisfies UrlCodec<boolean>,
|
|
12
|
+
oneOf<T extends string | number>(options: readonly T[]): UrlCodec<T> {
|
|
13
|
+
return { parse: (raw) => options.find((option) => String(option) === raw), format: String }
|
|
14
|
+
},
|
|
15
|
+
// listas: valores separados por vírgula
|
|
16
|
+
list<T>(item: UrlCodec<T>): UrlCodec<T[]> {
|
|
17
|
+
return {
|
|
18
|
+
parse: (raw) => {
|
|
19
|
+
const items = raw.split(",").map((part) => item.parse(part))
|
|
20
|
+
return items.every((value) => value !== undefined) ? (items as T[]) : undefined
|
|
21
|
+
},
|
|
22
|
+
format: (values) => values.map(item.format).join(","),
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|
package/src/uuid.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// UUID v4 (aleatório) e v7 (ordenável por tempo, RFC 9562)
|
|
2
|
+
export type UuidVersion = 4 | 7
|
|
3
|
+
|
|
4
|
+
function hex(bytes: Uint8Array): string {
|
|
5
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function format(raw: string): string {
|
|
9
|
+
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function uuidV4(bytes: Uint8Array = crypto.getRandomValues(new Uint8Array(16))): string {
|
|
13
|
+
const b = Uint8Array.from(bytes)
|
|
14
|
+
b[6] = ((b[6] ?? 0) & 0x0f) | 0x40
|
|
15
|
+
b[8] = ((b[8] ?? 0) & 0x3f) | 0x80
|
|
16
|
+
return format(hex(b))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function uuidV7(timestamp: number = Date.now(), bytes: Uint8Array = crypto.getRandomValues(new Uint8Array(10))): string {
|
|
20
|
+
const b = new Uint8Array(16)
|
|
21
|
+
// 48 bits de milissegundos desde a época Unix
|
|
22
|
+
let ms = timestamp
|
|
23
|
+
for (let i = 5; i >= 0; i--) {
|
|
24
|
+
b[i] = ms % 256
|
|
25
|
+
ms = Math.floor(ms / 256)
|
|
26
|
+
}
|
|
27
|
+
b.set(bytes.slice(0, 10), 6)
|
|
28
|
+
b[6] = ((b[6] ?? 0) & 0x0f) | 0x70
|
|
29
|
+
b[8] = ((b[8] ?? 0) & 0x3f) | 0x80
|
|
30
|
+
return format(hex(b))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
34
|
+
|
|
35
|
+
export interface UuidInfo {
|
|
36
|
+
valid: boolean
|
|
37
|
+
version?: number
|
|
38
|
+
timestamp?: Date
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function inspectUuid(value: string): UuidInfo {
|
|
42
|
+
const uuid = value.trim()
|
|
43
|
+
if (!UUID_PATTERN.test(uuid)) return { valid: false }
|
|
44
|
+
const version = parseInt(uuid[14] ?? "0", 16)
|
|
45
|
+
const info: UuidInfo = { valid: true, version }
|
|
46
|
+
if (version === 7) info.timestamp = new Date(parseInt(uuid.replace(/-/g, "").slice(0, 12), 16))
|
|
47
|
+
return info
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatUuid(uuid: string, { uppercase = false, hyphens = true } = {}): string {
|
|
51
|
+
const value = hyphens ? uuid : uuid.replace(/-/g, "")
|
|
52
|
+
return uppercase ? value.toUpperCase() : value
|
|
53
|
+
}
|