OASIS_MOBILE/src/backend/pdf_view.js
hacklab 83f26ba19e integrar la 1.3.5 del dev y perfiles Minimo, Acampada y Activista
Lo del dev, tal cual desde el nodejs-project de su APK oficial 1.3.5, mezclado
a tres bandas con la 1.3.3 como base. Lo que no habiamos tocado se coge entero.

Conflictos:
- backend.js: su middleware nuevo de media va delante de nuestro router.
- main_views.js y bookmark_view.js: lo suyo. Su menu de tarjeta (⋯) ya aparta
  borrar del pulgar, que era lo que hacia nuestro cambio.
- settings_view.js y tribes_view.js: lo suyo mas nuestra linea de enganche.

La 1.3.5 trae una cabecera de modulo para el movil (apkModbar) que se lleva la
primera caja de fichas, el buscador y los contadores. Con el skin Activista su
CSS (OasisMobile.css) no se carga: se le da estilo en mobile_ux.css, los avisos
suben encima con order y se ajusta la cabecera nueva de las tarjetas. Filtrar
va ahora dentro de la caja de fichas (casilla + etiqueta) para viajar con ella.

Perfiles: Minimo (6 modulos, de serie), Acampada (16), Activista (24, con
tribus, votaciones, campañas, encuestas y tareas) y despues los del dev por
numero de modulos.
2026-10-22 12:12:20 +02:00

176 lines
7.3 KiB
JavaScript

const path = require('path');
const crypto = require('crypto');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const { pathToFileURL } = require('url');
const WORKER_TAG = 'oasis-pdf-view';
const MUPDF_URL = pathToFileURL(path.join(__dirname, '..', 'server', 'node_modules', 'mupdf', 'dist', 'mupdf.js')).href;
const PDF_BASE_WIDTH = 1600;
const PDF_FIT_WIDTH = 1200;
const PDF_WEBP_EFFORT = 1;
const PDF_ZOOMS = [50, 75, 100, 125, 150, 200, 300];
const PDF_ZOOM_DEFAULT = 100;
const PDF_PAGE_MAX_PX = 4096;
const PDF_CALL_MS = 20000;
const PDF_DOCS_BYTES = 128 * 1024 * 1024;
const PDF_ROTATIONS = [0, 90, 180, 270];
const PDF_FRAME_ID_LEN = 12;
const PDF_PAGES_CACHE_BYTES = 64 * 1024 * 1024;
const PDF_WORKER_HEAP_MB = 256;
const PDF_WEBP_QUALITY = 85;
const PDF_MAGIC = Buffer.from('%PDF-');
const PDF_MAGIC_WINDOW = 1024;
const widthOf = (zoom) => (zoom <= PDF_ZOOM_DEFAULT ? PDF_FIT_WIDTH : Math.round(PDF_BASE_WIDTH * zoom / 100));
const PDF_WIDTHS = new Set(PDF_ZOOMS.map(widthOf));
const zoomOf = (value) => { const z = Number(value); return PDF_ZOOMS.includes(z) ? z : PDF_ZOOM_DEFAULT; };
const rotationOf = (value) => { const r = Number(value); return PDF_ROTATIONS.includes(r) ? r : 0; };
const looksLikePdf = (buf) => Buffer.isBuffer(buf) && buf.subarray(0, PDF_MAGIC_WINDOW).indexOf(PDF_MAGIC) >= 0;
if (!isMainThread && workerData === WORKER_TAG) {
const docs = new Map();
const sizes = new Map();
let openBytes = 0;
let mupdf = null;
const need = (key) => {
const doc = docs.get(key);
if (!doc) throw new Error('NO_DOC');
docs.delete(key);
docs.set(key, doc);
return doc;
};
const describe = (doc) => ({ pages: doc.countPages() });
const ops = {
open({ key, data }) {
const doc = mupdf.Document.openDocument(Buffer.from(data), 'application/pdf');
if (doc.needsPassword()) { doc.destroy(); throw new Error('LOCKED'); }
docs.set(key, doc);
sizes.set(key, data.byteLength);
openBytes += data.byteLength;
while (openBytes > PDF_DOCS_BYTES && docs.size > 1) {
const [oldKey, oldDoc] = docs.entries().next().value;
docs.delete(oldKey);
openBytes -= sizes.get(oldKey) || 0;
sizes.delete(oldKey);
try { oldDoc.destroy(); } catch (_) {}
}
return describe(doc);
},
info({ key }) { return describe(need(key)); },
render({ key, page, width, rotate }) {
const doc = need(key);
const pg = doc.loadPage(page - 1);
try {
const [x0, y0, x1, y1] = pg.getBounds();
let scale = width / Math.max(1, x1 - x0);
scale = Math.min(scale, PDF_PAGE_MAX_PX / Math.max(1, y1 - y0), PDF_PAGE_MAX_PX / Math.max(1, x1 - x0));
const pix = pg.toPixmap(mupdf.Matrix.concat(mupdf.Matrix.scale(scale, scale), mupdf.Matrix.rotate(rotate || 0)), mupdf.ColorSpace.DeviceRGB, false, true);
try { return { jpeg: pix.asJPEG(PDF_WEBP_QUALITY, false) }; } finally { pix.destroy(); }
} finally { pg.destroy(); }
}
};
const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const ready = import(MUPDF_URL).then((m) => {
mupdf = m;
mupdf.setLog(debug ? (message) => console.error(`[pdf] ${message}`) : () => {});
});
parentPort.on('message', async ({ id, op, args }) => {
try {
await ready;
if (!ops[op]) throw new Error('BAD_OP');
const { data, ...rest } = args || {};
if (data && !docs.has(rest.key)) ops.open({ key: rest.key, data });
const value = op === 'open' ? describe(need(rest.key)) : ops[op](rest);
parentPort.postMessage({ id, value });
} catch (e) {
parentPort.postMessage({ id, error: String((e && e.message) || e) });
}
});
} else {
let worker = null;
let current = null;
const queue = [];
const stop = () => { const w = worker; worker = null; if (w) w.terminate().catch(() => {}); };
const settle = (outcome) => {
const job = current;
if (!job) return;
current = null;
clearTimeout(job.timer);
if (outcome.error) job.reject(new Error(outcome.error)); else job.resolve(outcome.value);
pump();
};
const start = () => {
const w = new Worker(__filename, { workerData: WORKER_TAG, resourceLimits: { maxOldGenerationSizeMb: PDF_WORKER_HEAP_MB } });
w.on('message', (msg) => { if (worker === w && current && msg.id === current.id) settle(msg); });
const lost = () => { if (worker === w) { worker = null; settle({ error: 'WORKER' }); } };
w.on('error', lost);
w.on('exit', lost);
w.unref();
worker = w;
};
let seq = 0;
const pump = () => {
if (current || !queue.length) return;
if (!worker) start();
current = queue.shift();
current.timer = setTimeout(() => { stop(); settle({ error: 'TIMEOUT' }); }, PDF_CALL_MS);
worker.postMessage({ id: current.id, op: current.op, args: current.args });
};
const call = (op, args) => new Promise((resolve, reject) => {
queue.push({ id: ++seq, op, args, resolve, reject, timer: null });
pump();
});
const loading = new Map();
const loadPdf = (key, load) => {
if (!loading.has(key)) {
loading.set(key, (async () => {
const data = await load();
if (!data) throw new Error('UNAVAILABLE');
if (!looksLikePdf(data)) throw new Error('NOT_PDF');
return data;
})().finally(() => loading.delete(key)));
}
return loading.get(key);
};
const withDoc = async (key, load, op, args = {}) => {
try { return await call(op, { key, ...args }); } catch (e) { if (e.message !== 'NO_DOC') throw e; }
return call(op, { key, ...args, data: await loadPdf(key, load) });
};
const pages = new Map();
let pagesBytes = 0;
const remember = (k, v) => {
pages.set(k, v);
pagesBytes += v.body.length;
for (const [old, entry] of pages) {
if (pagesBytes <= PDF_PAGES_CACHE_BYTES) break;
pages.delete(old);
pagesBytes -= entry.body.length;
}
};
const encode = async ({ jpeg }) => ({ body: Buffer.from(jpeg), type: 'image/jpeg' });
const drawing = new Map();
const keyOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex');
const frameIdOf = (src) => `pdf-${keyOf(src).slice(0, PDF_FRAME_ID_LEN)}`;
module.exports = {
PDF_BASE_WIDTH, PDF_ZOOMS, PDF_ZOOM_DEFAULT, PDF_ROTATIONS,
widthOf, zoomOf, rotationOf, looksLikePdf, keyOf, frameIdOf,
isFrameId: (v) => new RegExp(`^pdf-[0-9a-f]{${PDF_FRAME_ID_LEN}}$`).test(String(v || '')),
isPdfWidth: (w) => PDF_WIDTHS.has(Number(w)),
info: (key, load) => withDoc(key, load, 'info'),
async page(key, load, page, width, rotation) {
const r = rotationOf(rotation);
const cacheKey = `${key}|${page}|${width}|${r}`;
const hit = pages.get(cacheKey);
if (hit) { pages.delete(cacheKey); pages.set(cacheKey, hit); return hit; }
if (!drawing.has(cacheKey)) {
drawing.set(cacheKey, (async () => {
const out = await encode(await withDoc(key, load, 'render', { page: Number(page), width: Number(width), rotate: r }), r);
remember(cacheKey, out);
return out;
})().finally(() => drawing.delete(cacheKey)));
}
return drawing.get(cacheKey);
},
shutdown: () => { stop(); while (queue.length) queue.shift().reject(new Error('STOPPED')); if (current) settle({ error: 'STOPPED' }); }
};
}