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.
- package/README.md +146 -91
- package/build/aula-virtual/client/client.js +135 -3
- package/build/aula-virtual/client/moodle-api.js +13 -13
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +101 -0
- package/build/aula-virtual/format/address.js +52 -0
- package/build/aula-virtual/format/fields.js +14 -0
- package/build/aula-virtual/format/files.js +89 -0
- package/build/aula-virtual/format/navigate.js +143 -0
- package/build/aula-virtual/tools.js +190 -12
- package/build/server.js +1 -1
- package/build/shared/documents/index.js +46 -46
- package/build/shared/documents/office.js +63 -63
- package/build/shared/documents/pdf.js +80 -60
- package/build/shared/documents/png.js +20 -20
- package/build/shared/documents/zip.js +49 -48
- package/build/shared/html.js +15 -2
- package/package.json +2 -2
- package/build/aula-virtual/address.js +0 -39
- package/build/aula-virtual/files.js +0 -89
- package/build/aula-virtual/navigate.js +0 -144
- /package/build/aula-virtual/{types.js → client/types.js} +0 -0
|
@@ -1,68 +1,68 @@
|
|
|
1
1
|
import { htmlToText } from "../html.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
export class
|
|
5
|
-
|
|
6
|
-
constructor(
|
|
7
|
-
super(`No sé leer archivos ${
|
|
8
|
-
this.
|
|
9
|
-
this.name = "
|
|
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
|
|
14
|
-
const
|
|
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
|
|
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
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
24
|
-
for (const [
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
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 (
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
31
|
-
throw new
|
|
32
|
-
return
|
|
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 (
|
|
35
|
-
throw new
|
|
34
|
+
if (hasSignature(bytes, [0xd0, 0xcf, 0x11, 0xe0])) {
|
|
35
|
+
throw new UnsupportedFormat("de Office viejo (.doc, .xls, .ppt, .pps)");
|
|
36
36
|
}
|
|
37
|
-
const extension =
|
|
37
|
+
const extension = name.toLowerCase().match(/\.(\w+)$/)?.[1] ?? "";
|
|
38
38
|
if (["txt", "csv", "md", "html", "htm"].includes(extension)) {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
return
|
|
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
|
|
43
|
+
throw new UnsupportedFormat(extension ? `.${extension}` : "de este tipo");
|
|
44
44
|
}
|
|
45
45
|
/** Un documento que ya está entero en memoria. */
|
|
46
|
-
export function
|
|
46
|
+
export function inMemory(format, unit, parts) {
|
|
47
47
|
return {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
total:
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
57
|
-
const
|
|
58
|
-
let
|
|
59
|
-
while (
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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 (
|
|
66
|
-
|
|
67
|
-
return
|
|
65
|
+
if (rest || chunks.length === 0)
|
|
66
|
+
chunks.push(rest);
|
|
67
|
+
return chunks;
|
|
68
68
|
}
|
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
const
|
|
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
|
|
12
|
-
const
|
|
13
|
-
return Object.keys(
|
|
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
|
|
16
|
-
const
|
|
17
|
-
const zip = Object.fromEntries(
|
|
18
|
-
.filter((e) =>
|
|
19
|
-
.map((e) => [e.
|
|
20
|
-
switch (
|
|
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
|
|
22
|
+
return inMemory("docx", "fragmento", splitText(xmlText(read(zip, "word/document.xml"), "w")).map((text) => ({ text })));
|
|
23
23
|
case "pptx":
|
|
24
|
-
return
|
|
24
|
+
return inMemory("pptx", "diapositiva", Object.keys(zip)
|
|
25
25
|
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/.test(p))
|
|
26
|
-
.sort((a, b) =>
|
|
27
|
-
.map((p) => ({
|
|
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
|
|
30
|
-
const
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
switch (
|
|
38
|
+
function isNeeded(format, name) {
|
|
39
|
+
switch (format) {
|
|
40
40
|
case "docx":
|
|
41
|
-
return
|
|
41
|
+
return name === "word/document.xml";
|
|
42
42
|
case "pptx":
|
|
43
|
-
return /^ppt\/slides\/slide\d+\.xml$/.test(
|
|
43
|
+
return /^ppt\/slides\/slide\d+\.xml$/.test(name);
|
|
44
44
|
case "xlsx":
|
|
45
|
-
return
|
|
46
|
-
|
|
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
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
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
|
|
57
|
+
function xmlText(xml, ns) {
|
|
58
58
|
let out = "";
|
|
59
|
-
let
|
|
60
|
-
let
|
|
61
|
-
for (const [,
|
|
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 (
|
|
64
|
-
out +=
|
|
63
|
+
if (inText)
|
|
64
|
+
out += decodeEntities(chars);
|
|
65
65
|
}
|
|
66
66
|
else if (tag === `${ns}:t`) {
|
|
67
|
-
|
|
67
|
+
inText = !closing && !selfClosing;
|
|
68
68
|
}
|
|
69
|
-
else if (tag === `${ns}:tc` && !
|
|
70
|
-
|
|
71
|
-
if (
|
|
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` &&
|
|
74
|
+
else if (tag === `${ns}:tr` && closing) {
|
|
75
75
|
out += "\n";
|
|
76
76
|
}
|
|
77
|
-
else if (tag === `${ns}:p` &&
|
|
78
|
-
out +=
|
|
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 +=
|
|
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,
|
|
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
|
|
96
|
-
const
|
|
97
|
-
const rels = Object.fromEntries([...
|
|
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 [...
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
.map(([, attrs,
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
return
|
|
110
|
-
if (
|
|
111
|
-
return
|
|
112
|
-
return
|
|
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 {
|
|
116
|
+
return { name, text: rows.filter(Boolean).join("\n") || "(hoja vacía)" };
|
|
117
117
|
});
|
|
118
118
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import {
|
|
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
|
|
9
|
-
let
|
|
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
|
-
|
|
12
|
+
pdfjsLoading ??= (async () => {
|
|
13
13
|
const warn = console.warn;
|
|
14
14
|
console.warn = (...args) => {
|
|
15
|
-
if (!
|
|
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
|
|
25
|
+
return pdfjsLoading;
|
|
26
26
|
}
|
|
27
27
|
/** Menos texto que esto en una página la tratamos como escaneada. */
|
|
28
|
-
const
|
|
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
|
|
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
|
|
33
|
-
|
|
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
|
|
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
|
|
45
|
+
const pdf = await loadingTask.promise;
|
|
44
46
|
return {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
format: "pdf",
|
|
48
|
+
unit: "página",
|
|
47
49
|
total: pdf.numPages,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
part: async (n) => readPage(lib, await pdf.getPage(n)),
|
|
51
|
+
close: () => loadingTask.destroy(),
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
|
-
async function
|
|
53
|
-
const
|
|
54
|
-
let
|
|
55
|
-
for (const item of
|
|
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
|
-
|
|
60
|
+
text += item.str + (item.hasEOL ? "\n" : "");
|
|
59
61
|
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
return {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
return {
|
|
66
|
-
return {
|
|
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
|
|
74
|
-
const base =
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const ops = await
|
|
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
|
|
82
|
-
let
|
|
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
|
-
|
|
89
|
+
stack.push(ctm);
|
|
88
90
|
break;
|
|
89
91
|
case OPS.restore:
|
|
90
|
-
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
|
-
|
|
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 =
|
|
103
|
+
ctm = stack.pop() ?? ctm;
|
|
102
104
|
break;
|
|
103
105
|
case OPS.paintImageXObject:
|
|
104
106
|
case OPS.paintInlineImageXObject: {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
135
|
-
const
|
|
136
|
-
const
|
|
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[
|
|
139
|
-
const i = (
|
|
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
|
|
164
|
+
let sum = 0;
|
|
145
165
|
let n = 0;
|
|
146
|
-
for (let sy = 0; sy <
|
|
147
|
-
for (let sx = 0; sx <
|
|
148
|
-
const px = x + (sx + 0.5) /
|
|
149
|
-
const py = y + (sy + 0.5) /
|
|
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
|
-
|
|
175
|
+
sum += gray(Math.floor(u * w), Math.floor((1 - v) * h));
|
|
156
176
|
n++;
|
|
157
177
|
}
|
|
158
178
|
}
|
|
159
179
|
if (n)
|
|
160
|
-
|
|
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
|
|
3
|
+
export function grayscalePng(width, height, pixels) {
|
|
4
4
|
// Cada fila va precedida por el byte de filtro (0 = sin filtro).
|
|
5
|
-
const
|
|
6
|
-
for (let y = 0; y <
|
|
7
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
17
|
+
chunk("IDAT", deflateSync(rows, { level: 6 })),
|
|
18
18
|
chunk("IEND", new Uint8Array()),
|
|
19
19
|
]);
|
|
20
20
|
}
|
|
21
|
-
function chunk(
|
|
22
|
-
const out = new Uint8Array(12 +
|
|
23
|
-
const
|
|
24
|
-
|
|
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] =
|
|
27
|
-
out.set(
|
|
28
|
-
|
|
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
|
|
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 =
|
|
40
|
+
c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
41
41
|
return ~c >>> 0;
|
|
42
42
|
}
|
|
43
|
-
function concat(
|
|
44
|
-
const out = new Uint8Array(
|
|
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
|
|
46
|
+
for (const p of parts) {
|
|
47
47
|
out.set(p, i);
|
|
48
48
|
i += p.length;
|
|
49
49
|
}
|