/** * Read a spreadsheet without any library. * * .xlsx is a ZIP of XML, so this walks the ZIP central directory, inflates the * two parts that matter with the browser's own DecompressionStream, and pulls * the cells out of the XML. CSV is handled too, for anyone who would rather * save as CSV or is on an older browser. */ const dec = new TextDecoder(); // ------------------------------------------------------------------- zip function findEOCD(buf) { // End-of-central-directory: 'PK\5\6', within the last 64KB. for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65558); i--) { if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) return i; } return -1; } function listEntries(buf) { const eocd = findEOCD(buf); if (eocd < 0) throw new Error('not a zip file'); const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); const count = dv.getUint16(eocd + 10, true); let p = dv.getUint32(eocd + 16, true); const entries = new Map(); for (let i = 0; i < count; i++) { if (dv.getUint32(p, true) !== 0x02014b50) break; const method = dv.getUint16(p + 10, true); const compressedSize = dv.getUint32(p + 20, true); const nameLen = dv.getUint16(p + 28, true); const extraLen = dv.getUint16(p + 30, true); const commentLen = dv.getUint16(p + 32, true); const localOff = dv.getUint32(p + 42, true); const name = dec.decode(buf.subarray(p + 46, p + 46 + nameLen)); entries.set(name, { method, compressedSize, localOff }); p += 46 + nameLen + extraLen + commentLen; } return entries; } async function readEntry(buf, entry) { const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); const { localOff } = entry; if (dv.getUint32(localOff, true) !== 0x04034b50) throw new Error('bad zip entry'); const nameLen = dv.getUint16(localOff + 26, true); const extraLen = dv.getUint16(localOff + 28, true); const start = localOff + 30 + nameLen + extraLen; const data = buf.subarray(start, start + entry.compressedSize); if (entry.method === 0) return dec.decode(data); if (entry.method !== 8) throw new Error(`unsupported compression (${entry.method})`); if (typeof DecompressionStream !== 'function') { throw new Error('this browser cannot open .xlsx files — save the sheet as CSV instead'); } const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')); return dec.decode(new Uint8Array(await new Response(stream).arrayBuffer())); } // ----------------------------------------------------------------- xlsx const colIndex = (ref) => { let n = 0; for (const ch of ref) { const c = ch.charCodeAt(0); if (c < 65 || c > 90) break; n = n * 26 + (c - 64); } return n - 1; }; function parseXml(text) { const doc = new DOMParser().parseFromString(text, 'application/xml'); if (doc.querySelector('parsererror')) throw new Error('could not read the spreadsheet XML'); return doc; } /** @returns {Promise} rows of plain strings from the first sheet */ export async function readXlsx(bytes) { const entries = listEntries(bytes); const sheetName = entries.has('xl/worksheets/sheet1.xml') ? 'xl/worksheets/sheet1.xml' : [...entries.keys()].find((n) => /^xl\/worksheets\/.*\.xml$/.test(n)); if (!sheetName) throw new Error('no worksheet found in that file'); let shared = []; if (entries.has('xl/sharedStrings.xml')) { const doc = parseXml(await readEntry(bytes, entries.get('xl/sharedStrings.xml'))); shared = [...doc.getElementsByTagName('si')].map((si) => [...si.getElementsByTagName('t')].map((t) => t.textContent).join('')); } const doc = parseXml(await readEntry(bytes, entries.get(sheetName))); const rows = []; for (const row of doc.getElementsByTagName('row')) { const cells = []; for (const c of row.getElementsByTagName('c')) { const type = c.getAttribute('t'); const ref = c.getAttribute('r') || ''; const at = ref ? colIndex(ref) : cells.length; let value = ''; if (type === 'inlineStr') { value = [...c.getElementsByTagName('t')].map((t) => t.textContent).join(''); } else { const v = c.getElementsByTagName('v')[0]; const raw = v ? v.textContent : ''; value = type === 's' ? (shared[Number(raw)] ?? '') : raw; } while (cells.length < at) cells.push(''); cells[at] = (value ?? '').trim(); } rows.push(cells); } return rows; } // ------------------------------------------------------------------ csv export function readCsv(text) { const rows = []; let row = [], field = '', quoted = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (quoted) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else quoted = false; } else field += ch; continue; } if (ch === '"') quoted = true; else if (ch === ',') { row.push(field.trim()); field = ''; } else if (ch === '\n') { row.push(field.trim()); rows.push(row); row = []; field = ''; } else if (ch !== '\r') field += ch; } if (field || row.length) { row.push(field.trim()); rows.push(row); } return rows; } // -------------------------------------------------------------- mapping const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, ''); /** * Work out which columns hold the controller name and the sign text. * The template that ships with these buses uses * `Line | Line Name | Description | Content (Display)`. */ export function mapRows(rows) { let company = ''; for (const r of rows.slice(0, 5)) { const i = r.findIndex((c) => norm(c).startsWith('companyname')); if (i >= 0) { company = (r[i + 1] || '').trim(); break; } } const headerAt = rows.findIndex((r) => r.some((c) => { const n = norm(c); return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination'; })); let nameCol = -1, textCol = -1, start = 0; if (headerAt >= 0) { const head = rows[headerAt].map(norm); // Search by priority, not by column order: the shipped template has both a // "Line" (a row number) and a "Line Name", and the name must win. const pick = (...wanted) => { for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; } return -1; }; nameCol = pick('linename', 'name', 'destinationname', 'route', 'line'); textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message'); start = headerAt + 1; } const out = []; for (const r of rows.slice(start)) { if (!r.length || r.every((c) => !c)) continue; const cells = r.filter((c) => c !== ''); let name = nameCol >= 0 ? (r[nameCol] || '') : ''; let text = textCol >= 0 ? (r[textCol] || '') : ''; if (!name && !text) { // no recognisable header — take what is there text = cells[cells.length - 1] || ''; name = cells.length > 1 ? cells[0] : text; } if (!text) text = name; // A bare row number is a line code, not something worth showing a driver. if (/^\d+$/.test(name) && text && text !== name) name = text; if (!name) name = text; if (!text.trim()) continue; if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) continue; out.push({ name: name.trim().slice(0, 16), text: text.trim() }); } return { company, destinations: out }; }