@thecodecodex/tools 0.0.0-stage → 0.1.0

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,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])
@@ -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
+ }