// Light previews of office files for the file manager, with no libraries:
// docx, xlsx and ods are zip archives of XML, opened with the browser's own
// DecompressionStream; csv and tsv are parsed here; an old binary .doc only
// gives up its text. Each turns into plain HTML the page styles itself.
(function () {
'use strict';
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
// ---------- zip ----------
// Reads the central directory; a file is inflated only when asked for.
function openZip(buf) {
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
let eocd = -1;
for (let i = b.length - 22; i >= Math.max(0, b.length - 65557); i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('не zip-архив');
const count = dv.getUint16(eocd + 10, true);
let p = dv.getUint32(eocd + 16, true);
const files = new Map();
const dec = new TextDecoder();
for (let n = 0; n < count && dv.getUint32(p, true) === 0x02014b50; n++) {
const method = dv.getUint16(p + 10, true), size = dv.getUint32(p + 20, true);
const nameLen = dv.getUint16(p + 28, true), extra = dv.getUint16(p + 30, true), comment = dv.getUint16(p + 32, true);
const local = dv.getUint32(p + 42, true);
files.set(dec.decode(b.subarray(p + 46, p + 46 + nameLen)), {method, size, local});
p += 46 + nameLen + extra + comment;
}
async function bytes(name) {
const f = files.get(name);
if (!f) return null;
const start = f.local + 30 + dv.getUint16(f.local + 26, true) + dv.getUint16(f.local + 28, true);
const raw = b.subarray(start, start + f.size);
if (f.method === 0) return raw;
if (f.method !== 8) throw new Error('сжатие zip, которое браузер не знает');
const out = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(out).arrayBuffer());
}
async function xml(name) {
const raw = await bytes(name);
return raw ? new DOMParser().parseFromString(new TextDecoder().decode(raw), 'application/xml') : null;
}
return {files, bytes, xml};
}
// Children of an element by local name, whatever the namespace prefix.
const kids = (el, name) => el ? Array.from(el.children).filter(c => c.localName === name) : [];
const kid = (el, name) => kids(el, name)[0] || null;
const attr = (el, name) => {
if (!el) return null;
for (const a of el.attributes) if (a.localName === name) return a.value;
return null;
};
// ---------- docx ----------
async function docx(buf) {
const z = openZip(buf);
const doc = await z.xml('word/document.xml');
if (!doc) throw new Error('в архиве нет word/document.xml');
// Style ids to names, to know headings whatever the language of Word.
const styles = {};
const st = await z.xml('word/styles.xml');
if (st) for (const s of st.getElementsByTagNameNS('*', 'style')) {
styles[attr(s, 'styleId')] = (attr(kid(s, 'name'), 'val') || '').toLowerCase();
}
const body = doc.getElementsByTagNameNS('*', 'body')[0];
return '
' + blocks(body, styles) + '
';
}
function blocks(el, styles) {
let html = '', list = false;
for (const c of el ? el.children : []) {
if (c.localName === 'p') {
const pPr = kid(c, 'pPr');
const isList = !!kid(pPr, 'numPr');
if (isList && !list) { html += '
'; list = true; }
if (!isList && list) { html += '
'; list = false; }
html += para(c, pPr, styles, isList);
} else {
if (list) { html += ''; list = false; }
if (c.localName === 'tbl') html += table(c, styles);
else if (c.localName === 'sdt') html += blocks(kid(c, 'sdtContent'), styles);
}
}
return html + (list ? '' : '');
}
function para(p, pPr, styles, isList) {
const name = styles[attr(kid(pPr, 'pStyle'), 'val')] || '';
const h = /^heading (\d)/.exec(name);
const tag = isList ? 'li' : name === 'title' ? 'h1' : h ? 'h' + Math.min(6, +h[1] + 1) : 'p';
const jc = attr(kid(pPr, 'jc'), 'val');
const align = jc === 'center' ? 'center' : (jc === 'right' || jc === 'end') ? 'right' : (jc === 'both' ? 'justify' : '');
const inner = runs(p);
return '<' + tag + (align ? ' style="text-align:' + align + '"' : '') + '>' + (inner || ' ') + '' + tag + '>';
}
function runs(el) {
let html = '';
for (const c of el.children) {
if (c.localName === 'r') html += run(c);
else if (c.localName === 'hyperlink' || c.localName === 'smartTag' || c.localName === 'ins' || c.localName === 'sdt' || c.localName === 'sdtContent' || c.localName === 'fldSimple') {
const inner = runs(c);
html += c.localName === 'hyperlink' ? '' + inner + '' : inner;
}
}
return html;
}
function run(r) {
const pr = kid(r, 'rPr');
const on = n => { const e = kid(pr, n); return !!e && attr(e, 'val') !== '0' && attr(e, 'val') !== 'false'; };
let t = '';
for (const c of r.children) {
if (c.localName === 't') t += esc(c.textContent);
else if (c.localName === 'tab') t += ' ';
else if (c.localName === 'br' || c.localName === 'cr') t += ' ';
}
if (!t) return '';
if (on('b')) t = '' + t + '';
if (on('i')) t = '' + t + '';
if (kid(pr, 'u') && attr(kid(pr, 'u'), 'val') !== 'none') t = '' + t + '';
if (on('strike')) t = '' + t + '';
// A size set on the run itself, as many documents do instead of styles.
const sz = +attr(kid(pr, 'sz'), 'val');
if (sz && sz !== 24) t = '' + t + '';
const va = attr(kid(pr, 'vertAlign'), 'val');
if (va === 'superscript') t = '' + t + '';
else if (va === 'subscript') t = '' + t + '';
return t;
}
function table(tbl, styles) {
let html = '
';
for (const tr of kids(tbl, 'tr')) {
html += '
';
for (const tc of kids(tr, 'tc')) {
const span = attr(kid(kid(tc, 'tcPr'), 'gridSpan'), 'val');
html += '
';
}
// ---------- tables: one grid per sheet ----------
const MAX_ROWS = 2000, MAX_COLS = 60;
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
function grid(rows) {
let cols = 0;
for (const r of rows) cols = Math.max(cols, r.length);
cols = Math.min(cols, MAX_COLS);
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
let html = '
';
for (let c = 0; c < cols; c++) html += '
' + colName(c) + '
';
html += '
';
rows.slice(0, MAX_ROWS).forEach((r, i) => {
html += '
' + (i + 1) + '
';
for (let c = 0; c < cols; c++) {
const v = r[c] == null ? '' : r[c];
html += '
' + esc(v) + '
';
}
html += '
';
});
html += '
';
return {html, cut};
}
// Several sheets: tabs over the grid.
function sheets(list) {
if (!list.length) return '
в книге нет листов
';
let html = '';
if (list.length > 1) {
html += '
' + list.map((s, i) => '').join('') + '
';
}
list.forEach((s, i) => {
const g = grid(s.rows);
html += '
' + g.html +
(g.cut ? '
показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов
' : '') + '
';
});
return html;
}
function bindTabs(root) {
root.querySelectorAll('[data-sheet]').forEach(b => b.onclick = () => {
root.querySelectorAll('[data-sheet]').forEach(x => x.classList.toggle('on', x === b));
root.querySelectorAll('[data-pane]').forEach(p => p.hidden = p.dataset.pane !== b.dataset.sheet);
});
}
// ---------- xlsx ----------
async function xlsx(buf) {
const z = openZip(buf);
const wb = await z.xml('xl/workbook.xml');
if (!wb) throw new Error('в архиве нет xl/workbook.xml');
const rels = {};
const rx = await z.xml('xl/_rels/workbook.xml.rels');
if (rx) for (const r of rx.getElementsByTagNameNS('*', 'Relationship')) rels[r.getAttribute('Id')] = r.getAttribute('Target');
const shared = [];
const ss = await z.xml('xl/sharedStrings.xml');
if (ss) for (const si of ss.getElementsByTagNameNS('*', 'si')) {
shared.push(Array.from(si.getElementsByTagNameNS('*', 't')).filter(t => t.parentElement.localName !== 'rPh').map(t => t.textContent).join(''));
}
const list = [];
for (const s of wb.getElementsByTagNameNS('*', 'sheet')) {
let target = rels[attr(s, 'id')] || '';
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target.replace(/^\.\//, '');
const doc = await z.xml(target);
list.push({name: s.getAttribute('name') || '?', rows: doc ? xlsxRows(doc, shared) : []});
}
return sheets(list);
}
function xlsxRows(doc, shared) {
const rows = [];
for (const row of doc.getElementsByTagNameNS('*', 'row')) {
const ri = (+row.getAttribute('r') || rows.length + 1) - 1;
if (ri >= MAX_ROWS) break;
const out = rows[ri] = rows[ri] || [];
let next = 0;
for (const c of kids(row, 'c')) {
const ref = /^([A-Z]+)/.exec(c.getAttribute('r') || '');
let ci = next;
if (ref) { ci = 0; for (const ch of ref[1]) ci = ci * 26 + ch.charCodeAt(0) - 64; ci--; }
next = ci + 1;
if (ci >= MAX_COLS + 1) continue;
const t = c.getAttribute('t'), v = kid(c, 'v');
let val = v ? v.textContent : '';
if (t === 's') val = shared[+val] || '';
else if (t === 'inlineStr') val = Array.from(c.getElementsByTagNameNS('*', 't')).map(x => x.textContent).join('');
else if (t === 'b') val = val === '1' ? 'TRUE' : 'FALSE';
else if (t !== 'str' && t !== 'e' && val !== '' && !isNaN(val)) val = +val;
out[ci] = val;
}
}
for (let i = 0; i < rows.length; i++) rows[i] = rows[i] || [];
return rows;
}
// ---------- ods / odt ----------
async function ods(buf) {
const z = openZip(buf);
const doc = await z.xml('content.xml');
if (!doc) throw new Error('в архиве нет content.xml');
const list = [];
for (const t of doc.getElementsByTagNameNS('*', 'table')) {
if (t.parentElement && t.parentElement.localName === 'table-cell') continue;
const rows = [];
for (const r of t.getElementsByTagNameNS('*', 'table-row')) {
const cells = [];
for (const c of r.children) {
if (c.localName !== 'table-cell' && c.localName !== 'covered-table-cell') continue;
const rep = Math.min(+attr(c, 'number-columns-repeated') || 1, MAX_COLS + 1 - cells.length);
const num = attr(c, 'value');
const text = Array.from(c.getElementsByTagNameNS('*', 'p')).map(p => p.textContent).join('\n');
const val = attr(c, 'value-type') === 'float' && num != null ? +num : text;
for (let i = 0; i < rep; i++) cells.push(val);
if (cells.length > MAX_COLS) break;
}
while (cells.length && cells[cells.length - 1] === '') cells.pop();
const rep = Math.min(+attr(r, 'number-rows-repeated') || 1, MAX_ROWS + 1 - rows.length);
for (let i = 0; i < rep; i++) rows.push(cells);
if (rows.length > MAX_ROWS) break;
}
while (rows.length && !rows[rows.length - 1].length) rows.pop();
list.push({name: attr(t, 'name') || '?', rows});
}
return sheets(list);
}
async function odt(buf) {
const z = openZip(buf);
const doc = await z.xml('content.xml');
const body = doc && doc.getElementsByTagNameNS('*', 'text')[0];
if (!body) throw new Error('в архиве нет текста');
// Runs of spaces, tabs and breaks are elements of their own in ODF.
const inline = el => {
let html = '';
for (const n of el.childNodes) {
if (n.nodeType === 3) html += esc(n.nodeValue);
else if (n.localName === 's') html += ' '.repeat(+attr(n, 'c') || 1);
else if (n.localName === 'tab') html += ' ';
else if (n.localName === 'line-break') html += ' ';
else if (n.nodeType === 1) html += inline(n);
}
return html;
};
const walk = el => {
let html = '';
for (const c of el.children) {
if (c.localName === 'h') { const l = Math.min(6, (+attr(c, 'outline-level') || 1) + 1); html += '' + inline(c) + ''; }
else if (c.localName === 'p') html += '
';
else if (c.localName === 'section') html += walk(c);
}
return html;
};
return '
' + walk(body) + '
';
}
// ---------- csv / tsv ----------
function csv(text, tab) {
const first = text.slice(0, text.indexOf('\n') >>> 0 || text.length);
const sep = tab ? '\t' : (first.split(';').length > first.split(',').length ? ';' : ',');
const rows = [];
let row = [], f = '', q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; }
else f += c;
} else if (c === '"' && f === '') q = true;
else if (c === sep) { row.push(f); f = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(f); f = ''; rows.push(row); row = [];
if (rows.length > MAX_ROWS) break;
} else f += c;
}
if (f !== '' || row.length) { row.push(f); rows.push(row); }
return sheets([{name: '', rows: rows.map(r => r.map(v => v !== '' && !isNaN(v) && /^-?[\d.]+$/.test(v.trim()) ? +v : v))}]);
}
// ---------- old .doc ----------
// Word 97-2003 is a binary format; without a full parser all that can be
// shown is the text, picked out of the document's WordDocument stream as
// the runs that read as words.
function cfbStream(b, want) {
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
if (dv.getUint32(0, true) !== 0xE011CFD0) return null;
const ss = 1 << dv.getUint16(0x1E, true);
const sector = n => 512 + n * ss;
const fat = [];
const addFat = n => { const o = sector(n); for (let i = 0; i < ss / 4; i++) fat.push(dv.getUint32(o + i * 4, true)); };
for (let i = 0; i < 109; i++) { const n = dv.getUint32(0x4C + i * 4, true); if (n < 0xFFFFFFFA) addFat(n); }
for (let n = dv.getUint32(0x44, true), k = 0; n < 0xFFFFFFFA && k < 1000; k++) {
const o = sector(n);
for (let i = 0; i < ss / 4 - 1; i++) { const m = dv.getUint32(o + i * 4, true); if (m < 0xFFFFFFFA) addFat(m); }
n = dv.getUint32(o + ss - 4, true);
}
const chain = (start, size) => {
const out = new Uint8Array(size);
let n = start, at = 0;
for (let guard = 0; n < 0xFFFFFFFA && at < size && guard < 1e6; guard++) {
const o = sector(n), len = Math.min(ss, size - at);
if (o + len > b.length) break;
out.set(b.subarray(o, o + len), at);
at += len;
n = fat[n];
}
return out.subarray(0, at);
};
const dirStart = dv.getUint32(0x30, true);
const dir = chain(dirStart, ss * 64);
const dd = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
for (let o = 0; o + 128 <= dir.length; o += 128) {
const nameLen = dd.getUint16(o + 0x40, true);
let name = '';
for (let i = 0; i + 2 < nameLen; i += 2) name += String.fromCharCode(dd.getUint16(o + i, true));
if (name === want && dd.getUint8(o + 0x42) === 2) return chain(dd.getUint32(o + 0x74, true), dd.getUint32(o + 0x78, true));
}
return null;
}
function doc(buf) {
const all = new Uint8Array(buf);
const b = cfbStream(all, 'WordDocument') || all;
const ok = c => c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || (c >= 0xA0 && c <= 0x24F) || (c >= 0x400 && c <= 0x4FF) || (c >= 0x2010 && c <= 0x2026) || c === 0x2116;
const pieces = [];
let cur = '';
const flush = min => {
const letters = (cur.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
if (cur.trim().length >= min && letters >= cur.trim().length * 0.5) pieces.push(cur);
cur = '';
};
for (let i = 0; i + 1 < b.length; i += 2) {
const c = b[i] | (b[i + 1] << 8);
if (ok(c)) cur += String.fromCharCode(c); else flush(3);
}
flush(4);
let text = pieces.join('\n');
if (text.length < 40) { // a document that keeps its text 8-bit
const p8 = [];
for (let i = 0; i < b.length; i++) {
const c = b[i];
if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127)) cur += String.fromCharCode(c);
else { if (cur.trim().length >= 8 && (cur.match(/[A-Za-z]/g) || []).length >= cur.length * 0.5) p8.push(cur); cur = ''; }
}
text = p8.join('\n');
}
if (!text.trim()) throw new Error('текст не нашёлся');
return '
старый формат .doc: показан только текст, без оформления