ZDDC/shared/preview-lib.js
ZDDC d6206b03e7 feat(shared): bake xlsx + utif + jszip + docx-preview into every tool
Removes every runtime CDN load. The "ship the record player with the
record" philosophy: a downloaded .html file works offline against any
file the user can open, with no network dependency at runtime.

Newly vendored under shared/vendor/:
  - xlsx.full.min.js (SheetJS, 928 KB) — XLSX/XLS preview
  - utif.min.js     (UTIF, 57 KB)      — TIFF preview

Already there but now used by mdedit too:
  - jszip.min.js, docx-preview.min.js

Call sites updated to drop the `await loadLibrary(URL)` pattern —
since the vendor JS is concatenated into the inline <script> at build
time, window.XLSX / window.JSZip / window.UTIF / window.docx are
available synchronously from page load.

Per-tool changes:

  - archive/build.sh:        +xlsx, +utif
  - classifier/build.sh:     +xlsx, +utif
  - transmittal/build.sh:    +xlsx, +utif
  - mdedit/build.sh:         +jszip, +docx-preview, +xlsx, +utif
                              (mdedit was the only tool not yet
                               bundling any of the preview deps)
  - browse/build.sh:         +utif
  - archive/js/table.js, classifier/js/preview.js,
    transmittal/js/files-preview.js, mdedit/js/file-tree.js (×2):
    drop the `await loadLibrary('…cdn…')` lines.
  - shared/preview-lib.js:
    drop the loadLibrary(UTIF) / loadLibrary(JSZip) wrappers; assume
    window.UTIF and window.JSZip are present.

Net bundle-size delta after baking:
  archive:     +990 KB → ~1.47 MB
  browse:       +57 KB → ~292 KB
  classifier:  +990 KB → ~1.43 MB
  mdedit:    +1100 KB → ~2.09 MB
  transmittal: +990 KB → ~1.63 MB

Docs (AGENTS.md, ARCHITECTURE.md) updated: removed the "runtime CDN
loading exception" paragraph and the table row that flagged xlsx as
CDN-loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:09:38 -05:00

547 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ZDDC — shared preview helpers
*
* Cross-tool helpers for previewing file types that need a decoder:
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
* - ZIP listing (JSZip) — sortable file-list view
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
*
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
*/
(function (root) {
'use strict';
var TIFF_EXTENSIONS = ['tif', 'tiff'];
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
var TEXT_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
'sql', 'env'
];
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
function lowerExt(ext) { return (ext || '').toLowerCase(); }
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isZip(ext) { return lowerExt(ext) === 'zip'; }
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
// ── CDN library loader (parent window cache) ─────────────────────────────
var _libCache = new Map();
function loadLibrary(url) {
if (_libCache.has(url)) return _libCache.get(url);
var p = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { resolve(); };
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
document.head.appendChild(s);
});
_libCache.set(url, p);
return p;
}
// ── Style injection (idempotent per-document) ────────────────────────────
function injectStyles(doc, id, css) {
if (doc.getElementById(id)) return;
var style = doc.createElement('style');
style.id = id;
style.textContent = css;
doc.head.appendChild(style);
}
// ── Helpers ──────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── TIFF renderer ────────────────────────────────────────────────────────
var TIFF_CSS =
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
'border-radius:3px;text-align:center;font-size:.85rem;}' +
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;font-size:.85rem;}' +
'.tiff-toolbar .tiff-spacer{flex:1;}' +
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
'justify-content:center;padding:1rem;}' +
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
'image-rendering:auto;}' +
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
'padding:2rem;text-align:center;}';
function renderTiff(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
// UTIF is bundled (shared/vendor/utif.min.js) — window.UTIF is
// available synchronously. Promise.resolve() keeps the existing
// .then() chain shape so callers don't need to change.
return Promise.resolve().then(function () {
var ifds;
try {
ifds = window.UTIF.decode(arrayBuffer);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
+ escapeHtml(e.message || e) + '</div>';
return;
}
if (!ifds || !ifds.length) {
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
return;
}
// Reset container to a flex column
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
// Toolbar
var toolbar = doc.createElement('div');
toolbar.className = 'tiff-toolbar';
var btnPrev = doc.createElement('button');
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
var pageInfo = doc.createElement('span');
pageInfo.className = 'tiff-page-info';
var pageInput = doc.createElement('input');
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
pageInput.className = 'tiff-page-input';
var pageOf = doc.createElement('span');
pageOf.textContent = ' of ' + ifds.length;
pageInfo.appendChild(doc.createTextNode('Page '));
pageInfo.appendChild(pageInput);
pageInfo.appendChild(pageOf);
var btnNext = doc.createElement('button');
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
btnNext.title = 'Next page'; btnNext.textContent = '▶';
var spacer = doc.createElement('span');
spacer.className = 'tiff-spacer';
var btnZoomOut = doc.createElement('button');
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '';
var zoomSelect = doc.createElement('select');
zoomSelect.className = 'tiff-zoom-select';
var zoomOptions = [
['fit-width', 'Fit width'],
['fit-page', 'Fit page'],
['0.5', '50%'],
['0.75', '75%'],
['1', '100%'],
['1.25', '125%'],
['1.5', '150%'],
['2', '200%'],
['3', '300%'],
['4', '400%']
];
zoomOptions.forEach(function (z) {
var o = doc.createElement('option');
o.value = z[0]; o.textContent = z[1];
zoomSelect.appendChild(o);
});
zoomSelect.value = 'fit-width';
var btnZoomIn = doc.createElement('button');
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
toolbar.appendChild(btnPrev);
toolbar.appendChild(pageInfo);
toolbar.appendChild(btnNext);
toolbar.appendChild(spacer);
toolbar.appendChild(btnZoomOut);
toolbar.appendChild(zoomSelect);
toolbar.appendChild(btnZoomIn);
// Viewport with canvas
var viewport = doc.createElement('div');
viewport.className = 'tiff-viewport';
var canvas = doc.createElement('canvas');
canvas.className = 'tiff-canvas';
viewport.appendChild(canvas);
container.appendChild(toolbar);
container.appendChild(viewport);
// Render state
var currentPage = 0;
var zoom = 1;
var fitMode = 'width'; // 'width' | 'page' | null
var decoded = new Array(ifds.length);
function decodePage(i) {
if (decoded[i]) return decoded[i];
var ifd = ifds[i];
window.UTIF.decodeImage(arrayBuffer, ifd);
var rgba = window.UTIF.toRGBA8(ifd);
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
return decoded[i];
}
function applyZoom() {
var page = decoded[currentPage];
if (!page) return;
var availW = viewport.clientWidth - 32; // padding
var availH = viewport.clientHeight - 32;
var scale;
if (fitMode === 'width') {
scale = availW / page.w;
} else if (fitMode === 'page') {
scale = Math.min(availW / page.w, availH / page.h);
} else {
scale = zoom;
}
if (!isFinite(scale) || scale <= 0) scale = 1;
canvas.style.width = (page.w * scale) + 'px';
canvas.style.height = (page.h * scale) + 'px';
}
function renderPage() {
var page;
try {
page = decodePage(currentPage);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to decode page '
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
return;
}
canvas.width = page.w;
canvas.height = page.h;
var ctx = canvas.getContext('2d');
var imgData = ctx.createImageData(page.w, page.h);
imgData.data.set(page.rgba);
ctx.putImageData(imgData, 0, 0);
applyZoom();
pageInput.value = String(currentPage + 1);
btnPrev.disabled = currentPage <= 0;
btnNext.disabled = currentPage >= ifds.length - 1;
}
function setZoomFromSelect() {
var v = zoomSelect.value;
if (v === 'fit-width') { fitMode = 'width'; }
else if (v === 'fit-page') { fitMode = 'page'; }
else { fitMode = null; zoom = parseFloat(v) || 1; }
applyZoom();
}
function nudgeZoom(factor) {
if (fitMode) {
// capture current effective scale before leaving fit mode
var page = decoded[currentPage];
if (page) {
var availW = viewport.clientWidth - 32;
var availH = viewport.clientHeight - 32;
zoom = fitMode === 'width'
? availW / page.w
: Math.min(availW / page.w, availH / page.h);
} else {
zoom = 1;
}
fitMode = null;
}
zoom = Math.max(0.1, Math.min(8, zoom * factor));
// Match select option if any are close, else show as percent
var matched = false;
for (var i = 0; i < zoomSelect.options.length; i++) {
var ov = zoomSelect.options[i].value;
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
zoomSelect.value = ov; matched = true; break;
}
}
if (!matched) {
// Nearest standard step
var best = '1', bestDiff = Infinity;
for (var j = 0; j < zoomSelect.options.length; j++) {
var v2 = zoomSelect.options[j].value;
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
var diff = Math.abs(parseFloat(v2) - zoom);
if (diff < bestDiff) { bestDiff = diff; best = v2; }
}
zoom = parseFloat(best);
zoomSelect.value = best;
}
applyZoom();
}
btnPrev.addEventListener('click', function () {
if (currentPage > 0) { currentPage--; renderPage(); }
});
btnNext.addEventListener('click', function () {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
});
pageInput.addEventListener('change', function () {
var n = parseInt(pageInput.value, 10);
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
currentPage = n - 1;
renderPage();
} else {
pageInput.value = String(currentPage + 1);
}
});
zoomSelect.addEventListener('change', setZoomFromSelect);
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
// Keyboard nav (only when toolbar/viewport in focus path)
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if (e.target === pageInput) return;
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
}
});
// Re-fit on viewport resize
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
ro.observe(viewport);
} else if (doc.defaultView) {
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
}
renderPage();
});
}
// ── ZIP listing renderer ─────────────────────────────────────────────────
var ZIP_CSS =
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
'font-size:.85rem;color:#444;}' +
'.zip-table-wrap{flex:1;overflow:auto;}' +
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
'font-weight:600;}' +
'.zip-table thead th:hover{background:#e6e6e6;}' +
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
'.zip-table tbody tr:hover{background:#f6faff;}' +
'.zip-table .zip-folder{color:#888;}' +
'.zip-table .zip-name{color:#222;}' +
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
'white-space:nowrap;color:#555;}' +
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
function renderZipListing(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
// JSZip is bundled in every tool that uses preview-lib (each
// tool's build.sh concatenates shared/vendor/jszip.min.js).
return window.JSZip.loadAsync(arrayBuffer).then(function (zip) {
var entries = [];
zip.forEach(function (relativePath, zipEntry) {
if (zipEntry.dir) return;
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
entries.push({
path: relativePath,
name: relativePath.split('/').pop(),
size: size,
modified: zipEntry.date instanceof Date ? zipEntry.date : null
});
});
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
var header = doc.createElement('div');
header.className = 'zip-header';
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
container.appendChild(header);
if (!entries.length) {
var empty = doc.createElement('div');
empty.className = 'zip-empty';
empty.textContent = '(empty archive)';
container.appendChild(empty);
return;
}
var wrap = doc.createElement('div');
wrap.className = 'zip-table-wrap';
var table = doc.createElement('table');
table.className = 'zip-table';
var thead = doc.createElement('thead');
var trh = doc.createElement('tr');
var cols = [
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
];
cols.forEach(function (c) {
var th = doc.createElement('th');
th.className = c.cls;
th.dataset.key = c.key;
th.textContent = c.label;
trh.appendChild(th);
});
thead.appendChild(trh);
table.appendChild(thead);
var tbody = doc.createElement('tbody');
table.appendChild(tbody);
wrap.appendChild(table);
container.appendChild(wrap);
var sortKey = 'path';
var sortDir = 1;
function render() {
var sorted = entries.slice().sort(function (a, b) {
var av, bv;
if (sortKey === 'size') { av = a.size; bv = b.size; }
else if (sortKey === 'modified') {
av = a.modified ? a.modified.getTime() : 0;
bv = b.modified ? b.modified.getTime() : 0;
} else {
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
}
if (av < bv) return -1 * sortDir;
if (av > bv) return 1 * sortDir;
return 0;
});
tbody.innerHTML = '';
sorted.forEach(function (e) {
var tr = doc.createElement('tr');
var td1 = doc.createElement('td');
var slash = e.path.lastIndexOf('/');
if (slash >= 0) {
var folder = doc.createElement('span');
folder.className = 'zip-folder';
folder.textContent = e.path.substring(0, slash + 1);
td1.appendChild(folder);
}
var name = doc.createElement('span');
name.className = 'zip-name';
name.textContent = e.name;
td1.appendChild(name);
var td2 = doc.createElement('td');
td2.className = 'zip-size';
td2.textContent = formatSize(e.size);
var td3 = doc.createElement('td');
td3.className = 'zip-date';
td3.textContent = formatDate(e.modified);
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tbody.appendChild(tr);
});
// Update sort arrows
var ths = thead.querySelectorAll('th');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
if (ths[i].dataset.key === sortKey) {
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
}
}
}
thead.querySelectorAll('th').forEach(function (th) {
th.addEventListener('click', function () {
var k = th.dataset.key;
if (sortKey === k) sortDir = -sortDir;
else { sortKey = k; sortDir = 1; }
render();
});
});
render();
}).catch(function (err) {
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
+ escapeHtml(err.message || err) + '</div>';
});
}
// ── Public API ───────────────────────────────────────────────────────────
if (!root.zddc) root.zddc = {};
root.zddc.preview = {
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
isTiff: isTiff,
isImage: isImage,
isText: isText,
isZip: isZip,
isOffice: isOffice,
loadLibrary: loadLibrary,
renderTiff: renderTiff,
renderZipListing: renderZipListing,
formatSize: formatSize,
formatDate: formatDate
};
})(typeof window !== 'undefined' ? window : this);