@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.
@@ -0,0 +1,453 @@
1
+ import { degrees, PDFDocument, rgb, StandardFonts } from "pdf-lib"
2
+
3
+ export const TABS = ["organize", "stamp", "metadata", "images"] as const
4
+ export type PdfStudioTab = (typeof TABS)[number]
5
+
6
+ export const ROTATIONS = [0, 90, 180, 270] as const
7
+ export type Rotation = (typeof ROTATIONS)[number]
8
+
9
+ export const NUMBER_POSITIONS = ["bottom-center", "bottom-right", "bottom-left", "top-center", "top-right", "top-left"] as const
10
+ export type NumberPosition = (typeof NUMBER_POSITIONS)[number]
11
+
12
+ export const NUMBER_FORMATS = ["slash", "number", "label"] as const
13
+ export type NumberFormat = (typeof NUMBER_FORMATS)[number]
14
+
15
+ export const WATERMARK_COLORS = ["gray", "red", "blue", "black"] as const
16
+ export type WatermarkColor = (typeof WATERMARK_COLORS)[number]
17
+
18
+ export const IMAGE_FORMATS = ["png", "jpeg"] as const
19
+ export type ExportImageFormat = (typeof IMAGE_FORMATS)[number]
20
+
21
+ export const IMAGE_SCALES = [1, 2, 3] as const
22
+ export type ExportImageScale = (typeof IMAGE_SCALES)[number]
23
+
24
+ export interface PdfMetadata {
25
+ title: string
26
+ author: string
27
+ subject: string
28
+ keywords: string
29
+ creator: string
30
+ producer: string
31
+ }
32
+
33
+ export interface PdfPageInfo {
34
+ index: number
35
+ width: number
36
+ height: number
37
+ rotation: number
38
+ paper: string
39
+ }
40
+
41
+ export interface PdfInspection {
42
+ pageCount: number
43
+ pages: PdfPageInfo[]
44
+ metadata: PdfMetadata
45
+ formFieldCount: number
46
+ byteLength: number
47
+ }
48
+
49
+ export interface PagePlanItem {
50
+ id: string
51
+ sourceIndex: number
52
+ sourceName: string
53
+ pageIndex: number
54
+ rotationDelta: Rotation
55
+ width: number
56
+ height: number
57
+ originalRotation: number
58
+ }
59
+
60
+ export interface PdfStudioOptions {
61
+ pageRange?: string
62
+ rotation?: Rotation
63
+ pageNumbers?: boolean
64
+ numberPosition?: NumberPosition
65
+ numberFormat?: NumberFormat
66
+ numberStart?: number
67
+ numberLang?: "pt" | "en"
68
+ watermarkText?: string
69
+ watermarkOpacity?: number
70
+ watermarkAngle?: number
71
+ watermarkSize?: number
72
+ watermarkColor?: WatermarkColor
73
+ flattenForms?: boolean
74
+ clearMetadata?: boolean
75
+ metadata?: Partial<PdfMetadata>
76
+ pagePlan?: PagePlanItem[]
77
+ }
78
+
79
+ export const DEFAULT_STUDIO_OPTIONS: Required<Omit<PdfStudioOptions, "metadata" | "pagePlan">> = {
80
+ pageRange: "",
81
+ rotation: 0,
82
+ pageNumbers: false,
83
+ numberPosition: "bottom-center",
84
+ numberFormat: "slash",
85
+ numberStart: 1,
86
+ numberLang: "pt",
87
+ watermarkText: "",
88
+ watermarkOpacity: 22,
89
+ watermarkAngle: -35,
90
+ watermarkSize: 48,
91
+ watermarkColor: "gray",
92
+ flattenForms: false,
93
+ clearMetadata: false,
94
+ }
95
+
96
+ const COLOR_RGB: Record<WatermarkColor, [number, number, number]> = {
97
+ gray: [0.45, 0.48, 0.52],
98
+ red: [0.82, 0.16, 0.16],
99
+ blue: [0.12, 0.36, 0.78],
100
+ black: [0.08, 0.09, 0.12],
101
+ }
102
+
103
+ export function normalizeRotation(deg: number): Rotation {
104
+ const mod = ((Math.round(deg / 90) * 90) % 360 + 360) % 360
105
+ return (mod === 90 || mod === 180 || mod === 270 ? mod : 0) as Rotation
106
+ }
107
+
108
+ // Identifica formatos de papel conhecidos a partir das dimensões em pontos (1 pt = 1/72 pol.)
109
+ export function detectPaperSize(widthPt: number, heightPt: number): string {
110
+ const w = Math.min(widthPt, heightPt)
111
+ const h = Math.max(widthPt, heightPt)
112
+ const near = (a: number, b: number) => Math.abs(a - b) <= 6
113
+ if (near(w, 595) && near(h, 842)) return "A4"
114
+ if (near(w, 420) && near(h, 595)) return "A5"
115
+ if (near(w, 842) && near(h, 1191)) return "A3"
116
+ if (near(w, 612) && near(h, 792)) return "Letter"
117
+ if (near(w, 612) && near(h, 1008)) return "Legal"
118
+ return `${Math.round(widthPt)}×${Math.round(heightPt)} pt`
119
+ }
120
+
121
+ // Converte uma expressão como "1-3, 5, 4-2" em índices 0-based na ordem solicitada
122
+ export function parsePageRanges(spec: string, totalPages: number): number[] {
123
+ if (totalPages <= 0) return []
124
+ const trimmed = spec.trim()
125
+ if (!trimmed) return Array.from({ length: totalPages }, (_, i) => i)
126
+
127
+ const indices: number[] = []
128
+ const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean)
129
+ for (const part of parts) {
130
+ const rangeMatch = /^(\d+)\s*-\s*(\d*)$/.exec(part)
131
+ if (rangeMatch) {
132
+ const start = Number(rangeMatch[1])
133
+ const end = rangeMatch[2] ? Number(rangeMatch[2]) : totalPages
134
+ if (start < 1 || end < 1 || start > totalPages || end > totalPages) {
135
+ throw new Error(`Intervalo fora do limite (1-${totalPages}): ${part}`)
136
+ }
137
+ const step = start <= end ? 1 : -1
138
+ for (let p = start; step > 0 ? p <= end : p >= end; p += step) {
139
+ indices.push(p - 1)
140
+ }
141
+ continue
142
+ }
143
+ if (/^\d+$/.test(part)) {
144
+ const page = Number(part)
145
+ if (page < 1 || page > totalPages) {
146
+ throw new Error(`Página fora do limite (1-${totalPages}): ${part}`)
147
+ }
148
+ indices.push(page - 1)
149
+ continue
150
+ }
151
+ throw new Error(`Intervalo de páginas inválido: ${part}`)
152
+ }
153
+ return indices
154
+ }
155
+
156
+ export function formatPageNumber(index: number, total: number, start: number, format: NumberFormat, lang: "pt" | "en" = "pt"): string {
157
+ const current = index + start
158
+ const last = total - 1 + start
159
+ if (format === "number") return String(current)
160
+ if (format === "label") return lang === "en" ? `Page ${current} of ${last}` : `Página ${current} de ${last}`
161
+ return `${current} / ${last}`
162
+ }
163
+
164
+ export async function inspectPdf(bytes: Uint8Array): Promise<PdfInspection> {
165
+ const doc = await PDFDocument.load(bytes, { ignoreEncryption: true })
166
+ const pages = doc.getPages().map((page, index) => {
167
+ const { width, height } = page.getSize()
168
+ const rotation = normalizeRotation(page.getRotation().angle)
169
+ return {
170
+ index,
171
+ width: Math.round(width * 100) / 100,
172
+ height: Math.round(height * 100) / 100,
173
+ rotation,
174
+ paper: detectPaperSize(width, height),
175
+ }
176
+ })
177
+ let formFieldCount = 0
178
+ try {
179
+ formFieldCount = doc.getForm().getFields().length
180
+ } catch {
181
+ formFieldCount = 0
182
+ }
183
+ const keywordsRaw = doc.getKeywords()
184
+ return {
185
+ pageCount: pages.length,
186
+ pages,
187
+ metadata: {
188
+ title: doc.getTitle() ?? "",
189
+ author: doc.getAuthor() ?? "",
190
+ subject: doc.getSubject() ?? "",
191
+ keywords: keywordsRaw ?? "",
192
+ creator: doc.getCreator() ?? "",
193
+ producer: doc.getProducer() ?? "",
194
+ },
195
+ formFieldCount,
196
+ byteLength: bytes.byteLength,
197
+ }
198
+ }
199
+
200
+ export async function buildInitialPagePlan(sources: { name: string, bytes: Uint8Array }[]): Promise<PagePlanItem[]> {
201
+ const items: PagePlanItem[] = []
202
+ for (let s = 0; s < sources.length; s++) {
203
+ const source = sources[s]
204
+ if (!source) continue
205
+ const info = await inspectPdf(source.bytes)
206
+ for (const p of info.pages) {
207
+ items.push({
208
+ id: `${s}-${p.index}-${Math.random().toString(36).slice(2, 7)}`,
209
+ sourceIndex: s,
210
+ sourceName: source.name,
211
+ pageIndex: p.index,
212
+ rotationDelta: 0,
213
+ width: p.width,
214
+ height: p.height,
215
+ originalRotation: p.rotation,
216
+ })
217
+ }
218
+ }
219
+ return items
220
+ }
221
+
222
+ export async function processPdf(sources: Uint8Array[], rawOptions: PdfStudioOptions = {}): Promise<{ bytes: Uint8Array, inspection: PdfInspection }> {
223
+ if (sources.length === 0) throw new Error("Nenhum PDF de entrada fornecido")
224
+ const opts = { ...DEFAULT_STUDIO_OPTIONS, ...rawOptions }
225
+
226
+ const loadedDocs: PDFDocument[] = []
227
+ for (const src of sources) {
228
+ const doc = await PDFDocument.load(src, { ignoreEncryption: true })
229
+ if (opts.flattenForms) {
230
+ try {
231
+ doc.getForm().flatten()
232
+ } catch {
233
+ // Documento sem formulário válido: ignora
234
+ }
235
+ }
236
+ loadedDocs.push(doc)
237
+ }
238
+
239
+ // Monta a lista base de páginas (do pagePlan interativo ou de todos os documentos na ordem)
240
+ const basePlan: { sourceIndex: number, pageIndex: number, rotationDelta: Rotation }[] = []
241
+ if (rawOptions.pagePlan && rawOptions.pagePlan.length > 0) {
242
+ for (const item of rawOptions.pagePlan) {
243
+ basePlan.push({ sourceIndex: item.sourceIndex, pageIndex: item.pageIndex, rotationDelta: item.rotationDelta })
244
+ }
245
+ } else {
246
+ for (let s = 0; s < loadedDocs.length; s++) {
247
+ const doc = loadedDocs[s]
248
+ if (!doc) continue
249
+ const count = doc.getPageCount()
250
+ for (let p = 0; p < count; p++) {
251
+ basePlan.push({ sourceIndex: s, pageIndex: p, rotationDelta: 0 })
252
+ }
253
+ }
254
+ }
255
+
256
+ const selectedIndices = parsePageRanges(opts.pageRange, basePlan.length)
257
+ if (selectedIndices.length === 0) throw new Error("Nenhuma página selecionada")
258
+
259
+ const out = await PDFDocument.create()
260
+ const firstDoc = loadedDocs[0]
261
+
262
+ for (const idx of selectedIndices) {
263
+ const entry = basePlan[idx]
264
+ if (!entry) continue
265
+ const srcDoc = loadedDocs[entry.sourceIndex]
266
+ if (!srcDoc) continue
267
+ const [copied] = await out.copyPages(srcDoc, [entry.pageIndex])
268
+ if (!copied) continue
269
+ const currentAngle = copied.getRotation().angle
270
+ const nextAngle = normalizeRotation(currentAngle + entry.rotationDelta + opts.rotation)
271
+ copied.setRotation(degrees(nextAngle))
272
+ out.addPage(copied)
273
+ }
274
+
275
+ const outPages = out.getPages()
276
+ const watermarkText = opts.watermarkText.trim()
277
+
278
+ if (watermarkText) {
279
+ const boldFont = await out.embedFont(StandardFonts.HelveticaBold)
280
+ const [r, g, b] = COLOR_RGB[opts.watermarkColor] ?? COLOR_RGB.gray
281
+ const size = Math.min(140, Math.max(10, opts.watermarkSize))
282
+ const opacity = Math.min(1, Math.max(0.03, opts.watermarkOpacity / 100))
283
+ const angleRad = (opts.watermarkAngle * Math.PI) / 180
284
+
285
+ for (const page of outPages) {
286
+ const { width, height } = page.getSize()
287
+ const textWidth = boldFont.widthOfTextAtSize(watermarkText, size)
288
+ const textHeight = boldFont.heightAtSize(size)
289
+ const cx = width / 2
290
+ const cy = height / 2
291
+ const dx = (textWidth / 2) * Math.cos(angleRad) - (textHeight / 3) * Math.sin(angleRad)
292
+ const dy = (textWidth / 2) * Math.sin(angleRad) + (textHeight / 3) * Math.cos(angleRad)
293
+ page.drawText(watermarkText, {
294
+ x: cx - dx,
295
+ y: cy - dy,
296
+ size,
297
+ font: boldFont,
298
+ color: rgb(r, g, b),
299
+ opacity,
300
+ rotate: degrees(opts.watermarkAngle),
301
+ })
302
+ }
303
+ }
304
+
305
+ if (opts.pageNumbers) {
306
+ const font = await out.embedFont(StandardFonts.Helvetica)
307
+ const fontSize = 10
308
+ const margin = 24
309
+ const total = outPages.length
310
+
311
+ for (let i = 0; i < total; i++) {
312
+ const page = outPages[i]
313
+ if (!page) continue
314
+ const { width, height } = page.getSize()
315
+ const label = formatPageNumber(i, total, opts.numberStart, opts.numberFormat, opts.numberLang)
316
+ const textWidth = font.widthOfTextAtSize(label, fontSize)
317
+ const isTop = opts.numberPosition.startsWith("top-")
318
+ const y = isTop ? height - margin - fontSize : margin
319
+ let x = (width - textWidth) / 2
320
+ if (opts.numberPosition.endsWith("-left")) x = margin
321
+ else if (opts.numberPosition.endsWith("-right")) x = width - margin - textWidth
322
+
323
+ page.drawText(label, {
324
+ x: Math.max(8, x),
325
+ y: Math.max(8, y),
326
+ size: fontSize,
327
+ font,
328
+ color: rgb(0.25, 0.27, 0.32),
329
+ })
330
+ }
331
+ }
332
+
333
+ // Metadados: preserva os do primeiro PDF salvo se clearMetadata ou se sobrescritos
334
+ if (opts.clearMetadata) {
335
+ out.setTitle("")
336
+ out.setAuthor("")
337
+ out.setSubject("")
338
+ out.setKeywords([])
339
+ out.setCreator("")
340
+ out.setProducer("")
341
+ } else {
342
+ const meta = rawOptions.metadata
343
+ const title = meta?.title !== undefined ? meta.title : (firstDoc?.getTitle() ?? "")
344
+ const author = meta?.author !== undefined ? meta.author : (firstDoc?.getAuthor() ?? "")
345
+ const subject = meta?.subject !== undefined ? meta.subject : (firstDoc?.getSubject() ?? "")
346
+ const keywords = meta?.keywords !== undefined ? meta.keywords : (firstDoc?.getKeywords() ?? "")
347
+ const creator = meta?.creator !== undefined ? meta.creator : (firstDoc?.getCreator() ?? "")
348
+ if (title) out.setTitle(title)
349
+ if (author) out.setAuthor(author)
350
+ if (subject) out.setSubject(subject)
351
+ if (keywords) out.setKeywords(keywords.split(",").map((k) => k.trim()).filter(Boolean))
352
+ if (creator) out.setCreator(creator)
353
+ }
354
+
355
+ const bytes = await out.save()
356
+ const inspection = await inspectPdf(bytes)
357
+ return { bytes, inspection }
358
+ }
359
+
360
+ // Divide o documento em múltiplos PDFs (um por intervalo informado ou um por página se vazio)
361
+ export async function splitPdf(source: Uint8Array, rangeSpec = ""): Promise<{ name: string, bytes: Uint8Array, pages: number[] }[]> {
362
+ const srcDoc = await PDFDocument.load(source, { ignoreEncryption: true })
363
+ const total = srcDoc.getPageCount()
364
+ if (total === 0) return []
365
+
366
+ const groups: { label: string, indices: number[] }[] = []
367
+ const trimmed = rangeSpec.trim()
368
+ if (!trimmed) {
369
+ for (let i = 0; i < total; i++) {
370
+ groups.push({ label: `page-${i + 1}`, indices: [i] })
371
+ }
372
+ } else {
373
+ const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean)
374
+ for (const part of parts) {
375
+ const indices = parsePageRanges(part, total)
376
+ if (indices.length > 0) {
377
+ groups.push({ label: `pages-${part.replace(/\s+/g, "")}`, indices })
378
+ }
379
+ }
380
+ }
381
+
382
+ const results: { name: string, bytes: Uint8Array, pages: number[] }[] = []
383
+ for (const group of groups) {
384
+ const out = await PDFDocument.create()
385
+ const copied = await out.copyPages(srcDoc, group.indices)
386
+ for (const p of copied) out.addPage(p)
387
+ results.push({
388
+ name: `${group.label}.pdf`,
389
+ bytes: await out.save(),
390
+ pages: group.indices.map((i) => i + 1),
391
+ })
392
+ }
393
+ return results
394
+ }
395
+
396
+ // Gera um PDF de demonstração de 4 páginas (com texto, cores e um campo de formulário) para testes imediatos
397
+ export async function createSamplePdf(): Promise<Uint8Array> {
398
+ const doc = await PDFDocument.create()
399
+ doc.setTitle("The Code Codex — Sample Document")
400
+ doc.setAuthor("Douglas Capeletti")
401
+ doc.setSubject("PDF Studio Demo & Test Suite")
402
+ doc.setKeywords(["pdf", "sample", "codex"])
403
+ doc.setCreator("The Code Codex PDF Studio")
404
+
405
+ const font = await doc.embedFont(StandardFonts.Helvetica)
406
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold)
407
+ const form = doc.getForm()
408
+
409
+ const pagesSpec = [
410
+ { title: "1. Cover & Interactive Form", subtitle: "A4 Portrait · 595 × 842 pt", accent: rgb(0.12, 0.36, 0.78), width: 595.28, height: 841.89 },
411
+ { title: "2. Architecture Overview", subtitle: "A4 Portrait · Client-side PDF processing", accent: rgb(0.08, 0.52, 0.38), width: 595.28, height: 841.89 },
412
+ { title: "3. Wide Diagram Layout", subtitle: "A4 Landscape · 842 × 595 pt", accent: rgb(0.68, 0.28, 0.12), width: 841.89, height: 595.28 },
413
+ { title: "4. Appendix & Notes", subtitle: "A4 Portrait · Ready to split, rotate or stamp", accent: rgb(0.42, 0.2, 0.65), width: 595.28, height: 841.89 },
414
+ ]
415
+
416
+ pagesSpec.forEach((spec, idx) => {
417
+ const page = doc.addPage([spec.width, spec.height])
418
+ // Moldura e faixa superior
419
+ page.drawRectangle({ x: 36, y: 36, width: spec.width - 72, height: spec.height - 72, borderColor: rgb(0.82, 0.84, 0.88), borderWidth: 1.5 })
420
+ page.drawRectangle({ x: 36, y: spec.height - 108, width: spec.width - 72, height: 72, color: spec.accent })
421
+ page.drawText("THE CODE CODEX · PDF STUDIO", { x: 56, y: spec.height - 68, size: 10, font: bold, color: rgb(0.92, 0.95, 1) })
422
+ page.drawText(spec.title, { x: 56, y: spec.height - 92, size: 18, font: bold, color: rgb(1, 1, 1) })
423
+
424
+ page.drawText(spec.subtitle, { x: 56, y: spec.height - 140, size: 12, font: bold, color: rgb(0.2, 0.22, 0.28) })
425
+ page.drawText(`Original page #${idx + 1} of ${pagesSpec.length}. Try rotating, reordering, watermarking, or adding page numbers.`, {
426
+ x: 56,
427
+ y: spec.height - 165,
428
+ size: 10.5,
429
+ font,
430
+ color: rgb(0.35, 0.38, 0.44),
431
+ })
432
+
433
+ // Número grande no centro para facilitar visualização de rotação nas miniaturas
434
+ const bigNum = String(idx + 1)
435
+ const numWidth = bold.widthOfTextAtSize(bigNum, 96)
436
+ page.drawText(bigNum, {
437
+ x: (spec.width - numWidth) / 2,
438
+ y: spec.height / 2 - 36,
439
+ size: 96,
440
+ font: bold,
441
+ color: rgb(0.9, 0.92, 0.95),
442
+ })
443
+
444
+ if (idx === 0) {
445
+ page.drawText("Reviewer Note (Form Field):", { x: 56, y: spec.height - 210, size: 10, font: bold, color: rgb(0.2, 0.22, 0.28) })
446
+ const field = form.createTextField("reviewer_note")
447
+ field.setText("Approved — test form flattening in the Stamp tab")
448
+ field.addToPage(page, { x: 56, y: spec.height - 244, width: 320, height: 24 })
449
+ }
450
+ })
451
+
452
+ return doc.save()
453
+ }
@@ -0,0 +1,19 @@
1
+ // Fonte de aleatoriedade injetável: usa crypto no navegador e permite valores fixos nos testes
2
+ export type Random = (max: number) => number
3
+
4
+ // Inteiro uniforme em [0, max) sem viés de módulo (rejection sampling sobre Uint32)
5
+ export const secureRandom: Random = (max) => {
6
+ if (max <= 0) return 0
7
+ const limit = Math.floor(0x1_0000_0000 / max) * max
8
+ const buffer = new Uint32Array(1)
9
+ let value: number
10
+ do {
11
+ crypto.getRandomValues(buffer)
12
+ value = buffer[0] ?? 0
13
+ } while (value >= limit)
14
+ return value % max
15
+ }
16
+
17
+ export function pick<T>(items: readonly T[], random: Random = secureRandom): T {
18
+ return items[random(items.length)] as T
19
+ }