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:
commit
07977624ae
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal 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
239
README.md
Normal 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
198
app/index.html
Normal 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 CHARTER RAIL BUS 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
953
app/main.mjs
Normal 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
2072
dist/DestoGod.html
vendored
Normal file
File diff suppressed because one or more lines are too long
38
re/affine.py
Normal file
38
re/affine.py
Normal 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
28
re/chk16.py
Normal 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
31
re/disx.py
Normal 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
14
re/find_const.py
Normal 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
46
re/findtab.py
Normal 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
33
re/parse.py
Normal 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
12
re/pe.py
Normal 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
48
re/tryranges.py
Normal 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
56
re/verify.py
Normal 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
10
re/xref.py
Normal 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
BIN
samples/EXPRESS 1.td5
Normal file
Binary file not shown.
1091
samples/EXPRESS.tp5
Normal file
1091
samples/EXPRESS.tp5
Normal file
File diff suppressed because it is too large
Load Diff
14
samples/README.md
Normal file
14
samples/README.md
Normal 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
BIN
samples/desto-test.xlsx
Normal file
Binary file not shown.
133
src/codec/bitfont.mjs
Normal file
133
src/codec/bitfont.mjs
Normal 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
196
src/codec/sheet.mjs
Normal 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
351
src/codec/td5.mjs
Normal 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
215
src/codec/tp5.mjs
Normal 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
3
src/fonts.js
Normal file
File diff suppressed because one or more lines are too long
83
test/authoring.mjs
Normal file
83
test/authoring.mjs
Normal 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
53
test/fontpreview.mjs
Normal 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
44
test/png.mjs
Normal 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
62
test/roundtrip.mjs
Normal 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
51
test/sheet.mjs
Normal 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
27
test/structdiff.mjs
Normal 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
20
test/tp5roundtrip.mjs
Normal 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
28
tools/build-fonts.mjs
Normal 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
59
tools/bundle.mjs
Normal 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)`);
|
||||
Loading…
Reference in New Issue
Block a user