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>
This commit is contained in:
type-two 2026-08-21 13:06:25 +10:00
commit 07977624ae
33 changed files with 6214 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
node_modules/
# generated by the test scripts
test/authored.td5
test/fontpreview.png

239
README.md Normal file
View File

@ -0,0 +1,239 @@
# DestoGod
A replacement for **TP5**, the 2012 Windows program used to program the LED
destination signs on Yutong buses.
`dist/DestoGod.html` is the whole application — one file, no installer, no
internet. Double-click it and it opens in any browser.
---
## Why this exists
TP5 is a 417 KB Chinese MFC application from November 2012, supplied by
Guangzhou Tongda with the signs Yutong fits to its buses. It has no live
preview, so building a destination list is guess-and-check, and the workflow is
Windows-only.
The bus never talks to TP5. The sign controller reads a `.td5` file off an SD
card, and that is the entire interface. So a replacement does not need to touch
any hardware or protocol — it only has to write byte-correct `.td5` files.
That is what this does.
## What it does
- **Opens the files the company already has.** Drop in a `.td5` (what goes on
the SD card) or a `.tp5` (the TP5 project). Drop in both and it uses the text
from the project with the exact artwork from the bus file.
- **Live sign preview.** The message is drawn as an LED matrix at the real size
of the real sign, and scrolls exactly as it will on the bus. TP5 shows you
nothing until you export.
- **Tells you when a message will not fit** and by how many pixels.
- **Imports a spreadsheet.** Drop in an `.xlsx` (or CSV) and it reads the
`Line | Line Name | Description | Content (Display)` layout of the template
these buses ship with — `Line Name` becomes what the driver sees on the
controller, `Content (Display)` becomes what the sign shows. Names that
already exist are updated rather than duplicated. No library involved: the
`.xlsx` is unzipped and parsed by the browser itself.
- **Paste a whole list at once** instead of typing destinations one at a time.
- **Delete a destination** with the × on its row in the list, or clear the whole
list at once. Both offer an Undo instead of asking you to confirm first — a
confirm box is friction while tidying a list and still leaves you stuck if you
confirm by mistake.
- **Refuses to ship a blank destination.** Tapping "+" one time too many leaves
an entry with nothing in it; those are flagged in the list and the export
stops with a one-click "Remove and export". A new destination starts genuinely
empty rather than pre-filled with placeholder text, because placeholder text
is worse than blank here — "NEW DESTINATION" will happily print itself on the
front of a bus.
- **Keeps the original artwork** for any destination you have not edited, so
re-exporting an existing list changes nothing about it.
- **Exports `.td5`** for the SD card, and **`.tp5`** so the old software can
still open the list during the changeover.
## How the file formats were worked out
Everything below was derived from the sample files and from `TP5(En).exe`
itself, then verified byte-for-byte.
### `.td5` — the file the bus reads
```
FILE
0x000 16 magic, GBK "广州通达图形线路"
0x010 4 "V5.0"
0x018 12 export timestamp, ASCII YYMMDDhhmmss
0x030 2 company count
0x040 2 sign height, sign width in bytes (16, 14 = 112x16)
0x080 16 company name \ repeats every 0x20 per company
0x090 6 pointer-table offset, destination count
0x200 .. u32 offset per destination, terminated by 0xffffffff
0x400 .. destination records; unused space is filled with 0xff
DESTINATION record header is 0x80 bytes; its block always starts at +0x200
+0x00 16 name
+0x10 16 name again — THIS is the copy the driver's controller displays,
and TP5 always writes it identical to the first (verified across
all 36 records). If the two are allowed to differ, the sign shows
the right words but the controller shows the wrong ones. See
setName() in app/main.mjs; export forces them equal, and opening
a file repairs a pair that has already drifted.
+0x30 8 block offset, block length
records are padded up to the next 0x200 boundary
BLOCK
+0x00 1 0x43 'C'
+0x01 2 CRC-16/ARC over block[3 .. blockLength]
+0x03 4 block id — TP5 stores rand(), so it never exceeds 0x7fff
+0x07 4 block length
+0x0b 3 frame count, three times
+0x0f 1 0x84
+0x10 6 per frame: u32 bitmap offset, u8 width in bytes, u8 height
.. two 0x80-byte action sections per frame
.. the frame bitmaps
header length = 0x10 + frames*6 + frames*0x100
BITMAP one 16-byte chunk per 8 pixels of width; each chunk is 8 columns x 16
rows, row-major, high bit leftmost. Wider than the sign means it scrolls.
```
The checksum was the last piece. Bytes `+5` and `+6` are always zero, which hid
a field boundary: `+1` is a 2-byte CRC and `+3` is a 4-byte random id whose top
half is always zero because `RAND_MAX` is `0x7fff`. The CRC is CRC-16/ARC
(reflected, polynomial `0xa001`, init 0), taken over everything from `+3` to the
end of the block. TP5 computes it in `sub_409150`, picking polynomial index 1
out of the table at `0x447020` = `{0x8480, 0xa001, 0x8621, 0xe950}`, and stores
it in `SetTypeAndCrc` at `0x4353c0`.
Because the id is random and sits inside the CRC's range, two exports of the
same list are never byte-identical — but any id you choose is valid.
### `.tp5` — the editable project
Line-based text, CRLF, one statement per line. Strings are UTF-16BE hex. Each
destination has five screens (front, rear, side, inner, backside), each with
"up" and "down" action lists, each action holding five icons that can carry text
objects. Only the front screen is used in practice.
### `.font` — the sign's built-in fonts
`name`, then `cellWidth,height`, then per glyph: character code, advance width,
and `height * ceil(cellWidth/8)` bytes — **each byte XORed with the character
code**. All 18 shipped fonts are included in the app.
### Rendering to match the existing signs
The company's current signs were made with the Windows font Impact, which TP5
hands to GDI. A browser canvas rasterises the same font differently, so the
renderer is calibrated against the real output: TP5's size 20 corresponds to
**15.77 canvas pixels** (scale 0.789, measured across all 36 destinations), and
the alpha threshold is **225**, because GDI lays down far less antialiasing than
canvas does. At those settings the rendered stroke weight, width and height all
land within 2% of TP5's own output.
Those numbers were measured on a Mac, and macOS, Windows and Linux all rasterise
fonts slightly differently — so they are only the starting point. When both a
`.tp5` and a `.td5` are loaded the app knows the text *and* the exact artwork TP5
produced for it, so it solves for the size and threshold that reproduce that
artwork **on whatever computer it is running on**, and says so in the preview.
On the development Mac it settles on 0.804 / 245 — a closer match than the
hand-tuned constants. Nothing about this depends on the machine being the one it
was written on.
## Which computers it runs on
Any reasonably current browser — Edge, Chrome, Brave or Firefox — on Windows,
macOS or Linux. Edge is already on every Windows 10/11 machine, so on a depot PC
there is nothing to install. Verified opening straight off the disk with no
server (`file://`).
Two things are worth knowing:
- **Nothing about the exported file depends on the operating system.** The
`.td5` writer is pure integer work: same list in, same bytes out, on any
machine. The sign's own built-in fonts are bitmaps, so they are identical
everywhere too. Only the system-font (Impact) path touches the OS rasteriser,
and that is what the calibration above corrects for.
- **Impact ships with Windows**, and always has. It is the font TP5 was using
via GDI, so on a Windows machine the lettering has a shorter distance to
travel than it did here.
The floor is a browser from roughly 2020 (Chrome/Edge 80+, Firefox 75+). It
deliberately avoids the newest APIs — no `structuredClone`, and it falls back
gracefully if `<dialog>` is unsupported — so an older depot PC is fine.
## Verification
```bash
node test/roundtrip.mjs # re-build a real TP5 export and compare byte-for-byte
node test/tp5roundtrip.mjs # same for the project format
node test/authoring.mjs # build a file from scratch, read it back, check CRCs
node test/sheet.mjs # spreadsheet column mapping
node test/fontpreview.mjs "SCHOOL BUS" # render sample text in every built-in font
```
`roundtrip.mjs` parses the real `EXPRESS 1.td5`, rebuilds all 46,080 bytes from
the parsed model with a **recomputed** CRC, and requires an exact match. All 36
stored CRCs verify against the implementation.
Driven through its own interface — open the project, merge the artwork, press
Export — the app reproduces the company's real file with **7 differing bytes,
all of them the export timestamp**, and no other difference anywhere.
Retyping a single destination and exporting again changes **only that
destination's block** (plus the timestamp). The other 35 stay byte-identical, so
editing one entry cannot disturb the rest of the list.
## On the road
DestoGod files have been loaded onto a real bus. **The signs display correctly
and the driver's controller lists the destinations correctly** — the format is
confirmed against the actual hardware, not just against TP5's output.
The first road test found the one thing no amount of byte-comparison would have
caught: the destination name is stored twice, and the driver's controller reads
the *second* copy. Renaming a destination updated only the first, so the signs
were right while the controller showed nothing useful. Fixed, forced to stay in
step on both export and import, and confirmed on the bus afterwards.
Still worth doing when convenient: export the same list from DestoGod and from
TP5 and compare. They should differ only in the timestamp and the random block
ids (and the CRCs that follow from them).
### What is on the SD card
Only the `.td5`. TP5 also leaves a folder tree next to it —
`<export name>/<company>/<destination>/`, one folder per destination — but
every one of those 36 folders is empty, so it is scratch output, not something
the controller reads. Checked directly rather than assumed, because it was the
obvious suspect when the controller names went missing.
## Building
```bash
node tools/bundle.mjs # everything -> dist/DestoGod.html
```
`src/fonts.js` is committed, so a fresh clone builds and runs without anything
else. Regenerating it needs the original `.font` files that ship beside TP5,
which are not in this repo:
```bash
node tools/build-fonts.mjs # ../../yutongapp/*.font -> src/fonts.js
```
## Layout
```
src/codec/td5.mjs read and write the file the bus reads
src/codec/tp5.mjs read and write the TP5 project file
src/codec/bitfont.mjs the sign's built-in bitmap fonts
src/codec/sheet.mjs .xlsx / .csv reading, no dependencies
src/fonts.js generated from the 18 shipped .font files
app/ the interface
tools/ font conversion, single-file bundler
test/ the checks described above
re/ the reverse-engineering scripts, kept as working notes
dist/DestoGod.html ← the thing to actually use
```

198
app/index.html Normal file
View File

@ -0,0 +1,198 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DestoGod — Bus Destination Sign Editor</title>
<style>
:root{
--bg:#14161a; --panel:#1c1f26; --panel2:#232732; --line:#31374a;
--ink:#e8ecf5; --dim:#98a2b8; --amber:#ffb000; --amber-dim:#3a2a05;
--accent:#4a9eff; --good:#3ddc84; --warn:#ffb74d; --bad:#ff6b6b;
--r:10px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background:var(--bg); color:var(--ink);
font:14px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex; flex-direction:column; overflow:hidden;
}
button,input,select,textarea{font:inherit;color:inherit}
button{
background:var(--panel2); border:1px solid var(--line); color:var(--ink);
padding:8px 14px; border-radius:8px; cursor:pointer; white-space:nowrap;
}
button:hover:not(:disabled){background:#2c3242; border-color:#465073}
button:disabled{opacity:.4; cursor:default}
button.primary{background:var(--accent); border-color:var(--accent); color:#08131f; font-weight:650}
button.primary:hover:not(:disabled){background:#68b0ff}
button.ghost{background:transparent}
button.danger:hover:not(:disabled){background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
input[type=text],input[type=number],select,textarea{
background:#12141a; border:1px solid var(--line); border-radius:8px; padding:8px 10px; width:100%;
}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent); outline-offset:-1px; border-color:transparent}
label{display:block; font-size:12px; color:var(--dim); margin-bottom:5px; font-weight:600; letter-spacing:.02em}
/* ---------- top bar ---------- */
header{
display:flex; align-items:center; gap:14px; padding:12px 18px;
background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; flex-wrap:wrap;
}
.brand{display:flex; align-items:baseline; gap:9px; margin-right:4px}
.brand b{font-size:19px; letter-spacing:-.02em}
.brand span{font-size:11px; color:var(--dim)}
.field{display:flex; align-items:center; gap:7px}
.field label{margin:0}
.field input,.field select{width:auto; min-width:120px}
.spacer{flex:1}
/* ---------- layout ---------- */
main{flex:1; display:grid; grid-template-columns:290px 1fr; min-height:0}
#sidebar{
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; min-height:0;
}
.side-head{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:11px 12px; border-bottom:1px solid var(--line);
}
.side-head h2{margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
#list{overflow-y:auto; flex:1; padding:6px}
.item{
display:flex; align-items:center; gap:9px; padding:7px 9px; border-radius:8px;
cursor:pointer; border:1px solid transparent;
}
.item:hover{background:var(--panel2)}
.item.sel{background:#20304a; border-color:#37517d}
.item .num{font-size:10px; color:var(--dim); width:20px; text-align:right; flex:0 0 auto; font-variant-numeric:tabular-nums}
.item .body{min-width:0; flex:1}
.item .nm{font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600}
.item canvas{display:block; image-rendering:pixelated; margin-top:3px; opacity:.85}
.item .tag{font-size:10px; color:var(--dim)}
.item .tag.warn{color:var(--warn)}
.item .del{
flex:0 0 auto; width:24px; height:24px; padding:0; line-height:1; font-size:16px;
border-radius:6px; background:transparent; border:1px solid transparent; color:var(--dim);
opacity:0; transition:opacity .12s ease;
}
.item:hover .del,.item.sel .del{opacity:1}
.item .del:hover{background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
.empty{padding:26px 16px; text-align:center; color:var(--dim); font-size:13px}
#toast button{
margin-left:12px; padding:3px 11px; font-size:12px; font-weight:650;
background:var(--accent); border-color:var(--accent); color:#08131f;
}
/* ---------- editor ---------- */
#editor{overflow-y:auto; padding:20px 24px; min-height:0}
.wrap{max-width:900px; margin:0 auto}
.card{background:var(--panel); border:1px solid var(--line); border-radius:var(--r); padding:18px; margin-bottom:16px}
.card > h3{margin:0 0 14px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
.row{display:flex; gap:12px; flex-wrap:wrap}
.row > *{flex:1; min-width:150px}
/* ---------- LED preview ---------- */
.led-stage{
background:#000; border:1px solid #2a2f3d; border-radius:8px; padding:14px;
display:flex; justify-content:center; overflow:hidden;
}
.led-stage canvas{display:block; image-rendering:pixelated}
.meta{display:flex; gap:14px; flex-wrap:wrap; margin-top:11px; font-size:12px; color:var(--dim)}
.meta b{color:var(--ink); font-weight:600}
.pill{
display:inline-flex; align-items:center; gap:5px; padding:2px 9px; border-radius:20px;
font-size:11px; font-weight:650; border:1px solid;
}
.pill.ok{color:var(--good); border-color:#235b3c; background:#12251b}
.pill.scroll{color:var(--warn); border-color:#6b4d1c; background:#241c0c}
.pill.bad{color:var(--bad); border-color:#6b2b2b; background:#241111}
/* ---------- pages ---------- */
.page{border:1px solid var(--line); border-radius:9px; padding:14px; margin-bottom:11px; background:var(--panel2)}
.page-head{display:flex; align-items:center; gap:10px; margin-bottom:11px}
.page-head .lbl{font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--dim); font-weight:700}
.seg{display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden}
.seg button{border:0; border-radius:0; padding:7px 13px; background:transparent; font-size:13px}
.seg button.on{background:var(--accent); color:#08131f; font-weight:650}
.hint{font-size:12px; color:var(--dim); margin-top:7px}
.hint.warn{color:var(--warn)}
/* ---------- modal / drop ---------- */
dialog{
border:1px solid var(--line); background:var(--panel); color:var(--ink);
border-radius:var(--r); padding:0; max-width:560px; width:92vw;
}
dialog::backdrop{background:#000a; backdrop-filter:blur(2px)}
dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px}
dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)}
textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center;
z-index:99; font-size:20px; color:var(--accent); border:3px dashed var(--accent); border-radius:14px;
}
#drop.on{display:grid}
#toast{
position:fixed; bottom:20px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--panel2); border:1px solid var(--line); padding:11px 18px; border-radius:9px;
transition:transform .22s ease, opacity .22s ease; opacity:0; z-index:100; font-size:13px; max-width:80vw;
}
#toast.on{transform:translateX(-50%) translateY(0); opacity:1}
#toast.err{border-color:#7d3b43; color:#ffc9c9}
.welcome{max-width:560px; margin:8vh auto; text-align:center}
.welcome h1{font-size:26px; margin:0 0 10px}
.welcome p{color:var(--dim); margin:0 0 22px}
.welcome .btns{display:flex; gap:10px; justify-content:center; flex-wrap:wrap}
kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:11px}
</style>
</head>
<body>
<header>
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="spacer"></div>
<button id="btn-open">Open file…</button>
<button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button>
<button id="btn-export-tp5">Save project (.tp5)</button>
<button class="primary" id="btn-export">Export for bus (.td5)</button>
<input type="file" id="file" accept=".td5,.tp5,.xlsx,.xlsm,.csv" hidden>
</header>
<main>
<aside id="sidebar">
<div class="side-head">
<h2>Destinations (<span id="count">0</span>)</h2>
<div style="display:flex;gap:6px">
<button class="ghost" id="btn-bulk" title="Add many at once">Paste list</button>
<button class="ghost" id="btn-add" title="Add one">+</button>
<button class="ghost danger" id="btn-clear" title="Delete every destination (you can undo)">Clear</button>
</div>
</div>
<div id="list"></div>
</aside>
<section id="editor"></section>
</main>
<div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div>
<div id="toast"></div>
<dialog id="bulk">
<div class="dlg-body">
<h3>Paste a list of destinations</h3>
<p>One per line. This is the fast way to build a whole list — far quicker than adding them one at a time.</p>
<textarea id="bulk-text" placeholder="SCHOOL BUS&#10;CHARTER&#10;RAIL BUS&#10;CARMEL COLLEGE"></textarea>
</div>
<div class="dlg-foot">
<button id="bulk-cancel" class="ghost">Cancel</button>
<button id="bulk-ok" class="primary">Add them</button>
</div>
</dialog>
<script type="module" src="./main.mjs"></script>
</body>
</html>

953
app/main.mjs Normal file
View File

@ -0,0 +1,953 @@
/** DestoGod — editor for Yutong / Guangzhou-Tongda bus destination signs. */
import { FONTS } from '../src/fonts.js';
import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs';
import { parseTd5, buildTd5, frameToPixels, pixelsToFrame, EFFECT } from '../src/codec/td5.mjs';
import { parseTp5, buildTp5, toSimple } from '../src/codec/tp5.mjs';
import { readXlsx, readCsv, mapRows } from '../src/codec/sheet.mjs';
// Sign sizes from the Yutong programming manual (section 2.2).
const MODELS = [
{ label: 'ZK6930 / ZK6938 — 112 × 16', width: 112, height: 16 },
{ label: 'ZK6760 — 80 × 16', width: 80, height: 16 },
{ label: 'ZK6129 / T12 — 144 × 16', width: 144, height: 16 },
{ label: 'Other — 128 × 16', width: 128, height: 16 },
{ label: 'Other — 160 × 16', width: 160, height: 16 },
{ label: 'Other — 192 × 16', width: 192, height: 16 },
{ label: 'Other — 256 × 16', width: 256, height: 16 },
];
const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma'];
/**
* TP5 hands the point size to Windows GDI, which maps it to a smaller em size
* than a browser canvas does for the same number. Measured against all 36
* destinations in the sample project: TP5 size 20 renders at 15.77 canvas px
* (spread 14.7-16.2, which is just per-string hinting noise). Keeping sizes in
* TP5's units means a .tp5 we write stays compatible with the old software.
*/
const TP5_SIZE_SCALE = 0.789;
let state = {
company: '',
screen: { ...MODELS[0] },
destinations: [],
selected: null,
tp5Template: null, // keeps the untouched project structure when a .tp5 was opened
};
let uid = 1;
const nextId = () => `d${uid++}`;
// --------------------------------------------------------------- rendering
/** Rasterise one page into {width, height, pixels}. */
function renderPage(page, screen) {
if (page.bitmap && page.pristine) return page.bitmap;
if (!page.text.trim()) return { width: 0, height: screen.height, pixels: new Uint8Array(0) };
const bmp = page.fontKind === 'builtin'
? renderBuiltin(page, screen)
: renderSystemFont(page, screen);
page.bitmap = bmp;
return bmp;
}
function renderBuiltin(page, screen) {
const json = FONTS[page.font] ?? FONTS.ASC1609;
const font = fontFromJSON(json);
const bmp = renderText(font, page.text, { tracking: page.tracking ?? 1 });
return fitVertically(trimX(bmp, 0), screen.height);
}
/**
* Render with a Windows/macOS font through a canvas and threshold to 1 bit.
* This is how the existing signs were made (TP5 hands the text to GDI), so it
* keeps a converted list looking like what the company already runs.
*/
function renderSystemFont(page, screen) {
const cal = state.calibration;
const useCal = cal && cal.family === page.ttfName;
const scale = useCal ? cal.scale : TP5_SIZE_SCALE;
const threshold = useCal ? cal.threshold : (page.threshold ?? 225);
const ink = rasteriseSystem(page.text, page.ttfName, (page.ptSize ?? 20) * scale, threshold, page.bold);
return fitVertically(ink, screen.height);
}
/** Draw text with a system font and cut it to 1 bit, trimmed to the ink. */
function rasteriseSystem(text, family, px, threshold, bold = false) {
const pad = 8;
const css = `${bold ? 'bold ' : ''}${px}px "${family}", sans-serif`;
const cv = document.createElement('canvas');
let ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.font = css;
cv.width = Math.max(1, Math.ceil(ctx.measureText(text).width) + pad * 2);
cv.height = Math.max(1, Math.ceil(px * 3));
ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.font = css;
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(text, pad, cv.height / 2);
const img = ctx.getImageData(0, 0, cv.width, cv.height).data;
const pixels = new Uint8Array(cv.width * cv.height);
for (let i = 0; i < pixels.length; i++) pixels[i] = img[i * 4 + 3] > threshold ? 1 : 0;
return trimX(trimY({ width: cv.width, height: cv.height, pixels }), 0);
}
const countLit = (b) => b.pixels.reduce((n, v) => n + v, 0);
function inkStats(bmp) {
const t = trimX(trimY(bmp), 0);
return { w: t.width, h: t.height, lit: countLit(t) };
}
/**
* The one part of this that depends on the computer is how the browser turns a
* system font into pixels macOS, Windows and Linux all rasterise slightly
* differently, and TP5 itself used Windows GDI. So rather than trust a constant
* measured on one machine, measure against the company's own signs: we know the
* text (from the .tp5) and the exact artwork TP5 produced (from the .td5), so we
* can solve for the size and threshold that reproduce it on THIS machine.
*/
function calibrateSystemFont(samples) {
if (samples.length < 3) return null;
const family = samples[0].family;
let best = null;
for (const threshold of [128, 170, 200, 225, 245]) {
// first pass: what size reproduces the original widths?
const ratios = samples
.map((s) => {
const r = rasteriseSystem(s.text, family, s.ptSize * TP5_SIZE_SCALE, threshold);
return r.width ? s.w / r.width : 0;
})
.filter(Boolean)
.sort((a, b) => a - b);
if (!ratios.length) continue;
const scale = TP5_SIZE_SCALE * ratios[Math.floor(ratios.length / 2)];
// second pass: at that size, how close is the stroke weight?
let lit = 0, wErr = 0, hErr = 0, n = 0;
for (const s of samples) {
const r = rasteriseSystem(s.text, family, s.ptSize * scale, threshold);
if (!r.width || !s.lit) continue;
lit += countLit(r) / s.lit;
wErr += Math.abs(r.width - s.w) / s.w;
hErr += Math.abs(r.height - s.h) / s.h;
n++;
}
if (!n) continue;
const litRatio = lit / n;
const score = Math.abs(litRatio - 1) + wErr / n + hErr / n;
if (!best || score < best.score) {
best = { family, scale, threshold, litRatio, widthErr: wErr / n, heightErr: hErr / n, score, samples: n };
}
}
return best;
}
function trimY(b) {
let lo = b.height, hi = -1;
for (let y = 0; y < b.height; y++) {
for (let x = 0; x < b.width; x++) {
if (b.pixels[y * b.width + x]) { if (y < lo) lo = y; if (y > hi) hi = y; break; }
}
}
if (hi < 0) return { width: 0, height: 0, pixels: new Uint8Array(0) };
const h = hi - lo + 1;
const out = new Uint8Array(b.width * h);
out.set(b.pixels.subarray(lo * b.width, (hi + 1) * b.width));
return { width: b.width, height: h, pixels: out };
}
/** Centre a bitmap in a canvas `height` rows tall, cropping if it is too tall. */
function fitVertically(b, height) {
if (b.height === height) return b;
const out = new Uint8Array(b.width * height);
const y0 = Math.floor((height - b.height) / 2);
for (let y = 0; y < b.height; y++) {
const ty = y + y0;
if (ty < 0 || ty >= height) continue;
out.set(b.pixels.subarray(y * b.width, (y + 1) * b.width), ty * b.width);
}
return { width: b.width, height, pixels: out };
}
// ------------------------------------------------------------- LED display
/** Draw a bitmap as an LED matrix, windowed to the sign and optionally scrolling. */
function drawLED(canvas, bmp, screen, { scale = 4, offset = 0, gap = 1 } = {}) {
const cw = screen.width, chh = screen.height;
const cell = scale + gap;
canvas.width = cw * cell;
canvas.height = chh * cell;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const scrolling = bmp.width > cw;
// A message that fits is centred, exactly as the sign controller shows it.
const x0 = scrolling ? -offset : Math.floor((cw - bmp.width) / 2);
for (let y = 0; y < chh; y++) {
for (let x = 0; x < cw; x++) {
let sx = x - x0;
if (scrolling) sx = ((sx % (bmp.width + cw)) + (bmp.width + cw)) % (bmp.width + cw);
const on = sx >= 0 && sx < bmp.width && bmp.height > 0 && bmp.pixels[y * bmp.width + sx];
ctx.fillStyle = on ? '#ffb000' : '#1a1508';
ctx.fillRect(x * cell, y * cell, scale, scale);
}
}
return scrolling;
}
let animTimer = null;
function startPreview(canvas, bmp, screen, effect) {
clearInterval(animTimer);
const scrolls = bmp.width > screen.width && effect === EFFECT.SCROLL;
if (!scrolls) { drawLED(canvas, bmp, screen, { scale: 6 }); return; }
let off = 0;
const span = bmp.width + screen.width;
animTimer = setInterval(() => {
drawLED(canvas, bmp, screen, { scale: 6, offset: off });
off = (off + 1) % span;
}, 45);
}
// ------------------------------------------------------------------ import
function newPage(text = '') {
return {
text,
fontKind: 'system',
ttfName: 'Impact',
ptSize: 20,
bold: false,
threshold: 225,
font: 'ASC1609',
tracking: 1,
effect: EFFECT.SCROLL,
bitmap: null,
pristine: false,
};
}
function newDestination(name = 'NEW DESTINATION') {
return { id: nextId(), name: name.slice(0, 16), lineName: name.slice(0, 16), pages: [newPage(name)], blockId: null };
}
function loadTd5(bytes) {
const doc = parseTd5(bytes);
state.company = doc.company;
state.screen = { ...(MODELS.find((m) => m.width === doc.screen.width) ?? doc.screen) };
state.tp5Template = null;
state.destinations = doc.destinations.map((d) => ({
id: nextId(),
name: d.name,
lineName: d.name, // repairs a file whose two name copies drifted apart
blockId: d.blockId,
pages: d.frames.map((f, i) => {
const p = newPage('');
p.bitmap = { width: f.width, height: f.height, pixels: frameToPixels(f) };
p.pristine = true; // keep TP5's exact artwork until edited
p.effect = d.effects[i] ?? EFFECT.SCROLL;
return p;
}),
}));
return `Opened ${state.destinations.length} destinations from the bus file.`;
}
function loadTp5(text) {
const doc = parseTp5(text);
const simple = toSimple(doc);
state.company = simple.company;
state.screen = { ...(MODELS.find((m) => m.width === simple.screen.width) ?? simple.screen) };
state.tp5Template = doc;
state.destinations = simple.destinations.map((d) => ({
id: nextId(),
name: d.name,
lineName: d.name, // repairs a file whose two name copies drifted apart
blockId: null,
pages: d.frames.map((f) => {
const p = newPage(f.text);
p.effect = f.effect;
if (f.useTrueType && f.ttfName) { p.fontKind = 'system'; p.ttfName = f.ttfName; p.ptSize = f.ptSize; }
else { p.fontKind = 'builtin'; p.font = FONTS[f.font] ? f.font : 'ASC1609'; }
return p;
}),
}));
return `Opened ${state.destinations.length} destinations, with their text, from the project file.`;
}
/** A .tp5 carries the text; a .td5 carries the exact artwork. Merge by name. */
function mergeArtworkFromTd5(bytes) {
const doc = parseTd5(bytes);
let hits = 0;
const samples = [];
for (const d of doc.destinations) {
const target = state.destinations.find((x) => x.name.trim() === d.name.trim());
if (!target) continue;
d.frames.forEach((f, i) => {
const page = target.pages[i];
if (!page) return;
const bitmap = { width: f.width, height: f.height, pixels: frameToPixels(f) };
page.bitmap = bitmap;
page.pristine = true;
hits++;
if (page.fontKind === 'system' && page.text.trim()) {
const s = inkStats(bitmap);
if (s.w && s.lit) samples.push({ text: page.text, family: page.ttfName, ptSize: page.ptSize, ...s });
}
});
target.blockId = d.blockId;
}
// Calibrate the lettering against the signs this company already runs.
const dominant = samples.filter((s) => s.family === (samples[0]?.family ?? ''));
state.calibration = calibrateSystemFont(dominant.slice(0, 24));
return hits;
}
// ------------------------------------------------------------------ export
function toTd5Doc() {
const dests = state.destinations.map((d) => {
const frames = d.pages.map((p) => {
const bmp = renderPage(p, state.screen);
const width = Math.max(8, bmp.width);
const padded = bmp.width === width ? bmp
: { width, height: state.screen.height, pixels: new Uint8Array(width * state.screen.height) };
return pixelsToFrame(padded.pixels, padded.width, state.screen.height);
});
const nf = frames.length;
const effects = [];
for (let k = 0; k < nf * 2; k++) effects.push(d.pages[k % nf].effect);
return {
// Both copies always carry the same text, as TP5 writes them. The second
// is what the driver's controller displays.
name: d.name, lineName: d.name,
frames, effects, blockId: d.blockId ?? undefined,
};
});
return { company: state.company, screen: state.screen, destinations: dests };
}
function exportTd5() {
if (!state.destinations.length) return toast('Nothing to export yet.', true);
// Don't let a stray empty destination reach a bus: the driver would see a
// selectable entry that displays nothing.
const blanks = state.destinations.filter(isBlank);
if (blanks.length) {
return toast(
`${blanks.length} destination${blanks.length === 1 ? '' : 's'} ${blanks.length === 1 ? 'has' : 'have'} no text — ` +
`${blanks.length === 1 ? 'it' : 'they'} would show as blank on the sign.`,
true,
'Remove and export',
() => { for (const b of blanks) state.destinations.splice(state.destinations.indexOf(b), 1); renderAll(); exportTd5(); },
);
}
// TP5 makes you create a company before you can add destinations, so a file
// with a blank one is a shape the controller has never been given. Cheap to
// avoid: fill it in rather than shipping an empty field.
if (!state.company.trim()) {
state.company = 'DESTINATIONS';
renderHeader();
toast("Company name was empty, so 'DESTINATIONS' was used — change it at the top if you want something else.");
}
const bytes = buildTd5(toTd5Doc());
download(bytes, `${safeName(state.company || 'destinations')}.td5`, 'application/octet-stream');
toast(`Exported ${state.destinations.length} destinations. Copy this file to the SD card.`);
}
/**
* Save a .tp5 so the old TP5 software can still open the list. When the project
* was opened from a .tp5 we edit that structure in place, which keeps every
* field we do not model (icons, rear/side screens) exactly as it was.
*/
function exportTp5() {
if (!state.destinations.length) return toast('Nothing to save yet.', true);
const template = state.tp5Template ?? blankProject();
const proto = template.companies[0].destinations[0];
const company = template.companies[0];
company.name = state.company;
company.destinations = state.destinations.map((d) => {
const rec = deepClone(proto);
rec.lineName = d.name;
rec.lineName2 = d.name;
const protoAct = deepClone(proto.screens.fore.up[0]);
rec.screens.fore.up = d.pages.map((p) => {
const act = deepClone(protoAct);
const icon = act.icons[0];
icon.fields[4] = String(p.effect);
icon.fields[6] = '1';
const obj = icon.objs[0] ?? {
type: 'S', unknown1: '-3', x: 0, y: 0, font: 'ASC0704',
text: '', useTrueType: 1, ttfName: 'Impact', unknown2: '0', ptSize: 20, rest: [],
};
obj.text = p.text;
if (p.fontKind === 'system') { obj.useTrueType = 1; obj.ttfName = p.ttfName; obj.ptSize = p.ptSize; }
else { obj.useTrueType = 0; obj.font = p.font; }
const bmp = renderPage(p, state.screen);
obj.x = Math.max(0, Math.floor((state.screen.width - bmp.width) / 2));
icon.objs = [obj];
return act;
});
return rec;
});
download(new TextEncoder().encode(buildTp5(template)), `${safeName(state.company || 'destinations')}.tp5`, 'text/plain');
toast('Project saved. The old TP5 software can open this file too.');
}
function blankProject() {
const w = state.screen.width, h = state.screen.height;
const screen = (width) => ({ params: [String(width), String(h), '11316396', '0', '255', '1', '10', '6'], up: [], down: [] });
const act = {
fields: ['0', '10', '5', '3', '0', '0', '0'],
icons: [
{ fields: ['0', '0', String(w), String(h), '9', '0', '1'], objs: [] },
{ fields: ['0', '0', '0', '0', '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '0', '0', '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '256', String(h), '3', '0', '0'], objs: [] },
{ fields: ['0', '0', '256', String(h), '3', '0', '0'], objs: [] },
],
};
const dest = {
actIconFiles: [], srnIconFiles: [], lineName: '', lineName2: '',
screens: {
fore: { ...screen(w), up: [act] },
bcak: screen(256), side: screen(256), inner: screen(256), backside: screen(256),
},
};
return {
version: 'V1.5.3', flag: '0',
screenPara: ['1', '0', String(h), String(w), '0', '0', '8', '4', '0',
'1', String(h), '256', '0', '0', '8', '4', '0', '0',
'2', String(h), '256', '0', '0', '8', '4', '0', '0',
'4', String(h), '256', '0', '0', '8', '4', '0',
'3', String(h), '256', '0', '0', '8', '4'],
companies: [{ name: state.company, destinations: [dest] }],
};
}
// --------------------------------------------------------------------- UI
const $ = (s) => document.querySelector(s);
const el = (tag, props = {}, ...kids) => {
const n = Object.assign(document.createElement(tag), props);
for (const k of kids) n.append(k);
return n;
};
/** Depot PCs can be old, so avoid structuredClone (2022) and dialog fallbacks. */
function deepClone(v) {
if (v === null || typeof v !== 'object') return v;
if (v instanceof Uint8Array) return new Uint8Array(v);
if (Array.isArray(v)) return v.map(deepClone);
const out = {};
for (const k of Object.keys(v)) out[k] = deepClone(v[k]);
return out;
}
function showDialog(dlg) {
if (typeof dlg.showModal === 'function') return dlg.showModal();
dlg.setAttribute('open', '');
dlg.style.cssText = 'display:block;position:fixed;top:12%;left:50%;transform:translateX(-50%);z-index:200';
}
function closeDialog(dlg) {
if (typeof dlg.close === 'function' && dlg.open !== undefined && typeof dlg.showModal === 'function') return dlg.close();
dlg.removeAttribute('open');
dlg.style.display = 'none';
}
/**
* A destination with nothing to display usually left behind by tapping "+"
* one time too many. It would still reach the bus as a selectable but blank
* entry, so it is flagged in the list and caught on export.
*/
const isBlank = (d) =>
d.pages.every((p) => !p.text.trim() && !(p.pristine && p.bitmap && p.bitmap.width));
function renderAll() { renderHeader(); renderList(); renderEditor(); }
function renderHeader() {
$('#company').value = state.company;
const sel = $('#model');
if (!sel.options.length) {
for (const m of MODELS) sel.append(el('option', { value: m.width, textContent: m.label }));
}
const known = MODELS.some((m) => m.width === state.screen.width);
if (!known) {
const label = `Custom — ${state.screen.width} × ${state.screen.height}`;
let opt = [...sel.options].find((o) => o.dataset.custom);
if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); }
opt.value = String(state.screen.width);
opt.textContent = label;
}
sel.value = String(state.screen.width);
const has = state.destinations.length > 0;
$('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has;
$('#count').textContent = state.destinations.length;
}
function renderList() {
const list = $('#list');
list.textContent = '';
if (!state.destinations.length) {
list.append(el('div', { className: 'empty', textContent: 'No destinations yet. Open a file, paste a list, or add one.' }));
return;
}
state.destinations.forEach((d, i) => {
const item = el('div', { className: 'item' + (d.id === state.selected ? ' sel' : '') });
item.onclick = () => { state.selected = d.id; renderAll(); };
const body = el('div', { className: 'body' });
body.append(el('div', { className: 'nm', textContent: d.name || '(new — type a name)' }));
const cv = el('canvas');
const bmp = renderPage(d.pages[0], state.screen);
drawLED(cv, bmp, state.screen, { scale: 1, gap: 0 });
cv.style.width = Math.min(230, state.screen.width) + 'px';
body.append(cv);
if (d.pages.length > 1) body.append(el('div', { className: 'tag', textContent: `${d.pages.length} pages` }));
if (isBlank(d)) body.append(el('div', { className: 'tag warn', textContent: 'empty — nothing to show' }));
const del = el('button', { className: 'del', textContent: '×', title: `Delete ${d.name}` });
del.onclick = (e) => { e.stopPropagation(); remove(d); };
item.append(el('div', { className: 'num', textContent: i + 1 }), body, del);
list.append(item);
});
}
/**
* A destination record stores its name twice, and the driver's controller reads
* the second copy. TP5 always writes the two identically (verified across every
* record in the sample file), so they must never be allowed to drift apart
* if they do, the sign shows the right words but the controller shows the wrong
* ones, or none at all.
*/
function setName(d, value) {
d.name = value.slice(0, 16);
d.lineName = d.name;
}
/** The preview is its own card so it can be redrawn without rebuilding the form. */
function buildPreviewCard(d) {
const prev = el('div', { className: 'card', id: 'preview-card' });
prev.append(el('h3', { textContent: 'What the sign will show' }));
const stage = el('div', { className: 'led-stage' });
const canvas = el('canvas');
stage.append(canvas);
prev.append(stage);
const page0 = d.pages[state.previewPage ?? 0] ?? d.pages[0];
const bmp = renderPage(page0, state.screen);
startPreview(canvas, bmp, state.screen, page0.effect);
const meta = el('div', { className: 'meta' });
const over = bmp.width - state.screen.width;
meta.append(el('span', {}, el('b', { textContent: `${state.screen.width} × ${state.screen.height}` }), ' sign'));
meta.append(el('span', {}, el('b', { textContent: `${bmp.width}px` }), ' wide'));
if (over > 0) {
meta.append(el('span', { className: 'pill ' + (page0.effect === EFFECT.SCROLL ? 'scroll' : 'bad') },
page0.effect === EFFECT.SCROLL ? `Too long — scrolls (${over}px over)` : `Too long by ${over}px — set it to Scroll, or shorten it`));
} else {
meta.append(el('span', { className: 'pill ok', textContent: 'Fits on the sign' }));
}
if (page0.pristine) meta.append(el('span', { className: 'pill ok', textContent: 'Original artwork kept' }));
const cal = state.calibration;
if (cal && cal.family === page0.ttfName && !page0.pristine) {
meta.append(el('span', { className: 'pill ok',
title: `Matched to your existing signs on this computer: size ×${cal.scale.toFixed(3)}, threshold ${cal.threshold}, ` +
`from ${cal.samples} of them. Width within ${(cal.widthErr * 100).toFixed(1)}%, weight within ${(Math.abs(cal.litRatio - 1) * 100).toFixed(1)}%.`,
textContent: `Lettering matched to your signs (±${(cal.widthErr * 100).toFixed(1)}%)` }));
}
prev.append(meta);
if (d.pages.length > 1) {
const row = el('div', { className: 'meta' });
row.append(el('span', { textContent: 'Preview page:' }));
d.pages.forEach((_, i) => {
const b = el('button', { textContent: String(i + 1), style: 'padding:2px 10px' });
if ((state.previewPage ?? 0) === i) b.className = 'primary';
b.onclick = () => { state.previewPage = i; updatePreview(d); };
row.append(b);
});
prev.append(row);
}
return prev;
}
/** Redraw only the preview — typing must never rebuild the box being typed in. */
function updatePreview(d) {
const old = $('#preview-card');
if (old) old.replaceWith(buildPreviewCard(d));
}
/** Refresh one sidebar row in place, for the same reason. */
function updateListItem(d) {
const i = state.destinations.indexOf(d);
const item = $('#list').children[i];
if (!item) return renderList();
item.querySelector('.nm').textContent = d.name || '(new — type a name)';
const cv = item.querySelector('canvas');
if (cv) drawLED(cv, renderPage(d.pages[0], state.screen), state.screen, { scale: 1, gap: 0 });
const warn = item.querySelector('.tag.warn');
const blank = isBlank(d);
if (blank && !warn) {
item.querySelector('.body').append(el('div', { className: 'tag warn', textContent: 'empty — nothing to show' }));
} else if (!blank && warn) {
warn.remove();
}
}
function renderEditor() {
const host = $('#editor');
host.textContent = '';
const d = state.destinations.find((x) => x.id === state.selected);
if (!d) {
const w = el('div', { className: 'welcome' });
w.append(
el('h1', { textContent: state.destinations.length ? 'Pick a destination' : 'Welcome' }),
el('p', { textContent: state.destinations.length
? 'Choose one from the list on the left to edit it.'
: 'Open the destination file from the bus (.td5), or the TP5 project (.tp5), and everything will load here. You can also start from scratch.' }),
);
if (!state.destinations.length) {
const btns = el('div', { className: 'btns' });
btns.append(
el('button', { className: 'primary', textContent: 'Open a file…', onclick: () => $('#file').click() }),
el('button', { textContent: 'Import a spreadsheet', onclick: () => $('#file').click() }),
el('button', { textContent: 'Paste a list', onclick: openBulk }),
el('button', { textContent: 'Add one destination', onclick: addOne }),
);
w.append(btns);
}
host.append(el('div', { className: 'wrap' }, w));
return;
}
const wrap = el('div', { className: 'wrap' });
wrap.append(buildPreviewCard(d));
// ---- name
const nameCard = el('div', { className: 'card' });
nameCard.append(el('h3', { textContent: 'Destination' }));
const nrow = el('div', { className: 'row' });
const nameBox = el('div');
nameBox.append(el('label', { textContent: "Name on the driver's controller (16 characters)" }));
const nameIn = el('input', { type: 'text', value: d.name, maxLength: 16 });
// The controller reads its own copy of this name from the file, so both
// copies have to move together — see setName().
nameIn.oninput = () => { setName(d, nameIn.value); updateListItem(d); };
nameBox.append(nameIn);
nrow.append(nameBox);
nameCard.append(nrow);
const acts = el('div', { className: 'row', style: 'margin-top:14px' });
acts.append(
el('button', { textContent: 'Duplicate', onclick: () => duplicate(d) }),
el('button', { textContent: 'Move up', disabled: state.destinations.indexOf(d) === 0, onclick: () => move(d, -1) }),
el('button', { textContent: 'Move down', disabled: state.destinations.indexOf(d) === state.destinations.length - 1, onclick: () => move(d, 1) }),
el('button', { className: 'danger', textContent: 'Delete', onclick: () => remove(d) }),
);
nameCard.append(acts);
wrap.append(nameCard);
// ---- pages
const pagesCard = el('div', { className: 'card' });
pagesCard.append(el('h3', { textContent: d.pages.length > 1 ? 'Pages (the sign alternates between these)' : 'Message' }));
d.pages.forEach((p, i) => pagesCard.append(pageEditor(d, p, i)));
const addRow = el('div', { className: 'row' });
addRow.append(el('button', { textContent: '+ Add another page', onclick: () => { d.pages.push(newPage('')); renderAll(); } }));
pagesCard.append(addRow);
wrap.append(pagesCard);
host.append(wrap);
}
function pageEditor(d, p, i) {
const box = el('div', { className: 'page' });
const head = el('div', { className: 'page-head' });
if (d.pages.length > 1) head.append(el('span', { className: 'lbl', textContent: `Page ${i + 1}` }));
head.append(el('div', { style: 'flex:1' }));
if (d.pages.length > 1) {
head.append(el('button', { className: 'danger ghost', textContent: 'Remove page', onclick: () => { d.pages.splice(i, 1); renderAll(); } }));
}
box.append(head);
const textBox = el('div');
textBox.append(el('label', { textContent: 'Text shown on the sign' }));
const ti = el('input', { type: 'text', value: p.text });
// Typing must not re-render the editor, or the input is destroyed mid-keystroke
// and the caret is lost — redraw only the preview and the sidebar row.
ti.oninput = () => { p.text = ti.value; p.pristine = false; p.bitmap = null; refreshLight(d); };
textBox.append(ti);
box.append(textBox);
if (p.pristine && !p.text) {
box.append(el('div', { className: 'hint',
textContent: 'This came from the bus file, which only stores the finished picture — not the words. The sign will keep showing exactly what it shows now. Type the text above if you want to change it.' }));
}
const row = el('div', { className: 'row', style: 'margin-top:12px' });
const fontBox = el('div');
fontBox.append(el('label', { textContent: 'Lettering' }));
const fsel = el('select');
const og1 = el('optgroup', { label: 'Computer fonts (match your current signs)' });
for (const f of SYSTEM_FONTS) og1.append(el('option', { value: `sys:${f}`, textContent: f }));
const og2 = el('optgroup', { label: "Sign's own built-in fonts (sharpest)" });
for (const [name, j] of Object.entries(FONTS)) {
if (j.height > state.screen.height) continue;
og2.append(el('option', { value: `bi:${name}`, textContent: `${name} (${j.cellWidth}×${j.height})` }));
}
fsel.append(og1, og2);
fsel.value = p.fontKind === 'system' ? `sys:${p.ttfName}` : `bi:${p.font}`;
fsel.onchange = () => {
const [kind, val] = fsel.value.split(':');
if (kind === 'sys') { p.fontKind = 'system'; p.ttfName = val; } else { p.fontKind = 'builtin'; p.font = val; }
p.pristine = false; p.bitmap = null; refresh();
};
fontBox.append(fsel);
row.append(fontBox);
if (p.fontKind === 'system') {
const szBox = el('div');
szBox.append(el('label', { textContent: 'Size' }));
const si = el('input', { type: 'number', value: p.ptSize, min: 6, max: 60 });
si.oninput = () => { p.ptSize = Number(si.value) || 20; p.pristine = false; p.bitmap = null; refreshLight(d); };
szBox.append(si);
row.append(szBox);
}
const fxBox = el('div');
fxBox.append(el('label', { textContent: 'If the text is too long' }));
const seg = el('div', { className: 'seg' });
const mk = (label, value) => {
const b = el('button', { textContent: label, className: p.effect === value ? 'on' : '' });
b.onclick = () => { p.effect = value; refresh(); };
return b;
};
seg.append(mk('Hold still', EFFECT.HOLD), mk('Scroll', EFFECT.SCROLL));
fxBox.append(seg);
row.append(fxBox);
box.append(row);
return box;
}
/** Full rebuild — for changes that alter which controls are on screen. */
const refresh = () => { renderList(); renderEditor(); };
/** Preview-only update — for anything driven by typing. */
const refreshLight = (d) => { updatePreview(d); updateListItem(d); };
// ------------------------------------------------------------------ actions
/**
* A new destination starts genuinely empty rather than pre-filled with
* placeholder text placeholder text is worse than blank here, because
* "NEW DESTINATION" will cheerfully print itself on the front of a bus.
* Empty means it gets flagged in the list and refused at export.
*/
function addOne() {
const d = newDestination('');
state.destinations.push(d);
state.selected = d.id;
renderAll();
const input = $('#editor input[type=text]');
if (input) input.focus();
}
function duplicate(d) {
const copy = deepClone({ ...d, id: undefined, blockId: null });
copy.id = nextId();
copy.pages.forEach((p) => { p.pristine = false; p.bitmap = null; });
state.destinations.splice(state.destinations.indexOf(d) + 1, 0, copy);
state.selected = copy.id;
renderAll();
}
function move(d, dir) {
const i = state.destinations.indexOf(d);
const j = i + dir;
if (j < 0 || j >= state.destinations.length) return;
state.destinations.splice(j, 0, state.destinations.splice(i, 1)[0]);
renderAll();
}
/**
* Deleting offers an undo rather than asking first. A confirm box on every
* delete is friction when you are tidying a list, and it still leaves you stuck
* if you confirm by mistake; undo covers both.
*/
function remove(d) {
const i = state.destinations.indexOf(d);
if (i < 0) return;
state.destinations.splice(i, 1);
if (state.selected === d.id) {
state.selected = state.destinations[Math.min(i, state.destinations.length - 1)]?.id ?? null;
}
renderAll();
toast(`Deleted "${d.name || 'unnamed'}".`, false, 'Undo', () => {
state.destinations.splice(Math.min(i, state.destinations.length), 0, d);
state.selected = d.id;
renderAll();
toast(`Put "${d.name || 'unnamed'}" back.`);
});
}
/** Remove every destination, undoably — for starting a list over. */
function removeAll() {
if (!state.destinations.length) return;
const previous = state.destinations;
const n = previous.length;
state.destinations = [];
state.selected = null;
renderAll();
toast(`Cleared all ${n} destinations.`, false, 'Undo', () => {
state.destinations = previous;
state.selected = previous[0].id;
renderAll();
toast(`Put all ${n} back.`);
});
}
function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files
/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */
function importRows(rows, label) {
const { company, destinations } = mapRows(rows);
if (!destinations.length) {
return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true);
}
if (company && !state.company) state.company = company;
let added = 0, updated = 0;
for (const r of destinations) {
const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) {
setName(existing, r.name);
const p = existing.pages[0];
p.text = r.text; p.pristine = false; p.bitmap = null;
updated++;
} else {
const d = newDestination(r.name);
setName(d, r.name);
d.pages[0].text = r.text;
d.pages[0].bitmap = null;
state.destinations.push(d);
added++;
}
}
state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected;
renderAll();
toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`);
}
async function openFile(file) {
try {
const name = file.name.toLowerCase();
if (name.endsWith('.xlsx') || name.endsWith('.xlsm')) {
const rows = await readXlsx(new Uint8Array(await file.arrayBuffer()));
return importRows(rows, 'Spreadsheet');
}
if (name.endsWith('.csv') || name.endsWith('.txt')) {
return importRows(readCsv(await file.text()), 'CSV');
}
if (name.endsWith('.tp5')) {
const text = await file.text();
toast(loadTp5(text));
} else if (name.endsWith('.td5')) {
const bytes = new Uint8Array(await file.arrayBuffer());
if (state.destinations.length && state.tp5Template) {
const hits = mergeArtworkFromTd5(bytes);
toast(hits ? `Matched the original artwork for ${hits} pages.` : 'No matching destinations found in that file.');
} else {
toast(loadTd5(bytes));
}
} else {
return toast('Open a .td5, .tp5, .xlsx or .csv file.', true);
}
state.selected = state.destinations[0]?.id ?? null;
state.previewPage = 0;
renderAll();
} catch (err) {
toast(`Could not open that file: ${err.message}`, true);
}
}
function download(bytes, filename, type) {
const url = URL.createObjectURL(new Blob([bytes], { type }));
const a = el('a', { href: url, download: filename });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const safeName = (s) => s.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'destinations';
let toastTimer;
function toast(msg, isError = false, actionLabel = null, actionFn = null) {
const t = $('#toast');
t.textContent = msg;
if (actionLabel && actionFn) {
const b = el('button', { textContent: actionLabel });
b.onclick = () => { t.className = ''; actionFn(); };
t.append(b);
}
t.className = 'on' + (isError ? ' err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ''; }, actionLabel ? 9000 : 4200);
}
// -------------------------------------------------------------------- wire
$('#btn-open').onclick = () => $('#file').click();
$('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').click(); };
$('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
$('#btn-add').onclick = addOne;
$('#btn-clear').onclick = removeAll;
$('#btn-bulk').onclick = openBulk;
$('#btn-export').onclick = exportTd5;
$('#btn-export-tp5').onclick = exportTp5;
$('#company').oninput = (e) => { state.company = e.target.value; };
$('#model').onchange = (e) => {
const m = MODELS.find((x) => String(x.width) === e.target.value);
if (m) state.screen = { ...m };
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll();
};
$('#bulk-cancel').onclick = () => closeDialog($('#bulk'));
$('#bulk-ok').onclick = () => {
const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean);
for (const line of lines) state.destinations.push(newDestination(line));
if (lines.length) state.selected = state.destinations[state.destinations.length - lines.length].id;
closeDialog($('#bulk'));
renderAll();
toast(`Added ${lines.length} destination${lines.length === 1 ? '' : 's'}.`);
};
let dragDepth = 0;
addEventListener('dragenter', (e) => { e.preventDefault(); if (++dragDepth === 1) $('#drop').classList.add('on'); });
addEventListener('dragleave', (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; $('#drop').classList.remove('on'); } });
addEventListener('dragover', (e) => e.preventDefault());
addEventListener('drop', (e) => {
e.preventDefault();
dragDepth = 0;
$('#drop').classList.remove('on');
if (e.dataTransfer.files[0]) openFile(e.dataTransfer.files[0]);
});
renderAll();

2072
dist/DestoGod.html vendored Normal file

File diff suppressed because one or more lines are too long

38
re/affine.py Normal file
View File

@ -0,0 +1,38 @@
import struct
from parse import records
rs = records()
oc = [r for r in rs if r['name'].startswith('OC')]
assert len(oc)==8
L = set(len(r['blk']) for r in oc)
print('lengths', L)
b = [r['blk'] for r in oc]
f = [r['field'] for r in oc]
# show differing byte positions
n = len(b[0])
diff = [i for i in range(n) if len(set(x[i] for x in b))>1]
print('differing byte offsets:', [hex(i) for i in diff])
for i in diff:
print(hex(i), [hex(x[i]) for x in b])
# GF(2) affine test: for equal-length messages, if h is affine over GF(2),
# then h(A)^h(B)^h(C)^h(D) == 0 whenever A^B^C^D == 0.
# Find quadruples among the 8 whose message-XOR is 0.
import itertools
def bx(x,y):
return bytes(p^q for p,q in zip(x,y))
found=0
for c in itertools.combinations(range(8),4):
m = bytes(n)
for i in c: m = bx(m,b[i])
if m == bytes(n):
v = 0
for i in c: v ^= f[i]
print('quad', c, 'msgxor=0 fieldxor=%08x' % v)
found+=1
print('quads with zero msg xor:', found)
# Alternative affine test using the base-block trick:
# Build a matrix over the differing bytes only.

28
re/chk16.py Normal file
View File

@ -0,0 +1,28 @@
import struct
from parse import records
rs = records()
for r in rs:
b = r['blk']
r['chk'] = struct.unpack_from('<H', b, 1)[0]
r['chkbe'] = struct.unpack_from('>H', b, 1)[0]
r['rnd'] = struct.unpack_from('<I', b, 3)[0]
print("%-18s %-6s %-6s %-6s %6s" % ('name','chkLE','chkBE','rand','blen'))
for r in rs:
print("%-18s %04x %04x %04x %6d" % (r['name'], r['chk'], r['chkbe'], r['rnd'], r['blklen']))
print()
# simple sum tests
def sums(b):
return dict(
sum_all=sum(b)&0xffff,
sum_3=sum(b[3:])&0xffff,
sum_7=sum(b[7:])&0xffff,
sum_116=sum(b[0x116:])&0xffff,
sum_z=(sum(b)-b[1]-b[2])&0xffff,
xor_all=0,
)
for r in rs[:6]:
b=r['blk']
print(r['name'], '%04x'%r['chk'], {k:'%04x'%v for k,v in sums(b).items()})

31
re/disx.py Normal file
View File

@ -0,0 +1,31 @@
import sys, struct
from capstone import *
import pefile
EXE = sys.argv[3] if len(sys.argv)>3 else '/Users/jing/Documents/yutong/yutongapp/TP5(En).exe'
d = open(EXE,'rb').read()
BASE = 0x400000
pe = pefile.PE(EXE)
# import name map
imp = {}
for e in pe.DIRECTORY_ENTRY_IMPORT:
for i in e.imports:
imp[i.address] = (e.dll.decode(), i.name.decode() if i.name else 'ord%d'%i.ordinal)
md = Cs(CS_ARCH_X86, CS_MODE_32)
md.detail = True
start = int(sys.argv[1],16)
n = int(sys.argv[2],16) if len(sys.argv)>2 else 0x200
off = start - BASE
for ins in md.disasm(d[off:off+n], start):
s = "%08x %-24s %s %s" % (ins.address, ins.bytes.hex(), ins.mnemonic, ins.op_str)
# annotate indirect calls
if ins.mnemonic in ('call','jmp') and 'dword ptr [0x' in ins.op_str:
try:
a = int(ins.op_str.split('[')[1].split(']')[0],16)
if a in imp: s += ' ; %s!%s' % imp[a]
except: pass
print(s)

14
re/find_const.py Normal file
View File

@ -0,0 +1,14 @@
import re
d = open('/Users/jing/Documents/yutong/yutongapp/TP5(En).exe','rb').read()
def hits(pat, lo=0x1000, hi=0x46000):
return [m.start() for m in re.finditer(re.escape(pat), d[lo:hi])]
for name,pat in [('0x11684', b'\x84\x16\x01\x00'), ('0x11684 push', b'\x68\x84\x16\x01\x00')]:
h = hits(pat)
print(name, [hex(x+0x1000) for x in h])
print('--- MSVCRT imports ---')
import pefile
p = pefile.PE('/Users/jing/Documents/yutong/yutongapp/TP5(En).exe')
for e in p.DIRECTORY_ENTRY_IMPORT:
if b'MSVCRT' in e.dll.upper() or b'KERNEL32' in e.dll.upper():
for imp in e.imports:
print(e.dll.decode(), '%08x' % imp.address, imp.name.decode() if imp.name else imp.ordinal)

46
re/findtab.py Normal file
View File

@ -0,0 +1,46 @@
import struct, re
EXES = ['/Users/jing/Documents/yutong/yutongapp/TP5(En).exe',
'/Users/jing/Documents/yutong/yutongapp/TP5(Cn).exe']
def crc_table16(poly, reflect):
t=[]
for i in range(256):
if reflect:
c=i
for _ in range(8):
c = (c>>1) ^ (poly if c&1 else 0)
else:
c=i<<8
for _ in range(8):
c = ((c<<1)^poly)&0xffff if c&0x8000 else (c<<1)&0xffff
t.append(c&0xffff)
return t
def crc_table32(poly, reflect):
t=[]
for i in range(256):
if reflect:
c=i
for _ in range(8):
c = (c>>1) ^ (poly if c&1 else 0)
else:
c=i<<24
for _ in range(8):
c = ((c<<1)^poly)&0xffffffff if c&0x80000000 else (c<<1)&0xffffffff
t.append(c&0xffffffff)
return t
polys16 = [0x1021,0x8408,0x8005,0xa001,0x3d65,0xa6bc,0xc867,0x0589,0x8bb7,0x8d95,0x1DCF,0x755B]
for path in EXES:
d=open(path,'rb').read()
print('===', path, len(d))
for p in polys16:
for refl in (0,1):
t = crc_table16(p, refl)
blob = b''.join(struct.pack('<H',x) for x in t[:16])
i = d.find(blob)
print(' tbl16 poly=%04x refl=%d -> %s' % (p,refl, hex(i) if i>=0 else '-'))
# generic immediate search for 16-bit polys used in bitwise loop:
# look for "xor ax, imm16" = 66 35 xx xx or "xor eax, imm32" = 35 xx xx xx xx
for m in re.finditer(rb'\x66\x35(..)', d, re.S):
pass

33
re/parse.py Normal file
View File

@ -0,0 +1,33 @@
import struct, sys
PATH = "/Users/jing/Documents/yutong/test files/EXPRESS 1.td5"
def load():
return open(PATH, 'rb').read()
def records(d=None):
if d is None: d = load()
out = []
off = 0x200
ptrs = []
while True:
p = struct.unpack_from('<I', d, off)[0]
off += 4
if p == 0xffffffff: break
ptrs.append(p)
for p in ptrs:
name = d[p:p+0x10].decode('latin1').rstrip()
name2 = d[p+0x10:p+0x20].decode('latin1').rstrip()
blkoff, blklen = struct.unpack_from('<II', d, p+0x30)
blk = d[blkoff:blkoff+blklen]
field = struct.unpack_from('<I', blk, 1)[0]
out.append(dict(ptr=p, name=name, name2=name2, blkoff=blkoff, blklen=blklen, blk=blk, field=field))
return out
if __name__ == '__main__':
d = load()
rs = records(d)
print(len(rs), 'records')
for r in rs:
print("%-18s ptr=%06x blkoff=%06x blklen=%04x field=%08x" % (
r['name'], r['ptr'], r['blkoff'], r['blklen'], r['field']))

12
re/pe.py Normal file
View File

@ -0,0 +1,12 @@
import pefile, sys
p = pefile.PE('/Users/jing/Documents/yutong/yutongapp/TP5(En).exe')
print('ImageBase %08x' % p.OPTIONAL_HEADER.ImageBase)
print('EP %08x' % p.OPTIONAL_HEADER.AddressOfEntryPoint)
for s in p.sections:
print(s.Name.decode().rstrip('\0'), 'VA=%08x VS=%08x PR=%08x RS=%08x' % (s.VirtualAddress, s.Misc_VirtualSize, s.PointerToRawData, s.SizeOfRawData))
print('--- imports ---')
for e in p.DIRECTORY_ENTRY_IMPORT:
names=[]
for imp in e.imports:
names.append(imp.name.decode() if imp.name else ('ord%d'%imp.ordinal))
print(e.dll.decode(), len(names))

48
re/tryranges.py Normal file
View File

@ -0,0 +1,48 @@
import struct
from parse import records
POLY = 0xA001
def crc16(data, init=0, poly=POLY):
c = init
for b in data:
c ^= b
for _ in range(8):
c = (c >> 1) ^ poly if c & 1 else c >> 1
return c & 0xffff
rs = records()
for r in rs:
r['chk'] = struct.unpack_from('<H', r['blk'], 1)[0]
# candidate ranges (start, end) end=None means to end of block
cands = []
for s in [0,1,3,5,7,0xb,0xf,0x10,0x11,0x14,0x16,0x20,0x116,0x117]:
cands.append((s, None))
cands += [(0,3),(3,7),(0,0x116),(3,0x116),(7,0x116),(0x116,None)]
blks = [r['blk'] for r in rs]
chks = [r['chk'] for r in rs]
best = []
for (s,e) in cands:
ok = 0
for b,c in zip(blks,chks):
seg = b[s:] if e is None else b[s:e]
for init in (0, 0xffff):
for xo in (0, 0xffff):
pass
if crc16(seg) == c: ok += 1
if ok: print('range', hex(s), e, 'matches', ok, '/', len(rs))
best.append((ok,s,e))
best.sort(reverse=True)
print('top:', best[:5])
# also try with the crc bytes zeroed
print('--- zeroed crc field ---')
for (s,e) in cands:
ok=0
for b,c in zip(blks,chks):
bb = bytearray(b); bb[1]=0; bb[2]=0
seg = bytes(bb[s:] if e is None else bb[s:e])
if crc16(seg) == c: ok+=1
if ok: print('range', hex(s), e, 'matches', ok)

56
re/verify.py Normal file
View File

@ -0,0 +1,56 @@
"""TP5 .td5 block header checksum - solved.
Block layout (first 0x13 bytes):
+0x00 u8 0x43 'C' block type tag
+0x01 u16 CRC-16/ARC over block[3:blockLen] <-- the "mystery field" low half
+0x03 u32 rand() (MSVCRT, 0..0x7FFF; srand(time(NULL)) once per export)
+0x07 u32 blockLen
+0x0b u8 1
+0x0c u8 1
+0x0d u16 1
+0x0f u8 0x84
+0x10 u16 0x0116 (offset of bitmap data = header size)
The 4 bytes read as one u32-LE at +1 are therefore CRC | (rand<<16).
"""
import struct
from parse import records
def crc16_arc(data, init=0, poly=0xA001):
"""TP5 sub_409150 with polyIndex=1 (table @0x447020 = [8480,A001,8621,E950])."""
c = init
for b in data:
c ^= b
for _ in range(8):
c = (c >> 1) ^ poly if c & 1 else c >> 1
return c & 0xFFFF
def td5_block_checksum(block):
"""block: the full block bytes (length == u32 at block[7:11]). Returns u16 for block[1:3]."""
blen = struct.unpack_from('<I', block, 7)[0]
return crc16_arc(block[3:blen])
def build_header(blen, rand_val, bitmap_off=0x0116):
"""Return the first 0x13 header bytes with a placeholder CRC (fill after body is built)."""
return bytes([0x43, 0, 0]) + struct.pack('<I', rand_val) + struct.pack('<I', blen) \
+ bytes([1, 1]) + struct.pack('<H', 1) + bytes([0x84]) + struct.pack('<H', bitmap_off)
if __name__ == '__main__':
rs = records()
ok = 0
print("%-18s %6s %-9s %-9s %-6s %-6s %s" %
("record", "blen", "field(exp)", "field(calc)", "crcExp", "crcCalc", "rand"))
for r in rs:
b = r['blk']
exp_crc = struct.unpack_from('<H', b, 1)[0]
rnd = struct.unpack_from('<I', b, 3)[0]
got = td5_block_checksum(b)
field_exp = r['field']
field_calc = (got | (rnd << 16)) & 0xFFFFFFFF
good = (got == exp_crc)
ok += good
print("%-18s %6d %08x %08x %04x %04x %04x %s" %
(r['name'], r['blklen'], field_exp, field_calc, exp_crc, got, rnd,
"OK" if good else "**MISMATCH**"))
print("\n%d/%d records match" % (ok, len(rs)))
print("all rand() values <= 0x7FFF (RAND_MAX):",
all(struct.unpack_from('<I', r['blk'], 3)[0] <= 0x7FFF for r in rs))

10
re/xref.py Normal file
View File

@ -0,0 +1,10 @@
import re,struct,sys
d = open('/Users/jing/Documents/yutong/yutongapp/TP5(En).exe','rb').read()
BASE=0x400000
def find_call_indirect(iat_va):
# ff 15 <iat_va>
pat = b'\xff\x15' + struct.pack('<I', iat_va)
return [m.start() for m in re.finditer(re.escape(pat), d)]
for name,va in [('rand',0x4465a4),('srand',0x4465a8),('time',0x4465ac),('_ftol',0x446590),('CreateFileA',0x4460d0)]:
h = find_call_indirect(va)
print(name, [hex(x+BASE) for x in h])

BIN
samples/EXPRESS 1.td5 Normal file

Binary file not shown.

1091
samples/EXPRESS.tp5 Normal file

File diff suppressed because it is too large Load Diff

14
samples/README.md Normal file
View File

@ -0,0 +1,14 @@
# Samples
Real files from the operator this was built for, used by the test suite as the
reference for "what TP5 actually produces".
- `EXPRESS.tp5` — a TP5 project: the destination list with its text
- `EXPRESS 1.td5` — the same list exported for the bus, made by TP5 itself.
This is the file `test/roundtrip.mjs` rebuilds byte-for-byte.
- `desto-test.xlsx` — a small sheet in the layout of the template these buses
ship with, for exercising spreadsheet import.
The `.td5`/`.tp5` pair is the ground truth for the whole format. Replacing them
with anything else means the round-trip test is no longer checking against a
known-good TP5 export.

BIN
samples/desto-test.xlsx Normal file

Binary file not shown.

133
src/codec/bitfont.mjs Normal file
View File

@ -0,0 +1,133 @@
/**
* The .font files shipped alongside TP5 the sign's own bitmap fonts.
*
* NAME,
* cellWidth,height,
* code,
* advance,
* 0xNN, x (height * ceil(cellWidth/8)) <- each byte XORed with (code & 0xff)
* ...
*
* Rows are stored MSB-first, leftmost pixel in the high bit, exactly like the
* bitmaps inside a .td5, which is why text rendered here can be written
* straight into a frame.
*/
/** @returns {{name: string, cellWidth: number, height: number, glyphs: Map<number, {advance: number, rows: number[]}>}} */
export function parseFont(text) {
const tok = text.split(/[,\r\n]+/).map((s) => s.trim()).filter(Boolean);
const name = tok[0];
const cellWidth = parseInt(tok[1], 10);
const height = parseInt(tok[2], 10);
const bytesPerRow = Math.ceil(cellWidth / 8);
const glyphs = new Map();
let i = 3;
while (i < tok.length) {
const code = parseInt(tok[i++], 10);
const advance = parseInt(tok[i++], 10);
if (!Number.isFinite(code) || !Number.isFinite(advance)) break;
const rows = [];
for (let y = 0; y < height; y++) {
let row = 0;
for (let b = 0; b < bytesPerRow; b++) {
const byte = parseInt(tok[i++], 16) ^ (code & 0xff);
row = (row << 8) | byte;
}
rows.push(row);
}
glyphs.set(code, { advance, rows });
}
return { name, cellWidth, height, glyphs };
}
/** Serialisable form for embedding in the app. */
export function fontToJSON(f) {
const glyphs = {};
for (const [code, g] of f.glyphs) glyphs[code] = [g.advance, ...g.rows];
return { name: f.name, cellWidth: f.cellWidth, height: f.height, glyphs };
}
export function fontFromJSON(j) {
const glyphs = new Map();
for (const [code, arr] of Object.entries(j.glyphs)) {
glyphs.set(Number(code), { advance: arr[0], rows: arr.slice(1) });
}
return { name: j.name, cellWidth: j.cellWidth, height: j.height, glyphs };
}
/** Width in pixels that `text` would occupy. */
export function measureText(font, text, tracking = 1) {
let w = 0;
for (const ch of text) {
const g = font.glyphs.get(ch.codePointAt(0));
if (g) w += g.advance + tracking;
}
return Math.max(0, w - tracking);
}
/**
* Render `text` into a {width, height, pixels} bitmap.
* `tracking` is the gap in pixels inserted between glyphs.
*/
export function renderText(font, text, { tracking = 1 } = {}) {
const width = measureText(font, text, tracking);
const height = font.height;
const pixels = new Uint8Array(Math.max(1, width) * height);
let x = 0;
for (const ch of text) {
const g = font.glyphs.get(ch.codePointAt(0));
if (!g) continue;
for (let y = 0; y < height; y++) {
const row = g.rows[y];
for (let b = 0; b < g.advance; b++) {
// high bit of the cell is the leftmost pixel
if (row & (1 << (font.cellWidth - 1 - b))) {
const px = x + b;
if (px < width) pixels[y * width + px] = 1;
}
}
}
x += g.advance + tracking;
}
return { width, height, pixels };
}
/** Place a rendered bitmap onto a canvas of the given size. */
export function layout(bitmap, { width, height, align = 'center', offsetX = 0, offsetY = 0 }) {
const pixels = new Uint8Array(width * height);
let x0 = offsetX;
if (align === 'center') x0 += Math.floor((width - bitmap.width) / 2);
else if (align === 'right') x0 += width - bitmap.width;
const y0 = offsetY + Math.floor((height - bitmap.height) / 2);
for (let y = 0; y < bitmap.height; y++) {
const ty = y + y0;
if (ty < 0 || ty >= height) continue;
for (let x = 0; x < bitmap.width; x++) {
const tx = x + x0;
if (tx < 0 || tx >= width) continue;
if (bitmap.pixels[y * bitmap.width + x]) pixels[ty * width + tx] = 1;
}
}
return { width, height, pixels };
}
/** Trim blank columns from both sides, keeping `pad` columns of margin. */
export function trimX(bitmap, pad = 0) {
const { width, height, pixels } = bitmap;
let lo = width, hi = -1;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
if (pixels[y * width + x]) { if (x < lo) lo = x; if (x > hi) hi = x; break; }
}
}
if (hi < 0) return { width: 0, height, pixels: new Uint8Array(0) };
lo = Math.max(0, lo - pad); hi = Math.min(width - 1, hi + pad);
const w = hi - lo + 1;
const out = new Uint8Array(w * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < w; x++) out[y * w + x] = pixels[y * width + lo + x];
}
return { width: w, height, pixels: out };
}

196
src/codec/sheet.mjs Normal file
View File

@ -0,0 +1,196 @@
/**
* 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 };
}

351
src/codec/td5.mjs Normal file
View File

@ -0,0 +1,351 @@
/**
* .td5 the binary file the Yutong / Guangzhou-Tongda bus destination sign
* controller reads off the SD card.
*
* Format reverse-engineered from TP5.exe output. Layout:
*
* FILE
* 0x000 16 magic, GBK "广州通达图形线路"
* 0x010 4 "V5.0"
* 0x014 4 00 b4 00 00
* 0x018 12 export timestamp, ASCII "YYMMDDhhmmss"
* 0x030 2 company count (u16)
* 0x040 2 screen height, screen width in bytes (16, 14 => 112x16)
* 0x080 16 company[0] name, space padded
* 0x090 4 offset of company[0] pointer table (u32) \ repeats every
* 0x094 2 company[0] destination count (u16) / 0x20 per company
* 0x200 .. pointer table: u32 absolute offset per destination,
* terminated by 0xffffffff
* 0x400 .. destination records
* everything unused is filled with 0xff
*
* DESTINATION (record header is 0x80 bytes, block always starts at +0x200)
* +0x00 16 name, space padded
* +0x10 16 name again (the "line name" shown on the driver's controller)
* +0x20 16 spaces
* +0x30 4 block offset (u32)
* +0x34 4 block length (u32)
* +0x38 72 constant tail
*
* BLOCK
* +0x00 1 0x43 'C'
* +0x01 2 CRC-16/ARC over block[3 .. blockLength] (u16)
* +0x03 4 block id TP5 stores rand(), so it is always <= 0x7fff, which
* is why bytes +5/+6 always read as zero
* +0x07 4 block length (u32, same as record header)
* +0x0b 3 frame count, three times
* +0x0e 1 0
* +0x0f 1 0x84 marker
* +0x10 6 frame descriptor x frameCount: u32 offset, u8 widthBytes, u8 height
* .. action section x (frameCount*2), 0x80 bytes each
* .. frame bitmaps
* headerLength = 0x10 + frameCount*6 + frameCount*0x100
*
* BITMAP widthBytes chunks of 16 bytes; each chunk is 8 columns x 16 rows,
* row-major, MSB = leftmost pixel. Chunks run left to right.
* A bitmap wider than the screen scrolls.
*/
export const MAGIC = new Uint8Array([
0xb9, 0xe3, 0xd6, 0xdd, 0xcd, 0xa8, 0xb4, 0xef,
0xcd, 0xbc, 0xd0, 0xce, 0xcf, 0xdf, 0xc2, 0xb7,
]);
export const REC_HEADER_LEN = 0x80;
export const BLOCK_START = 0x200; // block offset relative to record start
export const PAGE = 0x200; // records are aligned to this
export const PTR_TABLE = 0x200;
export const FILL = 0xff;
/** Display effect stored at section byte 8. */
export const EFFECT = { HOLD: 3, SCROLL: 9 };
/** Constant tail of a destination record header (record +0x38 .. +0x80). */
const REC_TAIL = hex(
'ffffffffffffffffffffffff' + '00000000' +
'ffffffffffffffffffffffff' + '00000000' +
'ffffffff' + '00000000' + 'ffffffff' + '00000000' +
'ffffffffffffffffffffffffffffffffffffffffffffffff'
);
/** One 0x80-byte action section. Byte 2 = frame index, byte 8 = effect. */
function buildSection(frameIndex, effect) {
const s = new Uint8Array(0x80);
s[1] = 0x0a;
s[2] = frameIndex;
s[6] = 0x0e;
s[7] = 0x10;
s[8] = effect;
s[15] = 0x03;
s[22] = 0x03;
s[27] = 0x20; s[28] = 0x10; s[29] = 0x03;
s[34] = 0x20; s[35] = 0x10; s[36] = 0x03;
return s;
}
function hex(s) {
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
const alignUp = (n, a) => Math.ceil(n / a) * a;
function ascii(bytes) {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return s;
}
/** Write `text` into `buf` at `off`, space padded / truncated to `len`. */
function putPadded(buf, off, text, len) {
for (let i = 0; i < len; i++) {
const c = i < text.length ? text.charCodeAt(i) : 0x20;
buf[off + i] = c < 0x100 ? c : 0x3f; // '?' for anything non latin-1
}
}
// ---------------------------------------------------------------- checksum
/**
* CRC-16/ARC reflected, poly 0xa001, init 0, no final xor. TP5 calls this
* with polynomial index 1 out of the table {0x8480, 0xa001, 0x8621, 0xe950}.
*/
export function crc16arc(data, init = 0) {
let crc = init;
for (let i = 0; i < data.length; i++) {
crc ^= data[i];
for (let b = 0; b < 8; b++) crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1;
}
return crc & 0xffff;
}
/** The CRC covers everything from the block id onward, so write it last. */
function sealBlock(block) {
const crc = crc16arc(block.subarray(3));
block[1] = crc & 0xff;
block[2] = (crc >> 8) & 0xff;
return crc;
}
/** TP5 uses MSVCRT rand(), whose RAND_MAX is 0x7fff. */
const randomBlockId = () => Math.floor(Math.random() * 0x8000);
// ------------------------------------------------------------------- parse
/**
* Parse a .td5 file.
* @param {Uint8Array} bytes
* @returns {{company: string, timestamp: string, screen: {width: number, height: number},
* destinations: Array<{name: string, lineName: string, effects: number[],
* frames: Array<{width: number, height: number, widthBytes: number,
* data: Uint8Array}>, checksum: number}>}}
*/
export function parseTd5(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let i = 0; i < MAGIC.length; i++) {
if (bytes[i] !== MAGIC[i]) throw new Error('Not a .td5 file (bad magic)');
}
const version = ascii(bytes.subarray(0x10, 0x14));
const timestamp = ascii(bytes.subarray(0x18, 0x24));
const screen = { height: bytes[0x40], width: bytes[0x41] * 8 };
const company = ascii(bytes.subarray(0x80, 0x90)).trimEnd();
const ptrTable = dv.getUint32(0x90, true);
const count = dv.getUint16(0x94, true);
const destinations = [];
for (let i = 0; i < count; i++) {
const ptr = dv.getUint32(ptrTable + i * 4, true);
if (ptr === 0xffffffff) break;
destinations.push(parseRecord(bytes, dv, ptr));
}
return { version, company, timestamp, screen, destinations };
}
function parseRecord(bytes, dv, ptr) {
const name = ascii(bytes.subarray(ptr, ptr + 16)).trimEnd();
const lineName = ascii(bytes.subarray(ptr + 0x10, ptr + 0x20)).trimEnd();
const blockOff = dv.getUint32(ptr + 0x30, true);
const blockLen = dv.getUint32(ptr + 0x34, true);
const block = bytes.subarray(blockOff, blockOff + blockLen);
if (block[0] !== 0x43) throw new Error(`${name}: bad block tag`);
if (block[0x0f] !== 0x84) throw new Error(`${name}: bad frame-table marker`);
const crc = block[1] | (block[2] << 8);
const blockId = dv.getUint32(blockOff + 3, true);
const crcOk = crc === crc16arc(block.subarray(3));
const frameCount = block[0x0b];
const frames = [];
for (let f = 0; f < frameCount; f++) {
const p = 0x10 + f * 6;
const off = block[p] | (block[p + 1] << 8) | (block[p + 2] << 16) | (block[p + 3] << 24);
const widthBytes = block[p + 4];
const height = block[p + 5];
frames.push({
width: widthBytes * 8,
height,
widthBytes,
data: block.slice(off, off + widthBytes * 16),
});
}
const base = 0x10 + frameCount * 6;
const effects = [];
for (let k = 0; k < frameCount * 2; k++) effects.push(block[base + k * 0x80 + 8]);
return { name, lineName, effects, frames, blockId, crc, crcOk };
}
// ------------------------------------------------------------------- build
/**
* Build a .td5 file.
* @param {object} doc same shape parseTd5 returns; `checksum` on a destination
* is reused when we cannot compute one.
* @param {{timestamp?: string}} [opts]
*/
export function buildTd5(doc, opts = {}) {
const dests = doc.destinations;
const screenWidthBytes = Math.round((doc.screen?.width ?? 112) / 8);
const screenHeight = doc.screen?.height ?? 16;
// Lay records out first so we know the file size.
const layout = [];
let cursor = 0x400;
for (const d of dests) {
const frameCount = d.frames.length;
const headerLen = 0x10 + frameCount * 6 + frameCount * 0x100;
const blockLen = headerLen + d.frames.reduce((n, f) => n + f.widthBytes * 16, 0);
layout.push({ recOff: cursor, blockOff: cursor + BLOCK_START, blockLen, headerLen });
cursor = alignUp(cursor + BLOCK_START + blockLen, PAGE);
}
const size = cursor;
const out = new Uint8Array(size).fill(FILL);
const dv = new DataView(out.buffer);
out.set(MAGIC, 0);
putAscii(out, 0x10, 'V5.0');
out[0x14] = 0x00; out[0x15] = 0xb4; out[0x16] = 0x00; out[0x17] = 0x00;
putAscii(out, 0x18, opts.timestamp ?? doc.timestamp ?? nowStamp());
out.fill(FILL, 0x24, 0x30);
dv.setUint16(0x30, 1, true);
out.fill(FILL, 0x32, 0x40);
out[0x40] = screenHeight;
out[0x41] = screenWidthBytes;
out.fill(FILL, 0x42, 0x80);
putPadded(out, 0x80, doc.company ?? '', 16);
dv.setUint32(0x90, PTR_TABLE, true);
dv.setUint16(0x94, dests.length, true);
out.fill(FILL, 0x96, 0xa0);
// remaining company slots stay 0x20/0xff as TP5 leaves them
for (let off = 0xa0; off < 0x200; off += 0x20) {
out.fill(0x20, off, off + 0x10);
out.fill(FILL, off + 0x10, off + 0x20);
}
let p = PTR_TABLE;
for (const l of layout) { dv.setUint32(p, l.recOff, true); p += 4; }
dv.setUint32(p, 0xffffffff, true);
dests.forEach((d, i) => {
const l = layout[i];
putPadded(out, l.recOff, d.name, 16);
putPadded(out, l.recOff + 0x10, d.lineName ?? d.name, 16);
out.fill(0x20, l.recOff + 0x20, l.recOff + 0x30);
dv.setUint32(l.recOff + 0x30, l.blockOff, true);
dv.setUint32(l.recOff + 0x34, l.blockLen, true);
out.set(REC_TAIL, l.recOff + 0x38);
const block = buildBlock(d, l);
out.set(block, l.blockOff);
});
return out;
}
function buildBlock(d, l) {
const frameCount = d.frames.length;
const block = new Uint8Array(l.blockLen);
const bv = new DataView(block.buffer);
block[0] = 0x43;
bv.setUint32(0x03, d.blockId ?? randomBlockId(), true);
bv.setUint32(0x07, l.blockLen, true);
block[0x0b] = frameCount;
block[0x0c] = frameCount;
block[0x0d] = frameCount;
block[0x0f] = 0x84;
let dataOff = l.headerLen;
d.frames.forEach((f, fi) => {
const p = 0x10 + fi * 6;
bv.setUint32(p, dataOff, true);
block[p + 4] = f.widthBytes;
block[p + 5] = f.height;
block.set(f.data, dataOff);
dataOff += f.widthBytes * 16;
});
// Two action groups, each holding one section per frame.
const base = 0x10 + frameCount * 6;
for (let k = 0; k < frameCount * 2; k++) {
const effect = d.effects?.[k] ?? d.effects?.[0] ?? EFFECT.SCROLL;
block.set(buildSection(k % frameCount, effect), base + k * 0x80);
}
sealBlock(block);
return block;
}
function putAscii(buf, off, s) {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
}
function nowStamp(date = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return p(date.getFullYear() % 100) + p(date.getMonth() + 1) + p(date.getDate()) +
p(date.getHours()) + p(date.getMinutes()) + p(date.getSeconds());
}
// ------------------------------------------------------------------ pixels
/** Unpack a frame into a width*height Uint8Array of 0/1. */
export function frameToPixels(frame) {
const { widthBytes, height } = frame;
const w = widthBytes * 8;
const px = new Uint8Array(w * height);
for (let c = 0; c < widthBytes; c++) {
for (let y = 0; y < height; y++) {
const byte = frame.data[c * 16 + y];
for (let b = 0; b < 8; b++) {
if (byte & (0x80 >> b)) px[y * w + c * 8 + b] = 1;
}
}
}
return px;
}
/** Pack a width*height 0/1 array into a frame. Width is padded up to a multiple of 8. */
export function pixelsToFrame(px, width, height) {
const widthBytes = Math.ceil(width / 8);
const data = new Uint8Array(widthBytes * 16);
for (let c = 0; c < widthBytes; c++) {
for (let y = 0; y < height; y++) {
let byte = 0;
for (let b = 0; b < 8; b++) {
const x = c * 8 + b;
if (x < width && px[y * width + x]) byte |= 0x80 >> b;
}
data[c * 16 + y] = byte;
}
}
return { width: widthBytes * 8, height, widthBytes, data };
}

215
src/codec/tp5.mjs Normal file
View File

@ -0,0 +1,215 @@
/**
* .tp5 TP5's editable project file. Plain text, CRLF, one statement per line,
* each terminated with ';'. Strings are stored as UTF-16BE hex.
*
* Unlike a .td5 (which only holds rendered bitmaps) this keeps the actual text,
* so importing one gives us something we can edit.
*
* V1.5.3;
* 0;
* screen para:...;
* Company Sum:1;
* <companyNameHex>,<destinationCount>;
* ... then per destination:
* ActIconFileNames:0;
* SrnIconFileNames:0;
* line name:<nameHex>,<name2Hex>,;
* fore screen: <- then bcak / side / inner / backside, in that order
* 112,16,11316396,0,255,1,10,6;
* Up Act Sum:1;
* act #0:0,10,5,3,0,0,0;
* icon #0:0,0,112,16,<effect>,0,1;
* Obj #0:S,-3,<x>,<y>,<fontHex>,<textHex>,<useTTF>,<ttfNameHex>,<0>,<ptSize>;
* icon #1:... (five icons per act, each may carry Obj lines)
* Down Act Sum:0;
*/
const SCREENS = ['fore', 'bcak', 'side', 'inner', 'backside']; // 'bcak' typo is TP5's
export function hexToStr(hex) {
let s = '';
for (let i = 0; i + 3 < hex.length; i += 4) s += String.fromCharCode(parseInt(hex.substr(i, 4), 16));
return s;
}
export function strToHex(s) {
let out = '';
for (const ch of s) out += ch.charCodeAt(0).toString(16).padStart(4, '0');
return out;
}
class Reader {
constructor(text) {
this.lines = text.split(/\r?\n/);
this.i = 0;
}
peek() { return this.lines[this.i]; }
next() { return this.lines[this.i++]; }
/** Strip the trailing ';' and return the payload after `label:`. */
expect(label) {
const line = this.next();
if (!line?.startsWith(label)) throw new Error(`line ${this.i}: expected "${label}", got "${line}"`);
return line.slice(label.length).replace(/;$/, '');
}
}
const stripSemi = (l) => (l ?? '').replace(/;$/, '');
export function parseTp5(text) {
const r = new Reader(text);
const version = stripSemi(r.next());
const flag = stripSemi(r.next());
const screenPara = r.expect('screen para:').split(',');
const companySum = Number(r.expect('Company Sum:'));
const companies = [];
for (let c = 0; c < companySum; c++) {
const [nameHex, count] = stripSemi(r.next()).split(',');
companies.push({ name: hexToStr(nameHex), count: Number(count), destinations: [] });
}
for (const company of companies) {
for (let d = 0; d < company.count; d++) company.destinations.push(readDestination(r));
}
return { version, flag, screenPara, companies };
}
function readDestination(r) {
const actIcons = readFileNameList(r, 'ActIconFileNames:');
const srnIcons = readFileNameList(r, 'SrnIconFileNames:');
const parts = r.expect('line name:').split(',');
const dest = {
actIconFiles: actIcons,
srnIconFiles: srnIcons,
lineName: hexToStr(parts[0] ?? ''),
lineName2: hexToStr(parts[1] ?? ''),
screens: {},
};
for (const key of SCREENS) {
const header = r.next();
if (header !== `${key} screen:`) throw new Error(`line ${r.i}: expected "${key} screen:", got "${header}"`);
dest.screens[key] = {
params: stripSemi(r.next()).split(','),
up: readActs(r, 'Up Act Sum:'),
down: readActs(r, 'Down Act Sum:'),
};
}
return dest;
}
function readFileNameList(r, label) {
const n = Number(r.expect(label));
const files = [];
for (let i = 0; i < n; i++) files.push(stripSemi(r.next()));
return files;
}
function readActs(r, label) {
const n = Number(r.expect(label));
const acts = [];
for (let a = 0; a < n; a++) {
const fields = stripSemi(r.next()).split(':')[1].split(',');
const icons = [];
while (/^icon #\d+:/.test(r.peek() ?? '')) {
const icon = { fields: stripSemi(r.next()).split(':')[1].split(','), objs: [] };
while (/^Obj #\d+:/.test(r.peek() ?? '')) icon.objs.push(parseObj(stripSemi(r.next()).split(':')[1]));
icons.push(icon);
}
acts.push({ fields, icons });
}
return acts;
}
function parseObj(payload) {
const f = payload.split(',');
return {
type: f[0], // 'S' = string
unknown1: f[1], // always -3 in the wild
x: Number(f[2]),
y: Number(f[3]),
font: hexToStr(f[4]), // built-in bitmap font, e.g. ASC0704
text: hexToStr(f[5]),
useTrueType: Number(f[6]), // 1 = render with the Windows font below
ttfName: hexToStr(f[7]), // e.g. Impact
unknown2: f[8],
ptSize: Number(f[9]),
rest: f.slice(10),
};
}
function objToLine(o, i) {
const f = [
o.type, o.unknown1, o.x, o.y, strToHex(o.font), strToHex(o.text),
o.useTrueType, strToHex(o.ttfName), o.unknown2, o.ptSize, ...(o.rest ?? []),
];
return `Obj #${i}:${f.join(',')};`;
}
export function buildTp5(doc) {
const out = [];
out.push(`${doc.version};`);
out.push(`${doc.flag};`);
out.push(`screen para:${doc.screenPara.join(',')};`);
out.push(`Company Sum:${doc.companies.length};`);
for (const c of doc.companies) out.push(`${strToHex(c.name)},${c.destinations.length};`);
for (const c of doc.companies) {
for (const d of c.destinations) {
out.push(`ActIconFileNames:${d.actIconFiles.length};`, ...d.actIconFiles.map((f) => `${f};`));
out.push(`SrnIconFileNames:${d.srnIconFiles.length};`, ...d.srnIconFiles.map((f) => `${f};`));
out.push(`line name:${strToHex(d.lineName)},${strToHex(d.lineName2)},;`);
for (const key of SCREENS) {
const s = d.screens[key];
out.push(`${key} screen:`);
out.push(`${s.params.join(',')};`);
emitActs(out, 'Up Act Sum:', s.up);
emitActs(out, 'Down Act Sum:', s.down);
}
}
}
out.push('');
return out.join('\r\n');
}
function emitActs(out, label, acts) {
out.push(`${label}${acts.length};`);
acts.forEach((a, ai) => {
out.push(`act #${ai}:${a.fields.join(',')};`);
a.icons.forEach((icon, ii) => {
out.push(`icon #${ii}:${icon.fields.join(',')};`);
icon.objs.forEach((o, oi) => out.push(objToLine(o, oi)));
});
});
}
/**
* Flatten a parsed project into the simple per-destination view the editor uses:
* one entry per destination, with the front-screen text of each frame (act).
*/
export function toSimple(doc) {
const company = doc.companies[0];
const [, , h, w] = doc.screenPara.map(Number);
return {
company: company?.name ?? '',
screen: { width: w || 112, height: h || 16 },
destinations: (company?.destinations ?? []).map((d) => ({
name: d.lineName,
lineName: d.lineName2 || d.lineName,
frames: d.screens.fore.up.map((act) => {
const icon = act.icons[0];
const obj = icon?.objs?.[0];
return {
text: obj?.text ?? '',
font: obj?.font ?? 'ASC0704',
useTrueType: obj?.useTrueType ?? 0,
ttfName: obj?.ttfName ?? '',
ptSize: obj?.ptSize ?? 20,
x: obj?.x ?? 0,
effect: Number(icon?.fields?.[4] ?? 9),
};
}),
_raw: d,
})),
};
}

3
src/fonts.js Normal file

File diff suppressed because one or more lines are too long

83
test/authoring.mjs Normal file
View File

@ -0,0 +1,83 @@
/**
* End-to-end: build a .td5 from nothing but text, then read it back and check
* the sign would accept it (CRC), and that the pixels survive intact.
*/
import { writeFileSync } from 'node:fs';
import { FONTS } from '../src/fonts.js';
import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs';
import { parseTd5, buildTd5, pixelsToFrame, frameToPixels, crc16arc, EFFECT } from '../src/codec/td5.mjs';
const SCREEN = { width: 112, height: 16 };
const font = fontFromJSON(FONTS.ASC1609);
const LIST = [
'SCHOOL BUS', 'CHARTER', 'RAIL BUS', 'EXPRESS COACH LINES',
'CARMEL COLLEGE', 'OC1', 'NOT IN SERVICE', 'GO LIONS!',
];
function fit(bmp, height) {
if (bmp.height === height) return bmp;
const out = new Uint8Array(bmp.width * height);
const y0 = Math.floor((height - bmp.height) / 2);
for (let y = 0; y < bmp.height; y++) {
const ty = y + y0;
if (ty >= 0 && ty < height) out.set(bmp.pixels.subarray(y * bmp.width, (y + 1) * bmp.width), ty * bmp.width);
}
return { width: bmp.width, height, pixels: out };
}
const destinations = LIST.map((text) => {
const bmp = fit(trimX(renderText(font, text)), SCREEN.height);
const frame = pixelsToFrame(bmp.pixels, bmp.width, SCREEN.height);
const effect = bmp.width > SCREEN.width ? EFFECT.SCROLL : EFFECT.HOLD;
return { name: text.slice(0, 16), lineName: text.slice(0, 16), frames: [frame], effects: [effect, effect] };
});
// one two-page destination, to exercise the multi-frame path
const pages = ['MORETON BAY', 'BOYS COLLEGE'].map((t) => {
const b = fit(trimX(renderText(font, t)), SCREEN.height);
return pixelsToFrame(b.pixels, b.width, SCREEN.height);
});
destinations.push({ name: 'MORETON BAY', lineName: 'MORETON BAY', frames: pages, effects: [EFFECT.HOLD, EFFECT.HOLD, EFFECT.HOLD, EFFECT.HOLD] });
const bytes = buildTd5({ company: 'Test Coaches', screen: SCREEN, destinations });
writeFileSync(new URL('./authored.td5', import.meta.url), bytes);
// ---- read it back as if we were the sign
const back = parseTd5(bytes);
let fails = 0;
const check = (cond, msg) => { if (!cond) { fails++; console.log(' ❌ ' + msg); } };
check(back.company === 'Test Coaches', 'company survived');
check(back.destinations.length === destinations.length, 'destination count');
check(back.screen.width === 112 && back.screen.height === 16, 'screen size');
check(bytes.length % 0x200 === 0, 'file padded to a 512-byte boundary');
for (const d of back.destinations) {
check(d.crcOk, `${d.name}: CRC verifies`);
const src = destinations.find((x) => x.name === d.name);
check(src && d.frames.length === src.frames.length, `${d.name}: frame count`);
d.frames.forEach((f, i) => {
const a = Buffer.from(f.data), b = Buffer.from(src.frames[i].data);
check(a.equals(b), `${d.name}: frame ${i} pixels identical`);
});
}
// independent CRC verification, byte-for-byte against the stored value
const dv = new DataView(bytes.buffer);
for (let i = 0; i < back.destinations.length; i++) {
const ptr = dv.getUint32(0x200 + i * 4, true);
const off = dv.getUint32(ptr + 0x30, true);
const len = dv.getUint32(ptr + 0x34, true);
const stored = bytes[off + 1] | (bytes[off + 2] << 8);
check(stored === crc16arc(bytes.subarray(off + 3, off + len)), `block ${i}: CRC recomputes`);
}
console.log(`authored ${destinations.length} destinations -> ${bytes.length} bytes (test/authored.td5)`);
for (const d of back.destinations) {
const w = d.frames[0].width;
console.log(` ${d.name.padEnd(17)} ${String(w).padStart(3)}px ${w > 112 ? 'scrolls' : 'fits '} ` +
`${d.frames.length > 1 ? d.frames.length + ' pages' : ''}`);
}
console.log(fails === 0 ? '\n✅ every check passed — a sign-ready file built from scratch' : `\n${fails} checks failed`);
process.exit(fails ? 1 : 0);

53
test/fontpreview.mjs Normal file
View File

@ -0,0 +1,53 @@
/** 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');

44
test/png.mjs Normal file
View File

@ -0,0 +1,44 @@
/** Minimal PNG writer, just enough for test previews. */
import { deflateSync } from 'node:zlib';
function crc32(buf) {
let c, crc = 0xffffffff;
for (let n = 0; n < buf.length; n++) {
c = (crc ^ buf[n]) & 0xff;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
crc = c ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
/** rgb: (x,y) => [r,g,b] */
export function writePNG(width, height, rgb) {
const raw = Buffer.alloc((width * 3 + 1) * height);
let p = 0;
for (let y = 0; y < height; y++) {
raw[p++] = 0;
for (let x = 0; x < width; x++) {
const [r, g, b] = rgb(x, y);
raw[p++] = r; raw[p++] = g; raw[p++] = b;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}

62
test/roundtrip.mjs Normal file
View File

@ -0,0 +1,62 @@
/**
* Proof of format: parse a real TP5 export, rebuild it from the parsed model,
* and require the result to be byte-identical.
*/
import { readFileSync } from 'node:fs';
import { parseTd5, buildTd5, frameToPixels, pixelsToFrame } from '../src/codec/td5.mjs';
const path = process.argv[2] ?? new URL('../samples/EXPRESS 1.td5', import.meta.url);
const original = new Uint8Array(readFileSync(path));
const doc = parseTd5(original);
console.log(`company : ${doc.company}`);
console.log(`version : ${doc.version} exported ${doc.timestamp}`);
console.log(`screen : ${doc.screen.width}x${doc.screen.height}`);
console.log(`dests : ${doc.destinations.length}`);
const multi = doc.destinations.filter((d) => d.frames.length > 1);
console.log(`multiframe: ${multi.map((d) => `${d.name}(${d.frames.length})`).join(', ') || 'none'}`);
const badCrc = doc.destinations.filter((d) => !d.crcOk);
console.log(`crc check : ${doc.destinations.length - badCrc.length}/${doc.destinations.length} verify` +
(badCrc.length ? ` — FAILED: ${badCrc.map((d) => d.name).join(', ')}` : ''));
const rebuilt = buildTd5(doc, { timestamp: doc.timestamp });
let ok = rebuilt.length === original.length;
const diffs = [];
if (!ok) {
console.log(`\nSIZE MISMATCH: original ${original.length}, rebuilt ${rebuilt.length}`);
} else {
for (let i = 0; i < original.length; i++) {
if (original[i] !== rebuilt[i]) {
diffs.push(i);
if (diffs.length > 40) break;
}
}
ok = diffs.length === 0;
}
if (ok) {
console.log('\n✅ BYTE-IDENTICAL round-trip over all ' + original.length + ' bytes');
} else {
console.log(`\n${diffs.length}${diffs.length > 40 ? '+' : ''} differing bytes`);
for (const off of diffs.slice(0, 24)) {
console.log(` 0x${off.toString(16).padStart(5, '0')}: orig ${hx(original[off])} != ${hx(rebuilt[off])}`);
}
}
// pixel round-trip
let pxOk = true;
for (const d of doc.destinations) {
for (const f of d.frames) {
const re = pixelsToFrame(frameToPixels(f), f.width, f.height);
if (Buffer.compare(Buffer.from(re.data), Buffer.from(f.data)) !== 0) {
pxOk = false;
console.log(` pixel round-trip failed: ${d.name}`);
}
}
}
console.log(pxOk ? '✅ pixel pack/unpack round-trips for every frame' : '❌ pixel round-trip failed');
function hx(b) { return '0x' + b.toString(16).padStart(2, '0'); }
process.exit(ok && pxOk ? 0 : 1);

51
test/sheet.mjs Normal file
View File

@ -0,0 +1,51 @@
/** Column-mapping checks for spreadsheet import. */
import { readCsv, mapRows } from '../src/codec/sheet.mjs';
let fails = 0;
function check(label, got, want) {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (!ok) { fails++; console.log(`${label}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`); }
else console.log(`${label}`);
}
// The template shipped with these buses: a "Line" row number AND a "Line Name".
// The name must win over the number — this was a real bug.
check('shipped template layout',
mapRows(readCsv(
'Company Name:,Yutong Coaches\n' +
'\n' +
'Line,Line Name,Description,Content (Display)\n' +
'1,SCHOOL BUS,morning,SCHOOL BUS\n' +
'2,CARMEL,college run,CARMEL COLLEGE\n'
)),
{ company: 'Yutong Coaches', destinations: [
{ name: 'SCHOOL BUS', text: 'SCHOOL BUS' },
{ name: 'CARMEL', text: 'CARMEL COLLEGE' },
] });
check('only a number column plus content — do not show the driver a bare number',
mapRows(readCsv('Line,Content (Display)\n1,DEPOT\n2,RAIL BUS\n')).destinations,
[{ name: 'DEPOT', text: 'DEPOT' }, { name: 'RAIL BUS', text: 'RAIL BUS' }]);
check('no header at all',
mapRows(readCsv('SCHOOL BUS\nCHARTER\n')).destinations,
[{ name: 'SCHOOL BUS', text: 'SCHOOL BUS' }, { name: 'CHARTER', text: 'CHARTER' }]);
check('destination column naming',
mapRows(readCsv('Name,Destination\nAIRPORT,AIRPORT SHUTTLE VIA CITY\n')).destinations,
[{ name: 'AIRPORT', text: 'AIRPORT SHUTTLE VIA CITY' }]);
check('blank rows and stray whitespace ignored',
mapRows(readCsv('Line Name,Content\n\n DEPOT , DEPOT VIA YARD \n\n')).destinations,
[{ name: 'DEPOT', text: 'DEPOT VIA YARD' }]);
check('controller name is capped at the 16 characters the record holds',
mapRows(readCsv('Line Name,Content\nA VERY LONG DESTINATION NAME,SOMETHING\n')).destinations[0].name,
'A VERY LONG DEST');
check('quoted fields with commas',
mapRows(readCsv('Line Name,Content\n"CITY, VIA MALL","CITY, VIA MALL"\n')).destinations,
[{ name: 'CITY, VIA MALL', text: 'CITY, VIA MALL' }]);
console.log(fails === 0 ? '\n✅ spreadsheet mapping correct' : `\n${fails} failed`);
process.exit(fails ? 1 : 0);

27
test/structdiff.mjs Normal file
View File

@ -0,0 +1,27 @@
/** Compare the structure of a TP5-made file against one we authored. */
import { readFileSync } from 'node:fs';
function dump(buf, label) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const ptrs = [];
for (let i = 0x200; ; i += 4) { const v = dv.getUint32(i, true); if (v === 0xffffffff) break; ptrs.push(v); }
const hx = (a, b) => Buffer.from(buf.subarray(a, b)).toString('hex').replace(/(..)/g, '$1 ').trim();
console.log(`=== ${label} (${buf.length} bytes, ${ptrs.length} destinations)`);
console.log(` 0x30..0x48 : ${hx(0x30, 0x48)}`);
console.log(` 0x80..0xa0 : ${hx(0x80, 0xa0)}`);
console.log(` 0xa0..0xb0 : ${hx(0xa0, 0xb0)}`);
for (let r = 0; r < 2 && r < ptrs.length; r++) {
const q = ptrs[r];
console.log(` rec${r} name : ${JSON.stringify(String.fromCharCode(...buf.subarray(q, q + 0x10)))}`);
console.log(` rec${r} line : ${JSON.stringify(String.fromCharCode(...buf.subarray(q + 0x10, q + 0x20)))}`);
console.log(` rec${r} third : ${JSON.stringify(String.fromCharCode(...buf.subarray(q + 0x20, q + 0x30)))}`);
console.log(` rec${r} tail : ${hx(q + 0x30, q + 0x60)}`);
console.log(` rec${r} tail2 : ${hx(q + 0x60, q + 0x80)}`);
}
}
const a = new Uint8Array(readFileSync(new URL('../samples/EXPRESS 1.td5', import.meta.url)));
const b = new Uint8Array(readFileSync(new URL('./authored.td5', import.meta.url)));
dump(a, 'TP5 original — works on the controller');
console.log();
dump(b, 'DestoGod, built from scratch');

20
test/tp5roundtrip.mjs Normal file
View File

@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs';
import { parseTp5, buildTp5, toSimple } from '../src/codec/tp5.mjs';
const orig = readFileSync(new URL('../samples/EXPRESS.tp5', import.meta.url), 'latin1');
const doc = parseTp5(orig);
const out = buildTp5(doc);
console.log('companies:', doc.companies.map(c => `${c.name}(${c.destinations.length})`).join(', '));
console.log(out === orig ? '✅ .tp5 round-trip byte-identical' : '❌ .tp5 round-trip differs');
if (out !== orig) {
const a = orig.split('\r\n'), b = out.split('\r\n');
console.log(` lines ${a.length} vs ${b.length}`);
for (let i = 0, n = 0; i < Math.max(a.length, b.length) && n < 6; i++)
if (a[i] !== b[i]) { console.log(` L${i+1}\n orig: ${String(a[i]).slice(0,110)}\n new : ${String(b[i]).slice(0,110)}`); n++; }
}
const s = toSimple(doc);
console.log(`\nsimple view: company=${s.company} screen=${s.screen.width}x${s.screen.height}`);
for (const d of s.destinations.slice(0, 5))
console.log(` ${d.name.padEnd(18)} -> ${d.frames.map(f => `"${f.text}" [${f.useTrueType ? f.ttfName + ' ' + f.ptSize : f.font}] fx=${f.effect}`).join(' + ')}`);
const ch = s.destinations.find(d => d.frames.length > 1);
if (ch) console.log(` ${ch.name.padEnd(18)} -> ${ch.frames.map(f => `"${f.text}"`).join(' + ')} (${ch.frames.length} frames)`);

28
tools/build-fonts.mjs Normal file
View File

@ -0,0 +1,28 @@
/** Convert the shipped .font files into a JS module the app can embed. */
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { parseFont, fontToJSON } from '../src/codec/bitfont.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const srcDir = join(here, '../../yutongapp');
const outFile = join(here, '../src/fonts.js');
const fonts = {};
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.font')).sort()) {
const parsed = parseFont(readFileSync(join(srcDir, f), 'latin1'));
fonts[parsed.name] = fontToJSON(parsed);
const codes = [...parsed.glyphs.keys()];
console.log(
`${parsed.name.padEnd(9)} cell ${String(parsed.cellWidth).padStart(2)}x${parsed.height}` +
` ${String(parsed.glyphs.size).padStart(3)} glyphs codes ${Math.min(...codes)}..${Math.max(...codes)}`
);
}
writeFileSync(outFile,
'// Generated by tools/build-fonts.mjs from the .font files shipped with TP5.\n' +
'// Each glyph is [advance, ...rows]; rows are MSB-first, cellWidth bits wide.\n' +
'export const FONTS = ' + JSON.stringify(fonts) + ';\n');
const kb = (readFileSync(outFile).length / 1024).toFixed(0);
console.log(`\nwrote ${outFile} (${kb} KB, ${Object.keys(fonts).length} fonts)`);

59
tools/bundle.mjs Normal file
View File

@ -0,0 +1,59 @@
/**
* Inline every module into one self-contained HTML file, so the whole tool is a
* single document that opens straight from the desktop with no install, no
* server and no internet.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
// Dependency order, leaves first.
const MODULES = [
'src/fonts.js',
'src/codec/bitfont.mjs',
'src/codec/td5.mjs',
'src/codec/tp5.mjs',
'src/codec/sheet.mjs',
'app/main.mjs',
];
function strip(src, name) {
const out = src
// drop the import lines; every symbol ends up in one shared scope
.replace(/^\s*import\s+[^;]*?from\s*['"][^'"]+['"];?\s*$/gm, '')
// `export const X` -> `const X`, `export function f` -> `function f`
.replace(/^export\s+(const|let|var|function|class|async)\b/gm, '$1')
// bare re-export statements, if any ever appear
.replace(/^export\s*\{[^}]*\};?\s*$/gm, '');
if (/^\s*(import|export)\b/m.test(out)) {
throw new Error(`${name}: an import/export slipped through the bundler`);
}
return `\n// ===== ${name} =====\n${out.trim()}\n`;
}
const code = MODULES.map((m) => strip(readFileSync(join(root, m), 'utf8'), m)).join('\n');
const html = readFileSync(join(root, 'app/index.html'), 'utf8');
if (!html.includes('<script type="module" src="./main.mjs"></script>')) {
throw new Error('index.html no longer has the expected script tag');
}
const banner = `<!--
DestoGod bus destination sign editor.
Single self-contained file: no install, no internet, nothing to set up.
Built ${new Date().toISOString().slice(0, 10)} from destogod/src + destogod/app.
-->
`;
const bundled = banner + html.replace(
'<script type="module" src="./main.mjs"></script>',
`<script type="module">\n${code}\n</script>`
);
mkdirSync(join(root, 'dist'), { recursive: true });
const outPath = join(root, 'dist/DestoGod.html');
writeFileSync(outPath, bundled);
console.log(`wrote dist/DestoGod.html (${(bundled.length / 1024).toFixed(0)} KB, ${MODULES.length} modules inlined)`);