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>
54 lines
2.1 KiB
JavaScript
54 lines
2.1 KiB
JavaScript
/** Render sample text in every 16-row font, next to the original TP5 bitmap. */
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { FONTS } from '../src/fonts.js';
|
|
import { fontFromJSON, renderText } from '../src/codec/bitfont.mjs';
|
|
import { parseTd5, frameToPixels } from '../src/codec/td5.mjs';
|
|
import { writePNG } from './png.mjs';
|
|
|
|
const SAMPLE = process.argv[2] ?? 'SCHOOL BUS';
|
|
|
|
const doc = parseTd5(new Uint8Array(readFileSync(new URL('../../test files/EXPRESS 1.td5', import.meta.url))));
|
|
const orig = doc.destinations.find((d) => d.name.startsWith(SAMPLE.slice(0, 10)));
|
|
|
|
const rows = [];
|
|
if (orig) {
|
|
const f = orig.frames[0];
|
|
rows.push({ label: 'TP5 original (Impact 20)', width: f.width, height: f.height, pixels: frameToPixels(f) });
|
|
}
|
|
for (const [name, j] of Object.entries(FONTS)) {
|
|
const font = fontFromJSON(j);
|
|
if (font.height > 16) continue;
|
|
const bmp = renderText(font, SAMPLE);
|
|
rows.push({ label: `${name} (${font.cellWidth}x${font.height})`, ...bmp });
|
|
}
|
|
|
|
const S = 3, PAD = 6, LABEL = 210, SCREEN = 112;
|
|
const maxW = Math.max(...rows.map((r) => r.width));
|
|
const W = LABEL + maxW * S + PAD * 2;
|
|
const H = rows.length * (16 * S + PAD) + PAD;
|
|
|
|
const px = new Map();
|
|
rows.forEach((r, i) => {
|
|
const oy = PAD + i * (16 * S + PAD);
|
|
for (let y = 0; y < r.height; y++)
|
|
for (let x = 0; x < r.width; x++)
|
|
if (r.pixels[y * r.width + x])
|
|
for (let dy = 0; dy < S; dy++)
|
|
for (let dx = 0; dx < S; dx++)
|
|
px.set((oy + y * S + dy) * W + LABEL + x * S + dx, 1);
|
|
});
|
|
|
|
writeFileSync(new URL('./fontpreview.png', import.meta.url), writePNG(W, H, (x, y) => {
|
|
if (px.has(y * W + x)) return [255, 176, 0];
|
|
// mark where the 112px screen ends
|
|
if (x === LABEL + SCREEN * S) return [80, 30, 30];
|
|
return [14, 14, 16];
|
|
}));
|
|
|
|
console.log(`sample: ${JSON.stringify(SAMPLE)} screen is ${SCREEN}px wide`);
|
|
for (const r of rows) {
|
|
const fits = r.width <= SCREEN ? 'fits' : `scrolls (+${r.width - SCREEN}px)`;
|
|
console.log(` ${r.label.padEnd(26)} ${String(r.width).padStart(3)}px ${fits}`);
|
|
}
|
|
console.log('wrote test/fontpreview.png');
|