@sirdaspdf/core 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,177 @@
1
+ const BULLET = /^\s*([•●▪◦‣∙·\-–*])\s+/;
2
+ const NUMBERED = /^\s*(\d{1,3}|[a-zA-Z])[.)]\s+/;
3
+ /** Líneas tipo formulario: "Paciente: …", "Fecha de ingreso: …". */
4
+ const KEY_VALUE = /^([^:]{2,40}):\s+\S/;
5
+ function isKeyValue(text) {
6
+ const m = text.match(KEY_VALUE);
7
+ return !!m && m[1].trim().split(/\s+/).length <= 7 && text.length < 160;
8
+ }
9
+ function mode(values) {
10
+ const counts = new Map();
11
+ for (const v of values)
12
+ counts.set(v, (counts.get(v) ?? 0) + 1);
13
+ let best = values[0] ?? 10;
14
+ let max = 0;
15
+ for (const [k, c] of counts)
16
+ if (c > max)
17
+ [best, max] = [k, c];
18
+ return best;
19
+ }
20
+ /** Extrae las líneas de texto con tamaño y posición de un documento abierto. */
21
+ export async function extractLines(pdf, onProgress) {
22
+ const lines = [];
23
+ for (let p = 1; p <= pdf.numPages; p++) {
24
+ const page = await pdf.getPage(p);
25
+ const content = await page.getTextContent();
26
+ const pageHeight = page.getViewport({ scale: 1 }).height || 792;
27
+ const styles = content.styles;
28
+ const rows = new Map();
29
+ for (const it of content.items) {
30
+ if (!("str" in it) || !it.str)
31
+ continue;
32
+ const t = it.transform;
33
+ const size = Math.round(Math.hypot(t[2], t[3]) * 2) / 2 || Math.round(it.height);
34
+ const y = Math.round(t[5] / 2) * 2;
35
+ const font = `${it.fontName} ${styles[it.fontName]?.fontFamily ?? ""}`;
36
+ const bold = /bold|black|heavy|semibold/i.test(font);
37
+ const row = rows.get(y) ?? [];
38
+ row.push({ x: t[4], str: it.str, w: it.width, size, bold });
39
+ rows.set(y, row);
40
+ }
41
+ const ys = [...rows.keys()].sort((a, b) => b - a);
42
+ for (const y of ys) {
43
+ const row = rows.get(y).sort((a, b) => a.x - b.x);
44
+ let text = "";
45
+ let prevEnd = -Infinity;
46
+ for (const r of row) {
47
+ const gap = r.x - prevEnd;
48
+ if (text && gap > r.size * 0.15 && !text.endsWith(" ") && !r.str.startsWith(" "))
49
+ text += " ";
50
+ text += r.str;
51
+ prevEnd = r.x + r.w;
52
+ }
53
+ text = text.replace(/\s+/g, " ").trim();
54
+ if (!text)
55
+ continue;
56
+ const size = Math.max(...row.map((r) => r.size));
57
+ const bold = row.filter((r) => r.bold).reduce((s, r) => s + r.str.length, 0) > text.length * 0.6;
58
+ lines.push({ text, size, y, x: row[0].x, page: p, bold, rel: y / pageHeight });
59
+ }
60
+ page.cleanup();
61
+ onProgress?.(p / pdf.numPages);
62
+ }
63
+ return removeRepeated(lines, pdf.numPages);
64
+ }
65
+ /** Quita encabezados/pies repetidos y números de página sueltos. */
66
+ function removeRepeated(lines, pages) {
67
+ const norm = (s) => s.toLowerCase().replace(/\d+/g, "#");
68
+ const freq = new Map();
69
+ for (const l of lines) {
70
+ const k = norm(l.text);
71
+ if (k.length > 80)
72
+ continue;
73
+ const set = freq.get(k) ?? new Set();
74
+ set.add(l.page);
75
+ freq.set(k, set);
76
+ }
77
+ return lines.filter((l) => {
78
+ if (/^(p[aá]g(ina)?\.?\s*)?\d{1,4}(\s*(de|of|\/)\s*\d{1,4})?$/i.test(l.text))
79
+ return false;
80
+ const margin = l.rel < 0.1 || l.rel > 0.92;
81
+ if (pages >= 3 && margin) {
82
+ const s = freq.get(norm(l.text));
83
+ if (s && s.size >= Math.max(3, pages * 0.6))
84
+ return false;
85
+ }
86
+ return true;
87
+ });
88
+ }
89
+ /** Convierte líneas ya extraídas en Markdown (encabezados, listas, párrafos, campos). */
90
+ export function linesToMarkdown(lines) {
91
+ if (!lines.length)
92
+ return "";
93
+ const body = mode(lines.map((l) => l.size));
94
+ const out = [];
95
+ let para = "";
96
+ let prev = null;
97
+ let inKv = false;
98
+ const flush = () => {
99
+ if (para)
100
+ out.push(para.trim(), "");
101
+ para = "";
102
+ };
103
+ const closeKv = () => {
104
+ if (inKv)
105
+ out.push("");
106
+ inKv = false;
107
+ };
108
+ for (const l of lines) {
109
+ const ratio = l.size / body;
110
+ const short = l.text.length < 90 && !/[.,;:]$/.test(l.text);
111
+ let heading = 0;
112
+ if (ratio >= 1.6)
113
+ heading = 1;
114
+ else if (ratio >= 1.3)
115
+ heading = 2;
116
+ else if ((ratio >= 1.12 || (l.bold && l.text.length < 70)) && short)
117
+ heading = 3;
118
+ if (!heading && isKeyValue(l.text)) {
119
+ flush();
120
+ out.push(`${l.text} `);
121
+ inKv = true;
122
+ prev = l;
123
+ continue;
124
+ }
125
+ closeKv();
126
+ if (heading) {
127
+ flush();
128
+ out.push(`${"#".repeat(heading)} ${l.text}`, "");
129
+ prev = l;
130
+ continue;
131
+ }
132
+ if (BULLET.test(l.text)) {
133
+ flush();
134
+ out.push(`- ${l.text.replace(BULLET, "")}`);
135
+ prev = l;
136
+ continue;
137
+ }
138
+ if (NUMBERED.test(l.text) && l.text.length < 300) {
139
+ flush();
140
+ const m = l.text.match(NUMBERED);
141
+ out.push(`${/\d/.test(m[1]) ? m[1] : "-"}. ${l.text.replace(NUMBERED, "")}`.replace(/^-\. /, "- "));
142
+ prev = l;
143
+ continue;
144
+ }
145
+ const newBlock = !prev ||
146
+ prev.page !== l.page ||
147
+ Math.abs(prev.y - l.y) > l.size * 1.9 ||
148
+ Math.abs(prev.size - l.size) > 0.6;
149
+ const continuesList = out.length && /^(- |\d+\. )/.test(out[out.length - 1]) && !newBlock && !para;
150
+ if (continuesList) {
151
+ out[out.length - 1] += ` ${l.text}`;
152
+ }
153
+ else {
154
+ if (newBlock)
155
+ flush();
156
+ if (para.endsWith("-") && /^[a-záéíóúñ]/.test(l.text))
157
+ para = para.slice(0, -1) + l.text;
158
+ else
159
+ para += (para ? " " : "") + l.text;
160
+ }
161
+ prev = l;
162
+ }
163
+ flush();
164
+ closeKv();
165
+ return out
166
+ .join("\n")
167
+ .replace(/\n{3,}/g, "\n\n")
168
+ .trim();
169
+ }
170
+ /** Documento PDF.js abierto → Markdown. */
171
+ export async function pdfToMarkdown(pdf, onProgress) {
172
+ return linesToMarkdown(await extractLines(pdf, onProgress));
173
+ }
174
+ /** Estimación rápida de tokens (~4 caracteres por token). */
175
+ export function estimateTokens(text) {
176
+ return Math.ceil(text.length / 4);
177
+ }
@@ -0,0 +1 @@
1
+ export declare function mergePdfs(buffers: ArrayBuffer[]): Promise<Uint8Array>;
package/dist/merge.js ADDED
@@ -0,0 +1,10 @@
1
+ import { PDFDocument } from "pdf-lib";
2
+ export async function mergePdfs(buffers) {
3
+ const out = await PDFDocument.create();
4
+ for (const buf of buffers) {
5
+ const src = await PDFDocument.load(buf, { ignoreEncryption: false });
6
+ const pages = await out.copyPages(src, src.getPageIndices());
7
+ pages.forEach((p) => out.addPage(p));
8
+ }
9
+ return out.save({ useObjectStreams: true });
10
+ }
package/dist/ocr.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Line } from "./markdown.js";
2
+ /** Caja de una línea reconocida, en píxeles de la imagen (origen arriba-izquierda). */
3
+ export interface OcrBox {
4
+ text: string;
5
+ x0: number;
6
+ y0: number;
7
+ x1: number;
8
+ y1: number;
9
+ }
10
+ export interface OcrPageInput {
11
+ /** Número de página, base 1. */
12
+ page: number;
13
+ /** Alto de la imagen rasterizada, en píxeles. */
14
+ heightPx: number;
15
+ /** Escala usada al rasterizar (1 = 72 dpi). Convierte píxeles a puntos PDF. */
16
+ scale: number;
17
+ boxes: OcrBox[];
18
+ }
19
+ /** Convierte las cajas de una página OCR en líneas del pipeline de Markdown. */
20
+ export declare function ocrPageToLines({ page, heightPx, scale, boxes }: OcrPageInput): Line[];
21
+ /** Une varias páginas OCR en una sola lista de líneas, en orden de lectura. */
22
+ export declare function ocrPagesToLines(pages: OcrPageInput[]): Line[];
23
+ /** ¿El texto extraído es tan pobre que conviene ofrecer OCR? */
24
+ export declare function looksScanned(text: string, pageCount: number): boolean;
package/dist/ocr.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * El alto de una caja varía según tenga tildes o letras con cola ("Hola" vs
3
+ * "Hpqy"), así que agrupamos tamaños parecidos en cubos. Sin esto, el cálculo
4
+ * de "tamaño de cuerpo" del Markdown vería decenas de tamaños distintos y no
5
+ * distinguiría un encabezado de un párrafo.
6
+ */
7
+ const BUCKET = 2;
8
+ const bucket = (n) => Math.max(BUCKET, Math.round(n / BUCKET) * BUCKET);
9
+ /** Convierte las cajas de una página OCR en líneas del pipeline de Markdown. */
10
+ export function ocrPageToLines({ page, heightPx, scale, boxes }) {
11
+ const heightPt = heightPx / scale;
12
+ const lines = [];
13
+ for (const b of boxes) {
14
+ const text = b.text.replace(/\s+/g, " ").trim();
15
+ if (!text)
16
+ continue;
17
+ // PDF mide la y desde abajo; el OCR desde arriba. Invertimos para que el
18
+ // resto del pipeline (saltos de bloque, encabezados/pies) funcione igual.
19
+ const y = (heightPx - b.y1) / scale;
20
+ lines.push({
21
+ text,
22
+ size: bucket((b.y1 - b.y0) / scale),
23
+ y,
24
+ x: b.x0 / scale,
25
+ page,
26
+ // El OCR no distingue negritas de forma fiable: lo dejamos en falso y
27
+ // que el tamaño decida los encabezados.
28
+ bold: false,
29
+ rel: heightPt > 0 ? y / heightPt : 0,
30
+ });
31
+ }
32
+ return lines.sort((a, b) => b.y - a.y);
33
+ }
34
+ /** Une varias páginas OCR en una sola lista de líneas, en orden de lectura. */
35
+ export function ocrPagesToLines(pages) {
36
+ return pages
37
+ .slice()
38
+ .sort((a, b) => a.page - b.page)
39
+ .flatMap(ocrPageToLines);
40
+ }
41
+ /** ¿El texto extraído es tan pobre que conviene ofrecer OCR? */
42
+ export function looksScanned(text, pageCount) {
43
+ const clean = text.replace(/\s+/g, "");
44
+ // Menos de 40 caracteres útiles por página es, casi siempre, un escaneo.
45
+ return clean.length < Math.max(40, pageCount * 40);
46
+ }
@@ -0,0 +1,5 @@
1
+ export interface PageEdit {
2
+ index: number;
3
+ rotation: number;
4
+ }
5
+ export declare function organizePdf(buf: ArrayBuffer, edits: PageEdit[]): Promise<Uint8Array>;
@@ -0,0 +1,13 @@
1
+ import { PDFDocument, degrees } from "pdf-lib";
2
+ export async function organizePdf(buf, edits) {
3
+ const src = await PDFDocument.load(buf);
4
+ const out = await PDFDocument.create();
5
+ const pages = await out.copyPages(src, edits.map((e) => e.index));
6
+ pages.forEach((p, i) => {
7
+ const extra = edits[i].rotation % 360;
8
+ if (extra)
9
+ p.setRotation(degrees((p.getRotation().angle + extra) % 360));
10
+ out.addPage(p);
11
+ });
12
+ return out.save({ useObjectStreams: true });
13
+ }
@@ -0,0 +1,68 @@
1
+ export interface ParagraphOptions {
2
+ /**
3
+ * Palabras mínimas para considerar un párrafo. Por debajo son títulos,
4
+ * números de página y firmas sueltas, que se repiten por naturaleza y
5
+ * quitarlos rompería la estructura del documento.
6
+ */
7
+ minWords?: number;
8
+ /** Apariciones a partir de las cuales se marca como repetido. */
9
+ minRepeats?: number;
10
+ /**
11
+ * Marcar solo lo que se repite en documentos distintos. Un párrafo repetido
12
+ * dentro de un mismo documento suele ser legítimo (una lista, un estribillo);
13
+ * el que sale en cinco archivos distintos es plantilla.
14
+ */
15
+ acrossDocumentsOnly?: boolean;
16
+ }
17
+ export interface RepeatedParagraph {
18
+ /** El texto tal y como apareció la primera vez. */
19
+ text: string;
20
+ /** Veces que aparece en todo el corpus. */
21
+ occurrences: number;
22
+ /** En cuántos documentos distintos. */
23
+ documents: number;
24
+ /** Nombres de esos documentos, hasta diez. */
25
+ examples: string[];
26
+ words: number;
27
+ }
28
+ export interface ParagraphReport {
29
+ repeated: RepeatedParagraph[];
30
+ /** Párrafos totales del corpus. */
31
+ total: number;
32
+ /** Párrafos que son una repetición (sin contar la primera aparición). */
33
+ redundant: number;
34
+ /** Fracción del texto, en palabras, que ocupan esas repeticiones. */
35
+ redundantWordRatio: number;
36
+ }
37
+ /**
38
+ * Normalización de CCNet: minúsculas, dígitos a cero, sin acentos y sin
39
+ * puntuación.
40
+ *
41
+ * Los dígitos van a cero para que «Factura 1042» y «Factura 1043» cuenten como
42
+ * el mismo párrafo de plantilla, que es justo lo que son. Es agresivo, y por
43
+ * eso el umbral de palabras mínimas existe: sobre textos cortos borraría
44
+ * distinciones reales.
45
+ */
46
+ export declare function normalizeParagraph(text: string): string;
47
+ /** Parte un documento en párrafos por línea en blanco. */
48
+ export declare function splitParagraphs(markdown: string): string[];
49
+ export interface ParagraphDocument {
50
+ name: string;
51
+ markdown: string;
52
+ }
53
+ /**
54
+ * Encuentra los párrafos repetidos en un corpus. No modifica nada: devuelve el
55
+ * informe para que quien lo lea decida.
56
+ */
57
+ export declare function findRepeatedParagraphs(docs: ParagraphDocument[], options?: ParagraphOptions): ParagraphReport;
58
+ /**
59
+ * Aplica el informe: devuelve los documentos sin las repeticiones, conservando
60
+ * siempre la primera aparición de cada párrafo.
61
+ *
62
+ * Es un paso aparte y explícito. El informe se puede leer y decidir; esto ya
63
+ * descarta contenido.
64
+ */
65
+ export declare function stripRepeatedParagraphs(docs: ParagraphDocument[], options?: ParagraphOptions): {
66
+ documents: ParagraphDocument[];
67
+ removed: number;
68
+ };
@@ -0,0 +1,138 @@
1
+ // Deduplicación exacta de párrafo, al estilo de CCNet.
2
+ //
3
+ // CCNet (arXiv:1911.00359) midió que los párrafos duplicados eran el 70 % del
4
+ // texto de su corpus. En documentos de oficina el patrón es aún más nítido:
5
+ // el encabezado con el membrete, el pie con «este documento es confidencial»,
6
+ // la cláusula de tratamiento de datos que va idéntica en cada contrato. Eso se
7
+ // aprende de memoria y no aporta nada.
8
+ //
9
+ // Su receta, que seguimos: normalizar el párrafo (minúsculas, dígitos a cero,
10
+ // sin puntuación ni acentos), hashear, y quedarse con la primera aparición.
11
+ // Cambiamos una cosa a propósito: CCNet usa los primeros 64 bits de SHA-1 y
12
+ // aquí se usan dos FNV-1a de 32 bits con semillas distintas. Es síncrono, sin
13
+ // dependencias y funciona igual en el navegador y en Node; la probabilidad de
14
+ // colisión a esta escala es la misma en la práctica.
15
+ //
16
+ // Y una diferencia de diseño, no de algoritmo: esto **informa** antes de
17
+ // borrar. Quitar contenido del corpus de alguien sin que lo vea es la clase de
18
+ // cosa que este producto no hace.
19
+ import { fnv1a } from "./text.js";
20
+ const DEFAULTS = { minWords: 5, minRepeats: 2, acrossDocumentsOnly: true };
21
+ /**
22
+ * Normalización de CCNet: minúsculas, dígitos a cero, sin acentos y sin
23
+ * puntuación.
24
+ *
25
+ * Los dígitos van a cero para que «Factura 1042» y «Factura 1043» cuenten como
26
+ * el mismo párrafo de plantilla, que es justo lo que son. Es agresivo, y por
27
+ * eso el umbral de palabras mínimas existe: sobre textos cortos borraría
28
+ * distinciones reales.
29
+ */
30
+ export function normalizeParagraph(text) {
31
+ return text
32
+ .toLowerCase()
33
+ .normalize("NFD")
34
+ .replace(/[̀-ͯ]/g, "")
35
+ .replace(/\d/g, "0")
36
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ }
40
+ /** Hash de 64 bits como dos mitades de 32, en hexadecimal. */
41
+ function hash64(s) {
42
+ const a = fnv1a(s);
43
+ const b = fnv1a(`␟${s}`);
44
+ return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
45
+ }
46
+ /** Parte un documento en párrafos por línea en blanco. */
47
+ export function splitParagraphs(markdown) {
48
+ return markdown
49
+ .split(/\n\s*\n/)
50
+ .map((p) => p.trim())
51
+ .filter((p) => p !== "");
52
+ }
53
+ const wordCount = (s) => (s === "" ? 0 : s.split(" ").length);
54
+ /**
55
+ * Encuentra los párrafos repetidos en un corpus. No modifica nada: devuelve el
56
+ * informe para que quien lo lea decida.
57
+ */
58
+ export function findRepeatedParagraphs(docs, options = {}) {
59
+ const { minWords, minRepeats, acrossDocumentsOnly } = { ...DEFAULTS, ...options };
60
+ const seen = new Map();
61
+ let total = 0;
62
+ let totalWords = 0;
63
+ for (const doc of docs) {
64
+ for (const raw of splitParagraphs(doc.markdown)) {
65
+ const normalized = normalizeParagraph(raw);
66
+ const words = wordCount(normalized);
67
+ total++;
68
+ totalWords += words;
69
+ if (words < minWords)
70
+ continue;
71
+ const key = hash64(normalized);
72
+ const entry = seen.get(key);
73
+ if (entry) {
74
+ entry.occurrences++;
75
+ entry.docs.add(doc.name);
76
+ }
77
+ else {
78
+ seen.set(key, { text: raw, occurrences: 1, docs: new Set([doc.name]), words });
79
+ }
80
+ }
81
+ }
82
+ const repeated = [];
83
+ let redundant = 0;
84
+ let redundantWords = 0;
85
+ for (const entry of seen.values()) {
86
+ if (entry.occurrences < minRepeats)
87
+ continue;
88
+ if (acrossDocumentsOnly && entry.docs.size < 2)
89
+ continue;
90
+ // La primera aparición se conserva: solo las siguientes son redundantes.
91
+ const extra = entry.occurrences - 1;
92
+ redundant += extra;
93
+ redundantWords += extra * entry.words;
94
+ repeated.push({
95
+ text: entry.text,
96
+ occurrences: entry.occurrences,
97
+ documents: entry.docs.size,
98
+ examples: [...entry.docs].slice(0, 10),
99
+ words: entry.words,
100
+ });
101
+ }
102
+ repeated.sort((a, b) => b.occurrences * b.words - a.occurrences * a.words);
103
+ return {
104
+ repeated,
105
+ total,
106
+ redundant,
107
+ redundantWordRatio: totalWords === 0 ? 0 : redundantWords / totalWords,
108
+ };
109
+ }
110
+ /**
111
+ * Aplica el informe: devuelve los documentos sin las repeticiones, conservando
112
+ * siempre la primera aparición de cada párrafo.
113
+ *
114
+ * Es un paso aparte y explícito. El informe se puede leer y decidir; esto ya
115
+ * descarta contenido.
116
+ */
117
+ export function stripRepeatedParagraphs(docs, options = {}) {
118
+ const report = findRepeatedParagraphs(docs, options);
119
+ const targets = new Set(report.repeated.map((r) => hash64(normalizeParagraph(r.text))));
120
+ const kept = new Set();
121
+ let removed = 0;
122
+ const documents = docs.map((doc) => {
123
+ const out = [];
124
+ for (const raw of splitParagraphs(doc.markdown)) {
125
+ const key = hash64(normalizeParagraph(raw));
126
+ if (targets.has(key)) {
127
+ if (kept.has(key)) {
128
+ removed++;
129
+ continue;
130
+ }
131
+ kept.add(key);
132
+ }
133
+ out.push(raw);
134
+ }
135
+ return { name: doc.name, markdown: out.join("\n\n") };
136
+ });
137
+ return { documents, removed };
138
+ }
@@ -0,0 +1,2 @@
1
+ /** "1-3, 5, 8-10" -> grupos de índices base 0. Vacío = cada página por separado. */
2
+ export declare function parseRanges(input: string, pageCount: number): number[][];
package/dist/ranges.js ADDED
@@ -0,0 +1,23 @@
1
+ // Utilidad pura (sin dependencias pesadas) para interpretar rangos de páginas.
2
+ // Vive aparte de split.ts para no arrastrar pdf-lib al bundle de la página.
3
+ /** "1-3, 5, 8-10" -> grupos de índices base 0. Vacío = cada página por separado. */
4
+ export function parseRanges(input, pageCount) {
5
+ const clean = input.trim();
6
+ if (!clean)
7
+ return Array.from({ length: pageCount }, (_, i) => [i]);
8
+ const groups = [];
9
+ for (const part of clean.split(/[,;]+/)) {
10
+ const p = part.trim();
11
+ if (!p)
12
+ continue;
13
+ const m = p.match(/^(\d+)\s*(?:-\s*(\d+|fin|end))?$/i);
14
+ if (!m)
15
+ throw new Error(`Rango inválido: ${p}`);
16
+ const start = Number(m[1]);
17
+ const end = m[2] ? (/^\d+$/.test(m[2]) ? Number(m[2]) : pageCount) : start;
18
+ if (start < 1 || end > pageCount || start > end)
19
+ throw new Error(`Rango fuera del documento: ${p}`);
20
+ groups.push(Array.from({ length: end - start + 1 }, (_, i) => start - 1 + i));
21
+ }
22
+ return groups;
23
+ }
@@ -0,0 +1,3 @@
1
+ export { parseRanges } from "./ranges.js";
2
+ export declare function splitPdf(buf: ArrayBuffer, groups: number[][]): Promise<Uint8Array[]>;
3
+ export declare function pageCount(buf: ArrayBuffer): Promise<number>;
package/dist/split.js ADDED
@@ -0,0 +1,17 @@
1
+ import { PDFDocument } from "pdf-lib";
2
+ export { parseRanges } from "./ranges.js";
3
+ export async function splitPdf(buf, groups) {
4
+ const src = await PDFDocument.load(buf);
5
+ const results = [];
6
+ for (const g of groups) {
7
+ const out = await PDFDocument.create();
8
+ const pages = await out.copyPages(src, g);
9
+ pages.forEach((p) => out.addPage(p));
10
+ results.push(await out.save({ useObjectStreams: true }));
11
+ }
12
+ return results;
13
+ }
14
+ export async function pageCount(buf) {
15
+ const src = await PDFDocument.load(buf);
16
+ return src.getPageCount();
17
+ }
@@ -0,0 +1,44 @@
1
+ import type { PDFDocumentProxy } from "pdfjs-dist";
2
+ /** Un trozo de texto con su posición en la página, en puntos PDF. */
3
+ export interface Cell {
4
+ text: string;
5
+ x0: number;
6
+ x1: number;
7
+ y: number;
8
+ page: number;
9
+ }
10
+ export interface Table {
11
+ page: number;
12
+ rows: string[][];
13
+ /** Número de columnas detectadas. */
14
+ columns: number;
15
+ }
16
+ export interface DetectOptions {
17
+ /** Filas mínimas para considerarlo una tabla (incluye el encabezado). */
18
+ minRows?: number;
19
+ /** Columnas mínimas. */
20
+ minCols?: number;
21
+ /** Separación vertical máxima entre filas de la misma tabla, en puntos. */
22
+ maxRowGap?: number;
23
+ /** Un corte vale si está en blanco en al menos esta fracción de filas. */
24
+ minSeparatorSupport?: number;
25
+ }
26
+ type Progress = (ratio: number) => void;
27
+ /** Extrae cada trozo de texto con sus coordenadas, sin fusionar filas. */
28
+ export declare function extractCells(pdf: PDFDocumentProxy, onProgress?: Progress): Promise<Cell[]>;
29
+ /** Encuentra las tablas de un documento a partir de sus trozos de texto. */
30
+ export declare function detectTables(cells: Cell[], options?: DetectOptions): Table[];
31
+ /** Documento abierto → tablas detectadas. */
32
+ export declare function pdfToTables(pdf: PDFDocumentProxy, options?: DetectOptions, onProgress?: Progress): Promise<Table[]>;
33
+ export interface CsvOptions {
34
+ delimiter?: string;
35
+ /**
36
+ * Antepone la marca de orden de bytes. Excel en Windows la necesita para
37
+ * abrir UTF-8 con tildes correctamente.
38
+ */
39
+ bom?: boolean;
40
+ }
41
+ export declare function tableToCsv(table: Table, { delimiter, bom }?: CsvOptions): string;
42
+ /** Tabla en Markdown, usando la primera fila como encabezado. */
43
+ export declare function tableToMarkdown(table: Table): string;
44
+ export {};