@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,299 @@
|
|
|
1
|
+
import { degrees, PDFDocument, rgb } from "pdf-lib"
|
|
2
|
+
import { normalizeRotation, type Rotation } from "./pdf-studio"
|
|
3
|
+
|
|
4
|
+
export const PAGE_SIZES = ["a4", "letter", "legal", "fit"] as const
|
|
5
|
+
export type PageSize = (typeof PAGE_SIZES)[number]
|
|
6
|
+
|
|
7
|
+
export const ORIENTATIONS = ["auto", "portrait", "landscape"] as const
|
|
8
|
+
export type Orientation = (typeof ORIENTATIONS)[number]
|
|
9
|
+
|
|
10
|
+
export const FIT_MODES = ["contain", "cover", "stretch"] as const
|
|
11
|
+
export type FitMode = (typeof FIT_MODES)[number]
|
|
12
|
+
|
|
13
|
+
export const BACKGROUNDS = ["white", "black", "transparent"] as const
|
|
14
|
+
export type PageBackground = (typeof BACKGROUNDS)[number]
|
|
15
|
+
|
|
16
|
+
export type ImageFormat = "png" | "jpeg" | "webp" | "gif" | "bmp" | "unknown"
|
|
17
|
+
|
|
18
|
+
export interface PdfImageInput {
|
|
19
|
+
name: string
|
|
20
|
+
bytes: Uint8Array
|
|
21
|
+
rotation?: Rotation
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ImageToPdfOptions {
|
|
25
|
+
pageSize?: PageSize
|
|
26
|
+
orientation?: Orientation
|
|
27
|
+
fit?: FitMode
|
|
28
|
+
margin?: number
|
|
29
|
+
background?: PageBackground
|
|
30
|
+
title?: string
|
|
31
|
+
author?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PageLayout {
|
|
35
|
+
pageWidth: number
|
|
36
|
+
pageHeight: number
|
|
37
|
+
boxX: number
|
|
38
|
+
boxY: number
|
|
39
|
+
boxWidth: number
|
|
40
|
+
boxHeight: number
|
|
41
|
+
anchorX: number
|
|
42
|
+
anchorY: number
|
|
43
|
+
drawWidth: number
|
|
44
|
+
drawHeight: number
|
|
45
|
+
rotation: Rotation
|
|
46
|
+
imageWidth: number
|
|
47
|
+
imageHeight: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_IMAGE_PDF_OPTIONS: Required<ImageToPdfOptions> = {
|
|
51
|
+
pageSize: "a4",
|
|
52
|
+
orientation: "auto",
|
|
53
|
+
fit: "contain",
|
|
54
|
+
margin: 24,
|
|
55
|
+
background: "white",
|
|
56
|
+
title: "",
|
|
57
|
+
author: "",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PAPER_DIMENSIONS: Record<Exclude<PageSize, "fit">, [number, number]> = {
|
|
61
|
+
a4: [595.28, 841.89],
|
|
62
|
+
letter: [612, 792],
|
|
63
|
+
legal: [612, 1008],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectImageFormat(bytes: Uint8Array): ImageFormat {
|
|
67
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "png"
|
|
68
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "jpeg"
|
|
69
|
+
if (
|
|
70
|
+
bytes.length >= 12 &&
|
|
71
|
+
bytes[0] === 0x52 &&
|
|
72
|
+
bytes[1] === 0x49 &&
|
|
73
|
+
bytes[2] === 0x46 &&
|
|
74
|
+
bytes[3] === 0x46 &&
|
|
75
|
+
bytes[8] === 0x57 &&
|
|
76
|
+
bytes[9] === 0x45 &&
|
|
77
|
+
bytes[10] === 0x42 &&
|
|
78
|
+
bytes[11] === 0x50
|
|
79
|
+
) {
|
|
80
|
+
return "webp"
|
|
81
|
+
}
|
|
82
|
+
if (bytes.length >= 4 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return "gif"
|
|
83
|
+
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return "bmp"
|
|
84
|
+
return "unknown"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function computePageLayout(imgWidth: number, imgHeight: number, rawOptions: ImageToPdfOptions = {}, rawRotation: Rotation = 0): PageLayout {
|
|
88
|
+
const opts = { ...DEFAULT_IMAGE_PDF_OPTIONS, ...rawOptions }
|
|
89
|
+
const rotation = normalizeRotation(rawRotation)
|
|
90
|
+
const swapped = rotation === 90 || rotation === 270
|
|
91
|
+
const effectiveW = Math.max(1, swapped ? imgHeight : imgWidth)
|
|
92
|
+
const effectiveH = Math.max(1, swapped ? imgWidth : imgHeight)
|
|
93
|
+
const margin = Math.max(0, Math.min(180, opts.margin))
|
|
94
|
+
|
|
95
|
+
let pageWidth: number
|
|
96
|
+
let pageHeight: number
|
|
97
|
+
|
|
98
|
+
if (opts.pageSize === "fit") {
|
|
99
|
+
pageWidth = effectiveW + margin * 2
|
|
100
|
+
pageHeight = effectiveH + margin * 2
|
|
101
|
+
} else {
|
|
102
|
+
const [baseW, baseH] = PAPER_DIMENSIONS[opts.pageSize]
|
|
103
|
+
const wantLandscape = opts.orientation === "landscape" || (opts.orientation === "auto" && effectiveW > effectiveH)
|
|
104
|
+
pageWidth = wantLandscape ? Math.max(baseW, baseH) : Math.min(baseW, baseH)
|
|
105
|
+
pageHeight = wantLandscape ? Math.min(baseW, baseH) : Math.max(baseW, baseH)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const availW = Math.max(1, pageWidth - margin * 2)
|
|
109
|
+
const availH = Math.max(1, pageHeight - margin * 2)
|
|
110
|
+
|
|
111
|
+
let boxWidth = availW
|
|
112
|
+
let boxHeight = availH
|
|
113
|
+
|
|
114
|
+
if (opts.pageSize === "fit") {
|
|
115
|
+
boxWidth = effectiveW
|
|
116
|
+
boxHeight = effectiveH
|
|
117
|
+
} else if (opts.fit === "contain") {
|
|
118
|
+
const scale = Math.min(availW / effectiveW, availH / effectiveH)
|
|
119
|
+
boxWidth = effectiveW * scale
|
|
120
|
+
boxHeight = effectiveH * scale
|
|
121
|
+
} else if (opts.fit === "cover") {
|
|
122
|
+
const scale = Math.max(availW / effectiveW, availH / effectiveH)
|
|
123
|
+
boxWidth = effectiveW * scale
|
|
124
|
+
boxHeight = effectiveH * scale
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const boxX = (pageWidth - boxWidth) / 2
|
|
128
|
+
const boxY = (pageHeight - boxHeight) / 2
|
|
129
|
+
|
|
130
|
+
let anchorX = boxX
|
|
131
|
+
let anchorY = boxY
|
|
132
|
+
let drawWidth = boxWidth
|
|
133
|
+
let drawHeight = boxHeight
|
|
134
|
+
|
|
135
|
+
if (rotation === 90) {
|
|
136
|
+
anchorX = boxX + boxWidth
|
|
137
|
+
anchorY = boxY
|
|
138
|
+
drawWidth = boxHeight
|
|
139
|
+
drawHeight = boxWidth
|
|
140
|
+
} else if (rotation === 180) {
|
|
141
|
+
anchorX = boxX + boxWidth
|
|
142
|
+
anchorY = boxY + boxHeight
|
|
143
|
+
} else if (rotation === 270) {
|
|
144
|
+
anchorX = boxX
|
|
145
|
+
anchorY = boxY + boxHeight
|
|
146
|
+
drawWidth = boxHeight
|
|
147
|
+
drawHeight = boxWidth
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
pageWidth: Math.round(pageWidth * 100) / 100,
|
|
152
|
+
pageHeight: Math.round(pageHeight * 100) / 100,
|
|
153
|
+
boxX: Math.round(boxX * 100) / 100,
|
|
154
|
+
boxY: Math.round(boxY * 100) / 100,
|
|
155
|
+
boxWidth: Math.round(boxWidth * 100) / 100,
|
|
156
|
+
boxHeight: Math.round(boxHeight * 100) / 100,
|
|
157
|
+
anchorX: Math.round(anchorX * 100) / 100,
|
|
158
|
+
anchorY: Math.round(anchorY * 100) / 100,
|
|
159
|
+
drawWidth: Math.round(drawWidth * 100) / 100,
|
|
160
|
+
drawHeight: Math.round(drawHeight * 100) / 100,
|
|
161
|
+
rotation,
|
|
162
|
+
imageWidth: imgWidth,
|
|
163
|
+
imageHeight: imgHeight,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function imagesToPdf(images: PdfImageInput[], rawOptions: ImageToPdfOptions = {}): Promise<{ bytes: Uint8Array, pages: PageLayout[] }> {
|
|
168
|
+
if (images.length === 0) throw new Error("Nenhuma imagem fornecida")
|
|
169
|
+
const opts = { ...DEFAULT_IMAGE_PDF_OPTIONS, ...rawOptions }
|
|
170
|
+
const doc = await PDFDocument.create()
|
|
171
|
+
if (opts.title.trim()) doc.setTitle(opts.title.trim())
|
|
172
|
+
if (opts.author.trim()) doc.setAuthor(opts.author.trim())
|
|
173
|
+
doc.setCreator("The Code Codex — Images to PDF")
|
|
174
|
+
|
|
175
|
+
const layouts: PageLayout[] = []
|
|
176
|
+
|
|
177
|
+
for (const item of images) {
|
|
178
|
+
const fmt = detectImageFormat(item.bytes)
|
|
179
|
+
if (fmt !== "png" && fmt !== "jpeg") {
|
|
180
|
+
throw new Error(`Formato ${fmt} requer conversão para PNG ou JPEG antes de embutir no PDF (${item.name})`)
|
|
181
|
+
}
|
|
182
|
+
const embedded = fmt === "png" ? await doc.embedPng(item.bytes) : await doc.embedJpg(item.bytes)
|
|
183
|
+
const layout = computePageLayout(embedded.width, embedded.height, opts, item.rotation ?? 0)
|
|
184
|
+
layouts.push(layout)
|
|
185
|
+
|
|
186
|
+
const page = doc.addPage([layout.pageWidth, layout.pageHeight])
|
|
187
|
+
if (opts.background !== "transparent") {
|
|
188
|
+
const color = opts.background === "black" ? rgb(0.05, 0.06, 0.08) : rgb(1, 1, 1)
|
|
189
|
+
page.drawRectangle({ x: 0, y: 0, width: layout.pageWidth, height: layout.pageHeight, color })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
page.drawImage(embedded, {
|
|
193
|
+
x: layout.anchorX,
|
|
194
|
+
y: layout.anchorY,
|
|
195
|
+
width: layout.drawWidth,
|
|
196
|
+
height: layout.drawHeight,
|
|
197
|
+
rotate: degrees(layout.rotation),
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const bytes = await doc.save()
|
|
202
|
+
return { bytes, pages: layouts }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Gerador puro de PNG RGB (blocos DEFLATE sem compressão + CRC32 + Adler32) para imagens de exemplo em qualquer runtime
|
|
206
|
+
function crc32(buf: Uint8Array): number {
|
|
207
|
+
let c = 0xffffffff
|
|
208
|
+
for (let i = 0; i < buf.length; i++) {
|
|
209
|
+
c ^= buf[i] ?? 0
|
|
210
|
+
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0)
|
|
211
|
+
}
|
|
212
|
+
return (c ^ 0xffffffff) >>> 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function adler32(buf: Uint8Array): number {
|
|
216
|
+
let a = 1
|
|
217
|
+
let b = 0
|
|
218
|
+
for (let i = 0; i < buf.length; i++) {
|
|
219
|
+
a = (a + (buf[i] ?? 0)) % 65521
|
|
220
|
+
b = (b + a) % 65521
|
|
221
|
+
}
|
|
222
|
+
return ((b << 16) | a) >>> 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function u32be(n: number): number[] {
|
|
226
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
230
|
+
const out = new Uint8Array(12 + data.length)
|
|
231
|
+
out.set(u32be(data.length), 0)
|
|
232
|
+
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
|
|
233
|
+
out.set(data, 8)
|
|
234
|
+
const crc = crc32(out.subarray(4, 8 + data.length))
|
|
235
|
+
out.set(u32be(crc), 8 + data.length)
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function createGradientPng(width: number, height: number, topRgb: [number, number, number], bottomRgb: [number, number, number]): Uint8Array {
|
|
240
|
+
const rowSize = 1 + width * 3
|
|
241
|
+
const raw = new Uint8Array(rowSize * height)
|
|
242
|
+
for (let y = 0; y < height; y++) {
|
|
243
|
+
const t = height > 1 ? y / (height - 1) : 0
|
|
244
|
+
const rowOffset = y * rowSize
|
|
245
|
+
raw[rowOffset] = 0 // filtro None
|
|
246
|
+
for (let x = 0; x < width; x++) {
|
|
247
|
+
const s = width > 1 ? x / (width - 1) : 0
|
|
248
|
+
const border = x < 4 || x >= width - 4 || y < 4 || y >= height - 4
|
|
249
|
+
const p = rowOffset + 1 + x * 3
|
|
250
|
+
raw[p] = border ? 255 : Math.round(topRgb[0] * (1 - t) + bottomRgb[0] * t * (0.75 + 0.25 * s))
|
|
251
|
+
raw[p + 1] = border ? 255 : Math.round(topRgb[1] * (1 - t) + bottomRgb[1] * t)
|
|
252
|
+
raw[p + 2] = border ? 255 : Math.round(topRgb[2] * (1 - t * 0.5) + bottomRgb[2] * t * 0.5)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Empacota em blocos zlib stored (máx. 65535 bytes por bloco)
|
|
257
|
+
const maxBlock = 65535
|
|
258
|
+
const numBlocks = Math.ceil(raw.length / maxBlock) || 1
|
|
259
|
+
const zlib = new Uint8Array(2 + raw.length + numBlocks * 5 + 4)
|
|
260
|
+
zlib[0] = 0x78
|
|
261
|
+
zlib[1] = 0x01
|
|
262
|
+
let zPos = 2
|
|
263
|
+
for (let b = 0; b < numBlocks; b++) {
|
|
264
|
+
const start = b * maxBlock
|
|
265
|
+
const slice = raw.subarray(start, Math.min(raw.length, start + maxBlock))
|
|
266
|
+
const len = slice.length
|
|
267
|
+
const nlen = (~len) & 0xffff
|
|
268
|
+
zlib[zPos++] = b === numBlocks - 1 ? 0x01 : 0x00
|
|
269
|
+
zlib[zPos++] = len & 0xff
|
|
270
|
+
zlib[zPos++] = (len >>> 8) & 0xff
|
|
271
|
+
zlib[zPos++] = nlen & 0xff
|
|
272
|
+
zlib[zPos++] = (nlen >>> 8) & 0xff
|
|
273
|
+
zlib.set(slice, zPos)
|
|
274
|
+
zPos += len
|
|
275
|
+
}
|
|
276
|
+
zlib.set(u32be(adler32(raw)), zPos)
|
|
277
|
+
|
|
278
|
+
const ihdr = new Uint8Array([...u32be(width), ...u32be(height), 8, 2, 0, 0, 0])
|
|
279
|
+
const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
|
280
|
+
const cIhdr = pngChunk("IHDR", ihdr)
|
|
281
|
+
const cIdat = pngChunk("IDAT", zlib)
|
|
282
|
+
const cIend = pngChunk("IEND", new Uint8Array(0))
|
|
283
|
+
|
|
284
|
+
const png = new Uint8Array(sig.length + cIhdr.length + cIdat.length + cIend.length)
|
|
285
|
+
let offset = 0
|
|
286
|
+
for (const part of [sig, cIhdr, cIdat, cIend]) {
|
|
287
|
+
png.set(part, offset)
|
|
288
|
+
offset += part.length
|
|
289
|
+
}
|
|
290
|
+
return png
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function createSampleImages(): PdfImageInput[] {
|
|
294
|
+
return [
|
|
295
|
+
{ name: "01-landscape-diagram.png", bytes: createGradientPng(160, 100, [30, 92, 198], [16, 165, 135]), rotation: 0 },
|
|
296
|
+
{ name: "02-portrait-scan.png", bytes: createGradientPng(100, 150, [110, 48, 175], [215, 75, 115]), rotation: 0 },
|
|
297
|
+
{ name: "03-square-chart.png", bytes: createGradientPng(120, 120, [210, 105, 30], [45, 65, 115]), rotation: 0 },
|
|
298
|
+
]
|
|
299
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { PDFDocument, rgb, StandardFonts, type PDFFont, type PDFPage } from "pdf-lib"
|
|
2
|
+
import { lineTotal, totals, type Invoice } from "./invoice"
|
|
3
|
+
|
|
4
|
+
// Gera o PDF do invoice (A4) sem navegador — usado pelo script de terminal. Layout espelha a prévia do site.
|
|
5
|
+
const A4: [number, number] = [595.28, 841.89]
|
|
6
|
+
const MARGIN = 50
|
|
7
|
+
const INK = rgb(0.07, 0.08, 0.12)
|
|
8
|
+
const MUTED = rgb(0.29, 0.33, 0.39)
|
|
9
|
+
const RULE = rgb(0.9, 0.91, 0.92)
|
|
10
|
+
|
|
11
|
+
// As fontes padrão do PDF usam WinAnsi: troca o que não existe nela (ex.: "−", "→") por equivalentes
|
|
12
|
+
function safe(font: PDFFont, text: string): string {
|
|
13
|
+
return [...text.replace(/−/g, "-").replace(/→/g, "->")]
|
|
14
|
+
.map((char) => {
|
|
15
|
+
try {
|
|
16
|
+
font.encodeText(char)
|
|
17
|
+
return char
|
|
18
|
+
} catch {
|
|
19
|
+
return "?"
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.join("")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wrap(font: PDFFont, text: string, size: number, maxWidth: number): string[] {
|
|
26
|
+
return text.split("\n").flatMap((paragraph) => {
|
|
27
|
+
const words = paragraph.split(/\s+/)
|
|
28
|
+
const lines: string[] = []
|
|
29
|
+
let line = ""
|
|
30
|
+
for (const word of words) {
|
|
31
|
+
const candidate = line ? `${line} ${word}` : word
|
|
32
|
+
if (font.widthOfTextAtSize(safe(font, candidate), size) > maxWidth && line) {
|
|
33
|
+
lines.push(line)
|
|
34
|
+
line = word
|
|
35
|
+
} else line = candidate
|
|
36
|
+
}
|
|
37
|
+
lines.push(line)
|
|
38
|
+
return lines
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const longDate = (iso: string) => (iso ? new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" }) : "-")
|
|
43
|
+
|
|
44
|
+
export async function invoicePdf(invoice: Invoice): Promise<Uint8Array> {
|
|
45
|
+
const doc = await PDFDocument.create()
|
|
46
|
+
doc.setTitle(`Invoice ${invoice.number}`)
|
|
47
|
+
doc.setCreator("thecodecodex.com")
|
|
48
|
+
const page: PDFPage = doc.addPage(A4)
|
|
49
|
+
const regular = await doc.embedFont(StandardFonts.Helvetica)
|
|
50
|
+
const bold = await doc.embedFont(StandardFonts.HelveticaBold)
|
|
51
|
+
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: invoice.currency })
|
|
52
|
+
const width = A4[0] - MARGIN * 2
|
|
53
|
+
let y = A4[1] - MARGIN
|
|
54
|
+
|
|
55
|
+
const text = (value: string, x: number, at: number, size = 10, font = regular, color = INK) => page.drawText(safe(font, value), { x, y: at, size, font, color })
|
|
56
|
+
const right = (value: string, xRight: number, at: number, size = 10, font = regular, color = INK) => text(value, xRight - font.widthOfTextAtSize(safe(font, value), size), at, size, font, color)
|
|
57
|
+
|
|
58
|
+
// cabeçalho: número à esquerda, emissor à direita
|
|
59
|
+
text("INVOICE", MARGIN, y, 9, bold, MUTED)
|
|
60
|
+
right(invoice.from.name || "-", MARGIN + width, y, 11, bold)
|
|
61
|
+
y -= 26
|
|
62
|
+
text(invoice.number || "-", MARGIN, y, 22, bold)
|
|
63
|
+
let ry = y + 12
|
|
64
|
+
for (const line of wrap(regular, invoice.from.details, 9, width / 2)) {
|
|
65
|
+
right(line, MARGIN + width, ry, 9, regular, MUTED)
|
|
66
|
+
ry -= 12
|
|
67
|
+
}
|
|
68
|
+
y = Math.min(y, ry) - 28
|
|
69
|
+
|
|
70
|
+
// cliente e datas
|
|
71
|
+
text("BILL TO", MARGIN, y, 8, bold, MUTED)
|
|
72
|
+
right(`Issue date ${longDate(invoice.issued)}`, MARGIN + width, y, 9)
|
|
73
|
+
y -= 14
|
|
74
|
+
text(invoice.to.name || "-", MARGIN, y, 10, bold)
|
|
75
|
+
right(`Due date ${longDate(invoice.due)}`, MARGIN + width, y, 9)
|
|
76
|
+
y -= 13
|
|
77
|
+
for (const line of wrap(regular, invoice.to.details, 9, width / 2)) {
|
|
78
|
+
text(line, MARGIN, y, 9, regular, MUTED)
|
|
79
|
+
y -= 12
|
|
80
|
+
}
|
|
81
|
+
y -= 18
|
|
82
|
+
|
|
83
|
+
// itens
|
|
84
|
+
const cols = { qty: MARGIN + width * 0.62, price: MARGIN + width * 0.8, amount: MARGIN + width }
|
|
85
|
+
text("DESCRIPTION", MARGIN, y, 8, bold)
|
|
86
|
+
right("QTY", cols.qty, y, 8, bold)
|
|
87
|
+
right("UNIT PRICE", cols.price, y, 8, bold)
|
|
88
|
+
right("AMOUNT", cols.amount, y, 8, bold)
|
|
89
|
+
y -= 6
|
|
90
|
+
page.drawLine({ start: { x: MARGIN, y }, end: { x: MARGIN + width, y }, thickness: 1.5, color: INK })
|
|
91
|
+
y -= 16
|
|
92
|
+
for (const item of invoice.items) {
|
|
93
|
+
const lines = wrap(regular, item.description || "-", 10, width * 0.5)
|
|
94
|
+
lines.forEach((line, i) => text(line, MARGIN, y - i * 12, 10))
|
|
95
|
+
right(String(item.quantity), cols.qty, y, 10)
|
|
96
|
+
right(money.format(item.unitPrice || 0), cols.price, y, 10)
|
|
97
|
+
right(money.format(lineTotal(item)), cols.amount, y, 10)
|
|
98
|
+
y -= lines.length * 12 + 6
|
|
99
|
+
page.drawLine({ start: { x: MARGIN, y: y + 2 }, end: { x: MARGIN + width, y: y + 2 }, thickness: 0.5, color: RULE })
|
|
100
|
+
y -= 10
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// totais
|
|
104
|
+
const sum = totals(invoice.items, invoice.discount, invoice.tax)
|
|
105
|
+
const label = MARGIN + width * 0.55
|
|
106
|
+
const row = (name: string, value: string, strong = false) => {
|
|
107
|
+
text(name, label, y, strong ? 11 : 10, strong ? bold : regular, strong ? INK : MUTED)
|
|
108
|
+
right(value, cols.amount, y, strong ? 12 : 10, strong ? bold : regular)
|
|
109
|
+
y -= 16
|
|
110
|
+
}
|
|
111
|
+
y -= 4
|
|
112
|
+
row("Subtotal", money.format(sum.subtotal))
|
|
113
|
+
if (sum.discount > 0) row(`Discount (${+(invoice.discount * 100).toFixed(2)}%)`, `-${money.format(sum.discount)}`)
|
|
114
|
+
if (sum.tax > 0) row(`Tax (${+(invoice.tax * 100).toFixed(2)}%)`, money.format(sum.tax))
|
|
115
|
+
page.drawLine({ start: { x: label, y: y + 10 }, end: { x: MARGIN + width, y: y + 10 }, thickness: 1.5, color: INK })
|
|
116
|
+
y -= 4
|
|
117
|
+
row("Total due", money.format(sum.total), true)
|
|
118
|
+
y -= 22
|
|
119
|
+
|
|
120
|
+
// pagamento e observações
|
|
121
|
+
const block = (title: string, body: string, x: number, top: number) => {
|
|
122
|
+
let by = top
|
|
123
|
+
text(title, x, by, 8, bold, MUTED)
|
|
124
|
+
by -= 14
|
|
125
|
+
for (const line of wrap(regular, body, 9.5, width / 2 - 12)) {
|
|
126
|
+
text(line, x, by, 9.5)
|
|
127
|
+
by -= 12
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (invoice.payment) block("PAYMENT DETAILS", invoice.payment, MARGIN, y)
|
|
131
|
+
if (invoice.notes) block("NOTES", invoice.notes, MARGIN + width / 2 + 12, y)
|
|
132
|
+
|
|
133
|
+
return doc.save()
|
|
134
|
+
}
|
package/src/invoice.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { UrlCodec } from "./url-codec"
|
|
2
|
+
|
|
3
|
+
// Invoice para clientes no exterior: totais e numeração sequencial
|
|
4
|
+
|
|
5
|
+
export interface InvoiceItem {
|
|
6
|
+
description: string
|
|
7
|
+
quantity: number
|
|
8
|
+
unitPrice: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InvoiceTotals {
|
|
12
|
+
subtotal: number
|
|
13
|
+
discount: number
|
|
14
|
+
tax: number
|
|
15
|
+
total: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cents = (value: number) => Math.round(value * 100) / 100
|
|
19
|
+
|
|
20
|
+
export function lineTotal(item: InvoiceItem): number {
|
|
21
|
+
return cents((Number.isFinite(item.quantity) ? item.quantity : 0) * (Number.isFinite(item.unitPrice) ? item.unitPrice : 0))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// desconto e imposto em fração (0.1 = 10%); o imposto incide depois do desconto
|
|
25
|
+
export function totals(items: InvoiceItem[], discountRate = 0, taxRate = 0): InvoiceTotals {
|
|
26
|
+
const subtotal = cents(items.reduce((sum, item) => sum + lineTotal(item), 0))
|
|
27
|
+
const discount = cents(subtotal * discountRate)
|
|
28
|
+
const tax = cents((subtotal - discount) * taxRate)
|
|
29
|
+
return { subtotal, discount, tax, total: cents(subtotal - discount + tax) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Próximo número mantendo prefixo e zeros: "INV-2026-009" → "INV-2026-010"; sem número no fim → acrescenta "-2"
|
|
33
|
+
export function nextInvoiceNumber(current: string): string {
|
|
34
|
+
const match = current.match(/^(.*?)(\d+)(\D*)$/)
|
|
35
|
+
if (!match) return current ? `${current}-2` : "1"
|
|
36
|
+
const [, prefix = "", digits = "0", suffix = ""] = match
|
|
37
|
+
const next = String(Number(digits) + 1).padStart(digits.length, "0")
|
|
38
|
+
return `${prefix}${next}${suffix}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addDaysIso(iso: string, days: number): string {
|
|
42
|
+
const date = new Date(`${iso}T00:00:00Z`)
|
|
43
|
+
date.setUTCDate(date.getUTCDate() + days)
|
|
44
|
+
return date.toISOString().slice(0, 10)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Modelo completo do invoice: o mesmo JSON do link do site (#invoice=...) e do parâmetro invoice= do script de terminal
|
|
48
|
+
export const CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "CHF", "BRL"] as const
|
|
49
|
+
|
|
50
|
+
export interface Party {
|
|
51
|
+
name: string
|
|
52
|
+
details: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Invoice {
|
|
56
|
+
number: string
|
|
57
|
+
issued: string
|
|
58
|
+
due: string
|
|
59
|
+
currency: (typeof CURRENCIES)[number]
|
|
60
|
+
from: Party
|
|
61
|
+
to: Party
|
|
62
|
+
items: InvoiceItem[]
|
|
63
|
+
discount: number
|
|
64
|
+
tax: number
|
|
65
|
+
payment: string
|
|
66
|
+
notes: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EMPTY_INVOICE: Invoice = {
|
|
70
|
+
number: "INV-001",
|
|
71
|
+
issued: "",
|
|
72
|
+
due: "",
|
|
73
|
+
currency: "USD",
|
|
74
|
+
from: { name: "", details: "" },
|
|
75
|
+
to: { name: "", details: "" },
|
|
76
|
+
items: [{ description: "Software development services", quantity: 1, unitPrice: 0 }],
|
|
77
|
+
discount: 0,
|
|
78
|
+
tax: 0,
|
|
79
|
+
payment: "",
|
|
80
|
+
notes: "Payment due within 15 days. Thank you for your business!",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Invoice inteiro como JSON no fragmento (#) da URL
|
|
84
|
+
export const invoiceCodec: UrlCodec<Invoice> = {
|
|
85
|
+
parse: (raw) => {
|
|
86
|
+
try {
|
|
87
|
+
const value = JSON.parse(raw) as Partial<Invoice>
|
|
88
|
+
return value && Array.isArray(value.items) ? { ...EMPTY_INVOICE, ...value } : undefined
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
format: (value) => JSON.stringify(value),
|
|
94
|
+
}
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { decode } from "./base64"
|
|
2
|
+
|
|
3
|
+
// Apenas decodifica: a assinatura NÃO é verificada (isso exige a chave do emissor)
|
|
4
|
+
export interface DecodedJwt {
|
|
5
|
+
header: Record<string, unknown>
|
|
6
|
+
payload: Record<string, unknown>
|
|
7
|
+
signature: string
|
|
8
|
+
issuedAt?: Date
|
|
9
|
+
expiresAt?: Date
|
|
10
|
+
notBefore?: Date
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type JwtResult = { ok: true, jwt: DecodedJwt } | { ok: false, error: "format" | "json" }
|
|
14
|
+
|
|
15
|
+
function part(value: string): Record<string, unknown> {
|
|
16
|
+
const parsed: unknown = JSON.parse(decode(value, "base64url"))
|
|
17
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new SyntaxError("not an object")
|
|
18
|
+
return parsed as Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const date = (value: unknown) => (typeof value === "number" ? new Date(value * 1000) : undefined)
|
|
22
|
+
|
|
23
|
+
export function decodeJwt(token: string): JwtResult {
|
|
24
|
+
const parts = token.trim().replace(/^Bearer\s+/i, "").split(".")
|
|
25
|
+
if (parts.length !== 3 || parts.some((p, i) => i < 2 && !p)) return { ok: false, error: "format" }
|
|
26
|
+
try {
|
|
27
|
+
const header = part(parts[0] ?? "")
|
|
28
|
+
const payload = part(parts[1] ?? "")
|
|
29
|
+
const jwt: DecodedJwt = { header, payload, signature: parts[2] ?? "" }
|
|
30
|
+
const iat = date(payload["iat"])
|
|
31
|
+
const exp = date(payload["exp"])
|
|
32
|
+
const nbf = date(payload["nbf"])
|
|
33
|
+
if (iat) jwt.issuedAt = iat
|
|
34
|
+
if (exp) jwt.expiresAt = exp
|
|
35
|
+
if (nbf) jwt.notBefore = nbf
|
|
36
|
+
return { ok: true, jwt }
|
|
37
|
+
} catch {
|
|
38
|
+
return { ok: false, error: "json" }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isExpired(jwt: DecodedJwt, now: Date = new Date()): boolean | undefined {
|
|
43
|
+
return jwt.expiresAt ? jwt.expiresAt.getTime() < now.getTime() : undefined
|
|
44
|
+
}
|
package/src/market.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Indicadores do Banco Central. Os dados vêm de fora (o site grava um JSON por dia no build) e são
|
|
2
|
+
// carregados com setMarketData antes do uso. Se a API falhou na última atualização, `stale` é true
|
|
3
|
+
// e o valor é o último conhecido.
|
|
4
|
+
export interface MarketValue {
|
|
5
|
+
code: number
|
|
6
|
+
name: string
|
|
7
|
+
unit: string
|
|
8
|
+
value: number
|
|
9
|
+
date: string
|
|
10
|
+
stale: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type MarketKey = "cdi" | "ipca12m" | "savingsMonthly" | "usd" | "eur" | "gbp" | "cad"
|
|
14
|
+
export type Currency = "USD" | "EUR" | "GBP" | "CAD"
|
|
15
|
+
|
|
16
|
+
export interface MarketData {
|
|
17
|
+
fetchedAt: string
|
|
18
|
+
source: string
|
|
19
|
+
series: Record<MarketKey, MarketValue>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `market` mantém a mesma referência; `marketFetchedAt` e `MARKET_SOURCE` são live bindings (ESM)
|
|
23
|
+
export const market = {} as Record<MarketKey, MarketValue>
|
|
24
|
+
export let marketFetchedAt = ""
|
|
25
|
+
export let MARKET_SOURCE = ""
|
|
26
|
+
|
|
27
|
+
export function setMarketData(data: MarketData) {
|
|
28
|
+
Object.assign(market, data.series)
|
|
29
|
+
marketFetchedAt = data.fetchedAt
|
|
30
|
+
MARKET_SOURCE = data.source
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const sgsUrl = (code: number) => `https://www3.bcb.gov.br/sgspub/consultarvalores/consultarValoresSeries.do?method=consultarValores&optSelecionaSerie=${code}`
|
|
34
|
+
|
|
35
|
+
const value = (key: MarketKey) => {
|
|
36
|
+
const series = market[key]
|
|
37
|
+
if (!series) throw new Error("Dados de mercado não carregados: chame setMarketData() antes")
|
|
38
|
+
return series.value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Taxas em fração (0.1365 = 13,65% a.a.)
|
|
42
|
+
export const cdiAnnual = () => value("cdi") / 100
|
|
43
|
+
export const ipca12m = () => value("ipca12m") / 100
|
|
44
|
+
// Poupança: rendimento mensal composto para anual
|
|
45
|
+
export const savingsAnnual = () => Math.pow(1 + value("savingsMonthly") / 100, 12) - 1
|
|
46
|
+
|
|
47
|
+
const FX_KEY: Record<Currency, MarketKey> = { USD: "usd", EUR: "eur", GBP: "gbp", CAD: "cad" }
|
|
48
|
+
export const fxKey = (currency: Currency) => FX_KEY[currency]
|
|
49
|
+
export const fxRate = (currency: Currency) => value(FX_KEY[currency])
|
package/src/password.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { secureRandom, type Random } from "./random/index"
|
|
2
|
+
|
|
3
|
+
export const CHARSETS = {
|
|
4
|
+
lower: "abcdefghijklmnopqrstuvwxyz",
|
|
5
|
+
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
6
|
+
digits: "0123456789",
|
|
7
|
+
symbols: "!@#$%&*()-_=+[]{};:,.?/",
|
|
8
|
+
} as const
|
|
9
|
+
|
|
10
|
+
export type Charset = keyof typeof CHARSETS
|
|
11
|
+
// Caracteres fáceis de confundir ao ler/digitar
|
|
12
|
+
const AMBIGUOUS = /[Il1O0o]/g
|
|
13
|
+
|
|
14
|
+
export interface PasswordOptions {
|
|
15
|
+
length: number
|
|
16
|
+
sets: Charset[]
|
|
17
|
+
excludeAmbiguous?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function alphabetFor(set: Charset, excludeAmbiguous: boolean): string {
|
|
21
|
+
return excludeAmbiguous ? CHARSETS[set].replace(AMBIGUOUS, "") : CHARSETS[set]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generatePassword({ length, sets, excludeAmbiguous = false }: PasswordOptions, random: Random = secureRandom): string {
|
|
25
|
+
const active = sets.length > 0 ? sets : (["lower"] as Charset[])
|
|
26
|
+
const alphabets = active.map((set) => alphabetFor(set, excludeAmbiguous))
|
|
27
|
+
const all = alphabets.join("")
|
|
28
|
+
const size = Math.max(length, alphabets.length)
|
|
29
|
+
// um caractere de cada conjunto escolhido, o resto aleatório, depois embaralha (Fisher–Yates)
|
|
30
|
+
const chars = [...alphabets.map((a) => a[random(a.length)] ?? ""), ...Array.from({ length: size - alphabets.length }, () => all[random(all.length)] ?? "")]
|
|
31
|
+
for (let i = chars.length - 1; i > 0; i--) {
|
|
32
|
+
const j = random(i + 1)
|
|
33
|
+
;[chars[i], chars[j]] = [chars[j] ?? "", chars[i] ?? ""]
|
|
34
|
+
}
|
|
35
|
+
return chars.join("")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Entropia em bits assumindo escolha uniforme no alfabeto completo
|
|
39
|
+
export function entropyBits({ length, sets, excludeAmbiguous = false }: PasswordOptions): number {
|
|
40
|
+
const size = (sets.length > 0 ? sets : (["lower"] as Charset[])).reduce((acc, set) => acc + alphabetFor(set, excludeAmbiguous).length, 0)
|
|
41
|
+
return Math.round(length * Math.log2(size))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Strength = "weak" | "fair" | "strong" | "very-strong"
|
|
45
|
+
|
|
46
|
+
export function strength(bits: number): Strength {
|
|
47
|
+
if (bits < 50) return "weak"
|
|
48
|
+
if (bits < 70) return "fair"
|
|
49
|
+
if (bits < 100) return "strong"
|
|
50
|
+
return "very-strong"
|
|
51
|
+
}
|