utn-mcp 0.2.0 → 0.6.7

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.
@@ -1,68 +1,68 @@
1
1
  import { htmlToText } from "../html.js";
2
- import { abrirOffice, formatoOffice } from "./office.js";
3
- import { abrirPdf } from "./pdf.js";
4
- export class FormatoNoSoportado extends Error {
5
- formato;
6
- constructor(formato) {
7
- super(`No sé leer archivos ${formato}.`);
8
- this.formato = formato;
9
- this.name = "FormatoNoSoportado";
2
+ import { detectOfficeFormat, openOffice } from "./office.js";
3
+ import { openPdf } from "./pdf.js";
4
+ export class UnsupportedFormat extends Error {
5
+ format;
6
+ constructor(format) {
7
+ super(`No sé leer archivos ${format}.`);
8
+ this.format = format;
9
+ this.name = "UnsupportedFormat";
10
10
  }
11
11
  }
12
12
  /** Tamaño de cada fragmento cuando un texto largo viene de una sola pieza (un docx, un html). */
13
- export const FRAGMENTO = 15_000;
14
- const IMAGENES = [
13
+ export const CHUNK_SIZE = 15_000;
14
+ const IMAGE_SIGNATURES = [
15
15
  [[0x89, 0x50, 0x4e, 0x47], "image/png"],
16
16
  [[0xff, 0xd8, 0xff], "image/jpeg"],
17
17
  [[0x47, 0x49, 0x46, 0x38], "image/gif"],
18
18
  ];
19
- const empieza = (bytes, firma) => firma.every((b, i) => bytes[i] === b);
19
+ const hasSignature = (bytes, signature) => signature.every((b, i) => bytes[i] === b);
20
20
  /** Reconoce el formato por los primeros bytes; el nombre solo se usa para los de texto plano. */
21
- export async function abrirDocumento(bytes, nombre) {
22
- if (empieza(bytes, [0x25, 0x50, 0x44, 0x46]))
23
- return abrirPdf(bytes);
24
- for (const [firma, mimeType] of IMAGENES) {
25
- if (empieza(bytes, firma))
26
- return deTexto("imagen", "imagen", [{ imagen: bytes, mimeType }]);
21
+ export async function openDocument(bytes, name) {
22
+ if (hasSignature(bytes, [0x25, 0x50, 0x44, 0x46]))
23
+ return openPdf(bytes);
24
+ for (const [signature, mimeType] of IMAGE_SIGNATURES) {
25
+ if (hasSignature(bytes, signature))
26
+ return inMemory("imagen", "imagen", [{ image: bytes, mimeType }]);
27
27
  }
28
- if (empieza(bytes, [0x50, 0x4b, 0x03, 0x04])) {
29
- const formato = formatoOffice(bytes);
30
- if (!formato)
31
- throw new FormatoNoSoportado("comprimidos (.zip)");
32
- return abrirOffice(bytes, formato);
28
+ if (hasSignature(bytes, [0x50, 0x4b, 0x03, 0x04])) {
29
+ const format = detectOfficeFormat(bytes);
30
+ if (!format)
31
+ throw new UnsupportedFormat("comprimidos (.zip)");
32
+ return openOffice(bytes, format);
33
33
  }
34
- if (empieza(bytes, [0xd0, 0xcf, 0x11, 0xe0])) {
35
- throw new FormatoNoSoportado("de Office viejo (.doc, .xls, .ppt, .pps)");
34
+ if (hasSignature(bytes, [0xd0, 0xcf, 0x11, 0xe0])) {
35
+ throw new UnsupportedFormat("de Office viejo (.doc, .xls, .ppt, .pps)");
36
36
  }
37
- const extension = nombre.toLowerCase().match(/\.(\w+)$/)?.[1] ?? "";
37
+ const extension = name.toLowerCase().match(/\.(\w+)$/)?.[1] ?? "";
38
38
  if (["txt", "csv", "md", "html", "htm"].includes(extension)) {
39
- const texto = new TextDecoder().decode(bytes);
40
- const plano = extension.startsWith("htm") ? htmlToText(texto) : texto;
41
- return deTexto(extension, "fragmento", fragmentar(plano).map((t) => ({ texto: t })));
39
+ const text = new TextDecoder().decode(bytes);
40
+ const plain = extension.startsWith("htm") ? htmlToText(text) : text;
41
+ return inMemory(extension, "fragmento", splitText(plain).map((t) => ({ text: t })));
42
42
  }
43
- throw new FormatoNoSoportado(extension ? `.${extension}` : "de este tipo");
43
+ throw new UnsupportedFormat(extension ? `.${extension}` : "de este tipo");
44
44
  }
45
45
  /** Un documento que ya está entero en memoria. */
46
- export function deTexto(formato, unidad, partes) {
46
+ export function inMemory(format, unit, parts) {
47
47
  return {
48
- formato,
49
- unidad,
50
- total: partes.length,
51
- parte: async (n) => partes[n - 1],
52
- cerrar: async () => { },
48
+ format,
49
+ unit,
50
+ total: parts.length,
51
+ part: async (n) => parts[n - 1],
52
+ close: async () => { },
53
53
  };
54
54
  }
55
55
  /** Corta un texto largo en pedazos de hasta `max` caracteres, prefiriendo cortar en un salto de línea. */
56
- export function fragmentar(texto, max = FRAGMENTO) {
57
- const fragmentos = [];
58
- let resto = texto.trim();
59
- while (resto.length > max) {
60
- const corte = resto.lastIndexOf("\n", max);
61
- const fin = corte > max / 2 ? corte : max;
62
- fragmentos.push(resto.slice(0, fin).trim());
63
- resto = resto.slice(fin).trim();
56
+ export function splitText(text, max = CHUNK_SIZE) {
57
+ const chunks = [];
58
+ let rest = text.trim();
59
+ while (rest.length > max) {
60
+ const cut = rest.lastIndexOf("\n", max);
61
+ const end = cut > max / 2 ? cut : max;
62
+ chunks.push(rest.slice(0, end).trim());
63
+ rest = rest.slice(end).trim();
64
64
  }
65
- if (resto || fragmentos.length === 0)
66
- fragmentos.push(resto);
67
- return fragmentos;
65
+ if (rest || chunks.length === 0)
66
+ chunks.push(rest);
67
+ return chunks;
68
68
  }
@@ -1,118 +1,118 @@
1
- import { deTexto, fragmentar } from "./index.js";
2
- import { leerEntrada, listarZip } from "./zip.js";
1
+ import { inMemory, splitText } from "./index.js";
2
+ import { listZip, readEntry } from "./zip.js";
3
3
  /** Tope por entrada descomprimida: un XML de texto de más que esto es un zip malicioso o no es un documento. */
4
- const MAX_ENTRADA = 50 * 1024 * 1024;
5
- const MARCAS = {
4
+ const MAX_ENTRY_BYTES = 50 * 1024 * 1024;
5
+ const MARKERS = {
6
6
  docx: "word/document.xml",
7
7
  pptx: "ppt/presentation.xml",
8
8
  xlsx: "xl/workbook.xml",
9
9
  };
10
10
  /** Mira la lista de entradas del zip sin descomprimir nada. */
11
- export function formatoOffice(bytes) {
12
- const nombres = new Set(listarZip(bytes).map((e) => e.nombre));
13
- return Object.keys(MARCAS).find((f) => nombres.has(MARCAS[f])) ?? null;
11
+ export function detectOfficeFormat(bytes) {
12
+ const names = new Set(listZip(bytes).map((e) => e.name));
13
+ return Object.keys(MARKERS).find((f) => names.has(MARKERS[f])) ?? null;
14
14
  }
15
- export function abrirOffice(bytes, formato) {
16
- const texto = new TextDecoder();
17
- const zip = Object.fromEntries(listarZip(bytes)
18
- .filter((e) => necesaria(formato, e.nombre))
19
- .map((e) => [e.nombre, texto.decode(leerEntrada(bytes, e, MAX_ENTRADA))]));
20
- switch (formato) {
15
+ export function openOffice(bytes, format) {
16
+ const decoder = new TextDecoder();
17
+ const zip = Object.fromEntries(listZip(bytes)
18
+ .filter((e) => isNeeded(format, e.name))
19
+ .map((e) => [e.name, decoder.decode(readEntry(bytes, e, MAX_ENTRY_BYTES))]));
20
+ switch (format) {
21
21
  case "docx":
22
- return deTexto("docx", "fragmento", fragmentar(xmlTexto(leer(zip, "word/document.xml"), "w")).map((texto) => ({ texto })));
22
+ return inMemory("docx", "fragmento", splitText(xmlText(read(zip, "word/document.xml"), "w")).map((text) => ({ text })));
23
23
  case "pptx":
24
- return deTexto("pptx", "diapositiva", Object.keys(zip)
24
+ return inMemory("pptx", "diapositiva", Object.keys(zip)
25
25
  .filter((p) => /^ppt\/slides\/slide\d+\.xml$/.test(p))
26
- .sort((a, b) => numero(a) - numero(b))
27
- .map((p) => ({ texto: xmlTexto(leer(zip, p), "a") || "(diapositiva sin texto)" })));
26
+ .sort((a, b) => slideNumber(a) - slideNumber(b))
27
+ .map((p) => ({ text: xmlText(read(zip, p), "a") || "(diapositiva sin texto)" })));
28
28
  case "xlsx":
29
- return deTexto("xlsx", "fragmento", hojas(zip).flatMap(({ nombre, texto }) => {
30
- const partes = fragmentar(texto);
31
- return partes.map((t, i) => ({
32
- titulo: partes.length > 1 ? `hoja "${nombre}" (${i + 1}/${partes.length})` : `hoja "${nombre}"`,
33
- texto: t,
29
+ return inMemory("xlsx", "fragmento", sheets(zip).flatMap(({ name, text }) => {
30
+ const chunks = splitText(text);
31
+ return chunks.map((t, i) => ({
32
+ title: chunks.length > 1 ? `hoja "${name}" (${i + 1}/${chunks.length})` : `hoja "${name}"`,
33
+ text: t,
34
34
  }));
35
35
  }));
36
36
  }
37
37
  }
38
- function necesaria(formato, nombre) {
39
- switch (formato) {
38
+ function isNeeded(format, name) {
39
+ switch (format) {
40
40
  case "docx":
41
- return nombre === "word/document.xml";
41
+ return name === "word/document.xml";
42
42
  case "pptx":
43
- return /^ppt\/slides\/slide\d+\.xml$/.test(nombre);
43
+ return /^ppt\/slides\/slide\d+\.xml$/.test(name);
44
44
  case "xlsx":
45
- return nombre === "xl/workbook.xml" || nombre === "xl/sharedStrings.xml" ||
46
- nombre === "xl/_rels/workbook.xml.rels" || nombre.startsWith("xl/worksheets/");
45
+ return name === "xl/workbook.xml" || name === "xl/sharedStrings.xml" ||
46
+ name === "xl/_rels/workbook.xml.rels" || name.startsWith("xl/worksheets/");
47
47
  }
48
48
  }
49
- const leer = (zip, ruta) => zip[ruta] ?? "";
50
- const numero = (ruta) => Number(ruta.match(/(\d+)\.xml$/)?.[1]);
51
- const ENTIDADES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
52
- const decodificar = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => e[0] !== "#" ? (ENTIDADES[e] ?? m) : String.fromCodePoint(e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1))));
49
+ const read = (zip, path) => zip[path] ?? "";
50
+ const slideNumber = (path) => Number(path.match(/(\d+)\.xml$/)?.[1]);
51
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
52
+ const decodeEntities = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => e[0] !== "#" ? (ENTITIES[e] ?? m) : String.fromCodePoint(e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1))));
53
53
  /**
54
54
  * Texto de un XML de Word (`w`) o PowerPoint (`a`), recorriendo los tags en orden: texto de los nodos
55
55
  * de texto, salto de línea al cerrar un párrafo, y las tablas como filas con celdas separadas por " | ".
56
56
  */
57
- function xmlTexto(xml, ns) {
57
+ function xmlText(xml, ns) {
58
58
  let out = "";
59
- let enTexto = false;
60
- let enCelda = 0;
61
- for (const [, cierre, tag, , autocierre, chars] of xml.matchAll(/<(\/?)([\w:]+)((?:[^>"]|"[^"]*")*?)(\/?)>|([^<]+)/g)) {
59
+ let inText = false;
60
+ let cellDepth = 0;
61
+ for (const [, closing, tag, , selfClosing, chars] of xml.matchAll(/<(\/?)([\w:]+)((?:[^>"]|"[^"]*")*?)(\/?)>|([^<]+)/g)) {
62
62
  if (chars !== undefined) {
63
- if (enTexto)
64
- out += decodificar(chars);
63
+ if (inText)
64
+ out += decodeEntities(chars);
65
65
  }
66
66
  else if (tag === `${ns}:t`) {
67
- enTexto = !cierre && !autocierre;
67
+ inText = !closing && !selfClosing;
68
68
  }
69
- else if (tag === `${ns}:tc` && !autocierre) {
70
- enCelda += cierre ? -1 : 1;
71
- if (cierre)
69
+ else if (tag === `${ns}:tc` && !selfClosing) {
70
+ cellDepth += closing ? -1 : 1;
71
+ if (closing)
72
72
  out += " | ";
73
73
  }
74
- else if (tag === `${ns}:tr` && cierre) {
74
+ else if (tag === `${ns}:tr` && closing) {
75
75
  out += "\n";
76
76
  }
77
- else if (tag === `${ns}:p` && cierre) {
78
- out += enCelda ? " " : "\n";
77
+ else if (tag === `${ns}:p` && closing) {
78
+ out += cellDepth ? " " : "\n";
79
79
  }
80
80
  else if (tag === `${ns}:tab`) {
81
81
  out += " ";
82
82
  }
83
83
  else if (tag === `${ns}:br`) {
84
- out += enCelda ? " " : "\n";
84
+ out += cellDepth ? " " : "\n";
85
85
  }
86
86
  }
87
87
  return out
88
88
  .split("\n")
89
89
  .map((l) => l.replace(/\s+/g, " ").replace(/\s*\|\s*$/, "").trim())
90
- .filter((l, i, todas) => l !== "|" && (l !== "" || todas[i - 1] !== ""))
90
+ .filter((l, i, all) => l !== "|" && (l !== "" || all[i - 1] !== ""))
91
91
  .join("\n")
92
92
  .trim();
93
93
  }
94
94
  /** Cada hoja como filas de celdas separadas por tabs, en el orden del libro. */
95
- function hojas(zip) {
96
- const compartidas = [...leer(zip, "xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => decodificar([...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => t[1]).join("")));
97
- const rels = Object.fromEntries([...leer(zip, "xl/_rels/workbook.xml.rels").matchAll(/<Relationship [^>]*>/g)].map(([tag]) => [
95
+ function sheets(zip) {
96
+ const sharedStrings = [...read(zip, "xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => decodeEntities([...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => t[1]).join("")));
97
+ const rels = Object.fromEntries([...read(zip, "xl/_rels/workbook.xml.rels").matchAll(/<Relationship [^>]*>/g)].map(([tag]) => [
98
98
  tag.match(/Id="([^"]+)"/)?.[1],
99
99
  tag.match(/Target="([^"]+)"/)?.[1],
100
100
  ]));
101
- return [...leer(zip, "xl/workbook.xml").matchAll(/<sheet [^>]*>/g)].map(([tag]) => {
102
- const nombre = decodificar(tag.match(/name="([^"]*)"/)?.[1] ?? "");
103
- const destino = rels[tag.match(/r:id="([^"]+)"/)?.[1] ?? ""] ?? "";
104
- const ruta = destino.replace(/^\//, "").replace(/^(xl\/)?/, "xl/");
105
- const filas = [...leer(zip, ruta).matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)].map((fila) => [...fila[1].matchAll(/<c ([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)]
106
- .map(([, attrs, cuerpo = ""]) => {
107
- const tipo = attrs.match(/\bt="(\w+)"/)?.[1];
108
- if (tipo === "s")
109
- return compartidas[Number(cuerpo.match(/<v>(\d+)<\/v>/)?.[1])] ?? "";
110
- if (tipo === "inlineStr")
111
- return decodificar(cuerpo.match(/<t[^>]*>([\s\S]*?)<\/t>/)?.[1] ?? "");
112
- return decodificar(cuerpo.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? "");
101
+ return [...read(zip, "xl/workbook.xml").matchAll(/<sheet [^>]*>/g)].map(([tag]) => {
102
+ const name = decodeEntities(tag.match(/name="([^"]*)"/)?.[1] ?? "");
103
+ const target = rels[tag.match(/r:id="([^"]+)"/)?.[1] ?? ""] ?? "";
104
+ const path = target.replace(/^\//, "").replace(/^(xl\/)?/, "xl/");
105
+ const rows = [...read(zip, path).matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)].map((row) => [...row[1].matchAll(/<c ([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)]
106
+ .map(([, attrs, body = ""]) => {
107
+ const type = attrs.match(/\bt="(\w+)"/)?.[1];
108
+ if (type === "s")
109
+ return sharedStrings[Number(body.match(/<v>(\d+)<\/v>/)?.[1])] ?? "";
110
+ if (type === "inlineStr")
111
+ return decodeEntities(body.match(/<t[^>]*>([\s\S]*?)<\/t>/)?.[1] ?? "");
112
+ return decodeEntities(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? "");
113
113
  })
114
114
  .join("\t")
115
115
  .replace(/\t+$/, ""));
116
- return { nombre, texto: filas.filter(Boolean).join("\n") || "(hoja vacía)" };
116
+ return { name, text: rows.filter(Boolean).join("\n") || "(hoja vacía)" };
117
117
  });
118
118
  }
@@ -1,18 +1,18 @@
1
- import { pngGris } from "./png.js";
1
+ import { grayscalePng } from "./png.js";
2
2
  /**
3
3
  * pdf.js no es una dependencia: scripts/vendor-pdfjs.mjs copia a vendor/pdfjs los pocos archivos que
4
4
  * usamos. Está a la misma altura desde src/ y desde build/, así que la ruta sirve en dev y publicado.
5
5
  */
6
6
  const VENDOR = new URL("../../../vendor/pdfjs/", import.meta.url);
7
7
  /** Al arrancar en Node, pdf.js avisa que le falta canvas para renderizar. No renderizamos: son ruido en el log. */
8
- const AVISOS_DE_CANVAS = /@napi-rs\/canvas|Cannot polyfill/;
9
- let pdfjsCargado;
8
+ const CANVAS_WARNINGS = /@napi-rs\/canvas|Cannot polyfill/;
9
+ let pdfjsLoading;
10
10
  /** Se carga recién al primer PDF: son 3 MB que el servidor no necesita para arrancar. */
11
11
  function pdfjs() {
12
- pdfjsCargado ??= (async () => {
12
+ pdfjsLoading ??= (async () => {
13
13
  const warn = console.warn;
14
14
  console.warn = (...args) => {
15
- if (!AVISOS_DE_CANVAS.test(String(args[0])))
15
+ if (!CANVAS_WARNINGS.test(String(args[0])))
16
16
  warn(...args);
17
17
  };
18
18
  try {
@@ -22,17 +22,19 @@ function pdfjs() {
22
22
  console.warn = warn;
23
23
  }
24
24
  })();
25
- return pdfjsCargado;
25
+ return pdfjsLoading;
26
26
  }
27
27
  /** Menos texto que esto en una página la tratamos como escaneada. */
28
- const MIN_TEXTO = 20;
28
+ const MIN_TEXT_CHARS = 20;
29
29
  /** Lado largo de la imagen de una página: el tamaño que Claude procesa sin reescalar. */
30
- const LADO_IMAGEN = 1568;
30
+ const IMAGE_LONG_SIDE = 1568;
31
31
  /** Muestras por eje y por píxel al reducir la imagen; suaviza los bordes y mejora la lectura. */
32
- const MUESTREO = 2;
33
- export async function abrirPdf(bytes) {
32
+ const SUPERSAMPLING = 2;
33
+ /** Cuánto esperar una imagen ya decodificada antes de seguir sin ella, en milisegundos. */
34
+ const IMAGE_WAIT_MS = 10_000;
35
+ export async function openPdf(bytes) {
34
36
  const lib = await pdfjs();
35
- const carga = lib.getDocument({
37
+ const loadingTask = lib.getDocument({
36
38
  data: bytes,
37
39
  verbosity: 0,
38
40
  isOffscreenCanvasSupported: false,
@@ -40,84 +42,102 @@ export async function abrirPdf(bytes) {
40
42
  useWasm: false,
41
43
  wasmUrl: new URL("wasm/", VENDOR).href,
42
44
  });
43
- const pdf = await carga.promise;
45
+ const pdf = await loadingTask.promise;
44
46
  return {
45
- formato: "pdf",
46
- unidad: "página",
47
+ format: "pdf",
48
+ unit: "página",
47
49
  total: pdf.numPages,
48
- parte: async (n) => leerPagina(lib, await pdf.getPage(n)),
49
- cerrar: () => carga.destroy(),
50
+ part: async (n) => readPage(lib, await pdf.getPage(n)),
51
+ close: () => loadingTask.destroy(),
50
52
  };
51
53
  }
52
- async function leerPagina(lib, pagina) {
53
- const contenido = await pagina.getTextContent();
54
- let texto = "";
55
- for (const item of contenido.items) {
54
+ async function readPage(lib, page) {
55
+ const content = await page.getTextContent();
56
+ let text = "";
57
+ for (const item of content.items) {
56
58
  if (!("str" in item))
57
59
  continue;
58
- texto += item.str + (item.hasEOL ? "\n" : "");
60
+ text += item.str + (item.hasEOL ? "\n" : "");
59
61
  }
60
- texto = texto.replace(/[ \t]+/g, " ").replace(/ ?\n ?/g, "\n").trim();
61
- if (texto.length >= MIN_TEXTO)
62
- return { texto };
63
- const escaneo = await rasterizarImagenes(lib, pagina);
64
- if (escaneo)
65
- return { titulo: "escaneada, como imagen", imagen: escaneo, mimeType: "image/png" };
66
- return { texto: texto || "(página sin texto)" };
62
+ text = text.replace(/[ \t]+/g, " ").replace(/ ?\n ?/g, "\n").trim();
63
+ if (text.length >= MIN_TEXT_CHARS)
64
+ return { text };
65
+ const scan = await rasterizeImages(lib, page);
66
+ if (scan)
67
+ return { title: "escaneada, como imagen", image: scan, mimeType: "image/png" };
68
+ return { text: text || "(página sin texto)" };
67
69
  }
68
70
  /**
69
71
  * Reconstruye la página dibujando solo sus imágenes, con la posición y rotación con que el PDF
70
72
  * las ubica. Una página escaneada es eso: una imagen. Así se evita un renderizador completo, que
71
73
  * en Node necesita un canvas nativo.
72
74
  */
73
- async function rasterizarImagenes({ OPS, Util }, pagina) {
74
- const base = pagina.getViewport({ scale: 1 });
75
- const vista = pagina.getViewport({ scale: LADO_IMAGEN / Math.max(base.width, base.height) });
76
- const ancho = Math.round(vista.width);
77
- const alto = Math.round(vista.height);
78
- const lienzo = new Uint8Array(ancho * alto).fill(255);
79
- const ops = await pagina.getOperatorList();
75
+ async function rasterizeImages({ OPS, Util }, page) {
76
+ const base = page.getViewport({ scale: 1 });
77
+ const viewport = page.getViewport({ scale: IMAGE_LONG_SIDE / Math.max(base.width, base.height) });
78
+ const width = Math.round(viewport.width);
79
+ const height = Math.round(viewport.height);
80
+ const canvas = new Uint8Array(width * height).fill(255);
81
+ const ops = await page.getOperatorList();
80
82
  let ctm = [1, 0, 0, 1, 0, 0];
81
- const pila = [];
82
- let pintadas = 0;
83
+ const stack = [];
84
+ let painted = 0;
83
85
  for (let i = 0; i < ops.fnArray.length; i++) {
84
86
  const args = ops.argsArray[i];
85
87
  switch (ops.fnArray[i]) {
86
88
  case OPS.save:
87
- pila.push(ctm);
89
+ stack.push(ctm);
88
90
  break;
89
91
  case OPS.restore:
90
- ctm = pila.pop() ?? ctm;
92
+ ctm = stack.pop() ?? ctm;
91
93
  break;
92
94
  case OPS.transform:
93
95
  ctm = Util.transform(ctm, args);
94
96
  break;
95
97
  case OPS.paintFormXObjectBegin:
96
- pila.push(ctm);
98
+ stack.push(ctm);
97
99
  if (args[0])
98
100
  ctm = Util.transform(ctm, args[0]);
99
101
  break;
100
102
  case OPS.paintFormXObjectEnd:
101
- ctm = pila.pop() ?? ctm;
103
+ ctm = stack.pop() ?? ctm;
102
104
  break;
103
105
  case OPS.paintImageXObject:
104
106
  case OPS.paintInlineImageXObject: {
105
- const imagen = typeof args[0] === "string" ? await new Promise((res) => pagina.objs.get(args[0], res)) : args[0];
106
- if (imagen?.data && pintar(lienzo, ancho, alto, imagen, Util.transform(vista.transform, ctm))) {
107
- pintadas++;
107
+ const image = typeof args[0] === "string" ? await imageObject(page, args[0]) : args[0];
108
+ if (image?.data && paint(canvas, width, height, image, Util.transform(viewport.transform, ctm))) {
109
+ painted++;
108
110
  }
109
111
  break;
110
112
  }
111
113
  }
112
114
  }
113
- return pintadas ? pngGris(ancho, alto, lienzo) : null;
115
+ return painted ? grayscalePng(width, height, canvas) : null;
116
+ }
117
+ /**
118
+ * Las imágenes que se repiten entre páginas (ids "g_...") pdf.js las guarda en `commonObjs`, no en los
119
+ * objetos de la página; su propio renderizador elige así. Buscarlas en `objs` espera para siempre.
120
+ * Con el tope, una imagen que no llega deja la página sin ella en vez de colgar la lectura.
121
+ */
122
+ async function imageObject(page, id) {
123
+ const store = id.startsWith("g_") ? page.commonObjs : page.objs;
124
+ let timer;
125
+ try {
126
+ return await Promise.race([
127
+ new Promise((resolve) => store.get(id, resolve)),
128
+ new Promise((resolve) => (timer = setTimeout(() => resolve(null), IMAGE_WAIT_MS))),
129
+ ]);
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ }
114
134
  }
115
135
  /**
116
136
  * Pinta una imagen en el lienzo. La imagen ocupa el cuadrado unitario transformado por `m`; por cada
117
137
  * píxel del lienzo se busca el píxel de origen con la transformación inversa, así cualquier rotación
118
138
  * o espejado sale bien sin casos especiales.
119
139
  */
120
- function pintar(lienzo, ancho, alto, img, m) {
140
+ function paint(canvas, width, height, img, m) {
121
141
  const [a, b, c, d, e, f] = m;
122
142
  const det = a * d - b * c;
123
143
  if (!det)
@@ -126,38 +146,38 @@ function pintar(lienzo, ancho, alto, img, m) {
126
146
  const xs = [e, a + e, c + e, a + c + e];
127
147
  const ys = [f, b + f, d + f, b + d + f];
128
148
  const x0 = Math.max(0, Math.floor(Math.min(...xs)));
129
- const x1 = Math.min(ancho, Math.ceil(Math.max(...xs)));
149
+ const x1 = Math.min(width, Math.ceil(Math.max(...xs)));
130
150
  const y0 = Math.max(0, Math.floor(Math.min(...ys)));
131
- const y1 = Math.min(alto, Math.ceil(Math.max(...ys)));
151
+ const y1 = Math.min(height, Math.ceil(Math.max(...ys)));
132
152
  const { width: w, height: h, kind } = img;
133
153
  const data = img.data;
134
- const bytesPorFila = (w + 7) >> 3;
135
- const canales = kind === 2 ? 3 : 4;
136
- const gris = (col, fila) => {
154
+ const bytesPerRow = (w + 7) >> 3;
155
+ const channels = kind === 2 ? 3 : 4;
156
+ const gray = (col, row) => {
137
157
  if (kind === 1)
138
- return ((data[fila * bytesPorFila + (col >> 3)] >> (7 - (col & 7))) & 1) * 255;
139
- const i = (fila * w + col) * canales;
158
+ return ((data[row * bytesPerRow + (col >> 3)] >> (7 - (col & 7))) & 1) * 255;
159
+ const i = (row * w + col) * channels;
140
160
  return data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
141
161
  };
142
162
  for (let y = y0; y < y1; y++) {
143
163
  for (let x = x0; x < x1; x++) {
144
- let suma = 0;
164
+ let sum = 0;
145
165
  let n = 0;
146
- for (let sy = 0; sy < MUESTREO; sy++) {
147
- for (let sx = 0; sx < MUESTREO; sx++) {
148
- const px = x + (sx + 0.5) / MUESTREO;
149
- const py = y + (sy + 0.5) / MUESTREO;
166
+ for (let sy = 0; sy < SUPERSAMPLING; sy++) {
167
+ for (let sx = 0; sx < SUPERSAMPLING; sx++) {
168
+ const px = x + (sx + 0.5) / SUPERSAMPLING;
169
+ const py = y + (sy + 0.5) / SUPERSAMPLING;
150
170
  const u = inv[0] * px + inv[2] * py + inv[4];
151
171
  const v = inv[1] * px + inv[3] * py + inv[5];
152
172
  if (u < 0 || u >= 1 || v < 0 || v >= 1)
153
173
  continue;
154
174
  // En el espacio de la imagen v crece hacia arriba; las filas de datos, hacia abajo.
155
- suma += gris(Math.floor(u * w), Math.floor((1 - v) * h));
175
+ sum += gray(Math.floor(u * w), Math.floor((1 - v) * h));
156
176
  n++;
157
177
  }
158
178
  }
159
179
  if (n)
160
- lienzo[y * ancho + x] = suma / n;
180
+ canvas[y * width + x] = sum / n;
161
181
  }
162
182
  }
163
183
  return true;
@@ -1,34 +1,34 @@
1
1
  import { deflateSync } from "node:zlib";
2
2
  /** PNG en escala de grises, 8 bits. Alcanza para páginas escaneadas y evita una dependencia de imágenes. */
3
- export function pngGris(ancho, alto, pixeles) {
3
+ export function grayscalePng(width, height, pixels) {
4
4
  // Cada fila va precedida por el byte de filtro (0 = sin filtro).
5
- const filas = new Uint8Array(alto * (ancho + 1));
6
- for (let y = 0; y < alto; y++)
7
- filas.set(pixeles.subarray(y * ancho, (y + 1) * ancho), y * (ancho + 1) + 1);
5
+ const rows = new Uint8Array(height * (width + 1));
6
+ for (let y = 0; y < height; y++)
7
+ rows.set(pixels.subarray(y * width, (y + 1) * width), y * (width + 1) + 1);
8
8
  const ihdr = new Uint8Array(13);
9
- const vista = new DataView(ihdr.buffer);
10
- vista.setUint32(0, ancho);
11
- vista.setUint32(4, alto);
9
+ const view = new DataView(ihdr.buffer);
10
+ view.setUint32(0, width);
11
+ view.setUint32(4, height);
12
12
  ihdr[8] = 8; // bits por canal
13
13
  ihdr[9] = 0; // escala de grises
14
14
  return concat([
15
15
  new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
16
16
  chunk("IHDR", ihdr),
17
- chunk("IDAT", deflateSync(filas, { level: 6 })),
17
+ chunk("IDAT", deflateSync(rows, { level: 6 })),
18
18
  chunk("IEND", new Uint8Array()),
19
19
  ]);
20
20
  }
21
- function chunk(tipo, datos) {
22
- const out = new Uint8Array(12 + datos.length);
23
- const vista = new DataView(out.buffer);
24
- vista.setUint32(0, datos.length);
21
+ function chunk(type, data) {
22
+ const out = new Uint8Array(12 + data.length);
23
+ const view = new DataView(out.buffer);
24
+ view.setUint32(0, data.length);
25
25
  for (let i = 0; i < 4; i++)
26
- out[4 + i] = tipo.charCodeAt(i);
27
- out.set(datos, 8);
28
- vista.setUint32(8 + datos.length, crc32(out.subarray(4, 8 + datos.length)));
26
+ out[4 + i] = type.charCodeAt(i);
27
+ out.set(data, 8);
28
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
29
29
  return out;
30
30
  }
31
- const TABLA_CRC = Int32Array.from({ length: 256 }, (_, n) => {
31
+ const CRC_TABLE = Int32Array.from({ length: 256 }, (_, n) => {
32
32
  let c = n;
33
33
  for (let k = 0; k < 8; k++)
34
34
  c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
@@ -37,13 +37,13 @@ const TABLA_CRC = Int32Array.from({ length: 256 }, (_, n) => {
37
37
  function crc32(bytes) {
38
38
  let c = -1;
39
39
  for (const b of bytes)
40
- c = TABLA_CRC[(c ^ b) & 0xff] ^ (c >>> 8);
40
+ c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
41
41
  return ~c >>> 0;
42
42
  }
43
- function concat(partes) {
44
- const out = new Uint8Array(partes.reduce((n, p) => n + p.length, 0));
43
+ function concat(parts) {
44
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
45
45
  let i = 0;
46
- for (const p of partes) {
46
+ for (const p of parts) {
47
47
  out.set(p, i);
48
48
  i += p.length;
49
49
  }