destogod/src/codec/sheet.mjs
type-two 07977624ae DestoGod: replacement for the TP5 bus destination sign software
TP5 is a 2012 Windows application supplied with the Guangzhou-Tongda LED
destination signs fitted to Yutong buses. It has no preview, so building a
destination list is guess-and-check, and it only runs on Windows.

The bus never talks to TP5 — the sign controller reads a .td5 file off an SD
card, and that is the whole interface. So this replaces the software without
touching any hardware or protocol: it just has to write byte-correct .td5.

Formats reverse-engineered from the sample files and TP5(En).exe, then verified
byte-for-byte:

  .td5   the file the bus reads. Fixed-layout binary; each destination block
         carries a CRC-16/ARC over block[3..len] and a rand() block id, which
         together looked like one 4-byte field because RAND_MAX is 0x7fff.
  .tp5   the editable project. Line-based text, UTF-16BE hex strings.
  .font  the sign's own bitmap fonts, each glyph row XORed with its char code.

The app is one self-contained HTML file: live LED preview at the real sign size
with real scrolling, spreadsheet/CSV import, multi-page destinations, undoable
delete, and export to both .td5 and .tp5.

Verified:
  - rebuilds a real 46,080-byte TP5 export byte-for-byte with a recomputed CRC
  - all 36 stored CRCs verify against the implementation
  - driven through its own UI, re-exporting the real file differs in 7 bytes,
    all of them the export timestamp
  - running on a real bus: signs and driver's controller both correct

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:06:25 +10:00

197 lines
7.3 KiB
JavaScript

/**
* 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<string[][]>} 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 };
}