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>
197 lines
7.3 KiB
JavaScript
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 };
|
|
}
|