@moonbase-machines/files 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/index.js +5 -0
- package/package.json +16 -4
- package/png-read.js +39 -0
- package/png.js +64 -0
- package/test/files.test.js +70 -0
- package/zip.js +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Wharton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moonbase-machines/files
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The formats the machines' files come in and go out as, for the tools that take a title's disks apart: a PNG
|
|
4
|
+
written (`png.js`: from rows of pixels or from RGBA bytes, and sheets of pictures) and read (`png-read.js`), and a
|
|
5
|
+
zip read (`zip.js`: every file, or the one a name matches). In Node only (`node:zlib`, `Buffer`); a CommonJS tool
|
|
6
|
+
loads them with `require()`, Node 22.12 and later.
|
|
7
|
+
|
|
8
|
+
Part of Moonbase (moonbase.au): Mark Wharton's titles of 1985 to 1995, running again in the browser on these
|
|
9
|
+
machines. MIT; see LICENSE. Each file opens with what it is and what it covers.
|
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @moonbase-machines/files: the formats the machines' files come in and go out as, read and written in Node
|
|
2
|
+
// (each module its own: png.js, png-read.js, zip.js).
|
|
3
|
+
export * as png from './png.js';
|
|
4
|
+
export * as pngRead from './png-read.js';
|
|
5
|
+
export * as zip from './zip.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonbase-machines/files",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "The formats the machines' files come in and go out as, in Node: PNG written and read, and zip read.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mark Wharton",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.12"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./index.js",
|
|
13
|
+
"./*": "./*"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.js"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/png-read.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A PNG read, in Node: its size and each pixel's colour as six hex digits, for pictures such as
|
|
2
|
+
// c64gfx's (paletted, 1 to 8 bits a pixel) and ordinary ones (RGB or RGBA, 8 bits a channel).
|
|
3
|
+
//
|
|
4
|
+
// readPng(bytes) { width, height, palette (hex strings, or null), at(x, y) -> 'rrggbb' }
|
|
5
|
+
import { inflateSync } from 'node:zlib';
|
|
6
|
+
|
|
7
|
+
export function readPng(b) {
|
|
8
|
+
if (b.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
|
|
9
|
+
let i = 8, width, height, depth, type, palette = null;
|
|
10
|
+
const idat = [];
|
|
11
|
+
while (i < b.length) {
|
|
12
|
+
const n = b.readUInt32BE(i), t = b.toString('ascii', i + 4, i + 8), d = b.subarray(i + 8, i + 8 + n);
|
|
13
|
+
if (t === 'IHDR') { width = d.readUInt32BE(0); height = d.readUInt32BE(4); depth = d[8]; type = d[9]; if (d[12]) throw new Error('an interlaced PNG'); }
|
|
14
|
+
if (t === 'PLTE') { palette = []; for (let k = 0; k < n; k += 3) palette.push(d.subarray(k, k + 3).toString('hex')); }
|
|
15
|
+
if (t === 'IDAT') idat.push(d);
|
|
16
|
+
i += 12 + n;
|
|
17
|
+
}
|
|
18
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type];
|
|
19
|
+
if (!channels || (type !== 3 && depth !== 8)) throw new Error(`a PNG of type ${type}, depth ${depth}: not read here`);
|
|
20
|
+
const raw = inflateSync(Buffer.concat(idat)), bpp = Math.max(1, (channels * depth) >> 3), stride = Math.ceil(width * channels * depth / 8);
|
|
21
|
+
const out = Buffer.alloc(stride * height);
|
|
22
|
+
// (each row unfiltered: none, sub, up, average, Paeth)
|
|
23
|
+
for (let y = 0; y < height; y++) {
|
|
24
|
+
const f = raw[y * (stride + 1)], src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)), row = out.subarray(y * stride, (y + 1) * stride), up = y ? out.subarray((y - 1) * stride, y * stride) : null;
|
|
25
|
+
for (let x = 0; x < stride; x++) {
|
|
26
|
+
const a = x >= bpp ? row[x - bpp] : 0, u = up ? up[x] : 0, c = up && x >= bpp ? up[x - bpp] : 0;
|
|
27
|
+
const p = a + u - c, pa = Math.abs(p - a), pb = Math.abs(p - u), pc = Math.abs(p - c);
|
|
28
|
+
row[x] = (src[x] + [0, a, u, (a + u) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? u : c][f]) & 0xff;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const hex = (r, g, bl) => ((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0');
|
|
32
|
+
const at = (x, y) => {
|
|
33
|
+
const row = y * stride;
|
|
34
|
+
if (type === 3) { const bit = x * depth, v = (out[row + (bit >> 3)] >> (8 - depth - (bit & 7))) & ((1 << depth) - 1); return palette[v]; }
|
|
35
|
+
const p = row + x * channels;
|
|
36
|
+
return channels >= 3 ? hex(out[p], out[p + 1], out[p + 2]) : hex(out[p], out[p], out[p]);
|
|
37
|
+
};
|
|
38
|
+
return { width, height, palette, at };
|
|
39
|
+
}
|
package/png.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A PNG written, in Node: one IDAT chunk, deflated, no filtering. From rows of pixels, each [r, g, b] or
|
|
2
|
+
// [r, g, b, a] (with its alpha), or from RGBA bytes as a canvas or the machines' screens give them.
|
|
3
|
+
//
|
|
4
|
+
// png(rows) a PNG's bytes from rows of [r, g, b] or [r, g, b, a]
|
|
5
|
+
// pngOfRGBA(rgba, width, height, { alpha }) a PNG's bytes from RGBA bytes, four a pixel, row by row; alpha
|
|
6
|
+
// false, written as RGB, its alpha left out (a picture with none to show)
|
|
7
|
+
// sheet(pictures, { across, gap, scale }) pictures (rows) on one sheet, `gap` gray dots between
|
|
8
|
+
// padded(pictures, fill) pictures of different sizes padded to the largest, top left
|
|
9
|
+
import { deflateSync } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => { for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; return n >>> 0; });
|
|
12
|
+
const crc = buf => { let c = 0xffffffff; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
13
|
+
|
|
14
|
+
function chunk(type, data) {
|
|
15
|
+
const head = Buffer.alloc(4); head.writeUInt32BE(data.length);
|
|
16
|
+
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
|
|
17
|
+
const tail = Buffer.alloc(4); tail.writeUInt32BE(crc(body));
|
|
18
|
+
return Buffer.concat([head, body, tail]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// (the rows as PNG wants them, each a filter byte, 0, then its pixels; n bytes a pixel, 3 or 4)
|
|
22
|
+
function encode(raw, w, h, n) {
|
|
23
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr.set([8, n === 4 ? 6 : 2, 0, 0, 0], 8);
|
|
24
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function png(pixels) {
|
|
28
|
+
const h = pixels.length, w = pixels[0].length, n = pixels[0][0].length;
|
|
29
|
+
const raw = Buffer.alloc(h * (w * n + 1));
|
|
30
|
+
pixels.forEach((row, y) => row.forEach((c, x) => raw.set(c, y * (w * n + 1) + 1 + x * n)));
|
|
31
|
+
return encode(raw, w, h, n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function pngOfRGBA(rgba, w, h, { alpha = true } = {}) {
|
|
35
|
+
const n = alpha ? 4 : 3, raw = Buffer.alloc(h * (w * n + 1));
|
|
36
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) raw.set(rgba.subarray((y * w + x) * 4, (y * w + x) * 4 + n), y * (w * n + 1) + 1 + x * n);
|
|
37
|
+
return encode(raw, w, h, n);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pictures scaled by `scale`, with `gap` gray dots between them, `across` to a row.
|
|
41
|
+
export function sheet(pictures, { across = 3, gap = 8, scale = 1 } = {}) {
|
|
42
|
+
const GRAY = [60, 60, 60];
|
|
43
|
+
const w = pictures[0][0].length * scale, h = pictures[0].length * scale;
|
|
44
|
+
const out = [];
|
|
45
|
+
for (let r = 0; r * across < pictures.length; r++) {
|
|
46
|
+
if (r) for (let g = 0; g < gap; g++) out.push(Array((w + gap) * across - gap).fill(GRAY));
|
|
47
|
+
for (let y = 0; y < h; y++) {
|
|
48
|
+
const row = [];
|
|
49
|
+
for (let c = 0; c < across; c++) {
|
|
50
|
+
if (c) for (let g = 0; g < gap; g++) row.push(GRAY);
|
|
51
|
+
const p = pictures[r * across + c];
|
|
52
|
+
if (!p) { for (let x = 0; x < w; x++) row.push(GRAY); continue; }
|
|
53
|
+
for (const px of p[Math.floor(y / scale)]) for (let s = 0; s < scale; s++) row.push(px);
|
|
54
|
+
}
|
|
55
|
+
out.push(row);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return png(out);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function padded(pictures, fill = [60, 60, 60]) {
|
|
62
|
+
const w = Math.max(...pictures.map(p => p[0].length)), h = Math.max(...pictures.map(p => p.length));
|
|
63
|
+
return pictures.map(p => Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => (p[y] && p[y][x]) || fill)));
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A PNG written and read back; a zip read, its files stored and deflated, a folder and a Mac's __MACOSX copy among
|
|
2
|
+
// them. Made from bytes the tests build, nothing of a title's.
|
|
3
|
+
//
|
|
4
|
+
// node --test test/*.test.js
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { deflateRawSync } from 'node:zlib';
|
|
8
|
+
import { png, pngOfRGBA, sheet, padded } from '@moonbase-machines/files/png.js';
|
|
9
|
+
import { readPng } from '@moonbase-machines/files/png-read.js';
|
|
10
|
+
import { unzip, fromZip } from '@moonbase-machines/files/zip.js';
|
|
11
|
+
|
|
12
|
+
test('a PNG of rows, with and without alpha, reads back pixel for pixel', () => {
|
|
13
|
+
const rows = [[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [17, 34, 51]]];
|
|
14
|
+
const p = readPng(png(rows));
|
|
15
|
+
assert.equal(p.width, 2); assert.equal(p.height, 2);
|
|
16
|
+
assert.deepEqual([p.at(0, 0), p.at(1, 0), p.at(0, 1), p.at(1, 1)], ['ff0000', '00ff00', '0000ff', '112233']);
|
|
17
|
+
const q = readPng(png([[[1, 2, 3, 128]]]));
|
|
18
|
+
assert.equal(q.at(0, 0), '010203');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a PNG of RGBA bytes reads back as the same picture as its rows', () => {
|
|
22
|
+
const rgba = new Uint8Array([10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 1, 2, 3, 255]);
|
|
23
|
+
const p = readPng(pngOfRGBA(rgba, 2, 2));
|
|
24
|
+
assert.deepEqual([p.at(0, 0), p.at(1, 0), p.at(0, 1), p.at(1, 1)], ['0a141e', '28323c', '46505a', '010203']);
|
|
25
|
+
const rows = [[[10, 20, 30], [40, 50, 60]], [[70, 80, 90], [1, 2, 3]]];
|
|
26
|
+
assert.deepEqual(pngOfRGBA(rgba, 2, 2, { alpha: false }), png(rows), 'without its alpha, the same bytes as the rows\' PNG');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a sheet lays pictures out with gray between, the smaller padded to the largest', () => {
|
|
30
|
+
const a = [[[255, 255, 255]]], b = [[[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]];
|
|
31
|
+
const [pa, pb] = padded([a, b]);
|
|
32
|
+
assert.equal(pa.length, 2); assert.equal(pa[0].length, 2); assert.deepEqual(pa[1][1], [60, 60, 60]);
|
|
33
|
+
const s = readPng(sheet([pa, pb], { across: 2, gap: 1 }));
|
|
34
|
+
assert.equal(s.width, 5); assert.equal(s.height, 2);
|
|
35
|
+
assert.deepEqual([s.at(0, 0), s.at(2, 0), s.at(3, 0)], ['ffffff', '3c3c3c', '000000']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// a zip as a zip tool writes one: local headers, the data, the central directory and its end
|
|
39
|
+
function zip(entries) {
|
|
40
|
+
const locals = [], central = [];
|
|
41
|
+
let at = 0;
|
|
42
|
+
for (const { name, data, deflate } of entries) {
|
|
43
|
+
const body = deflate ? deflateRawSync(data) : data, n = Buffer.from(name, 'latin1');
|
|
44
|
+
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(deflate ? 8 : 0, 8); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(n.length, 26);
|
|
45
|
+
const c = Buffer.alloc(46); c.writeUInt32LE(0x02014b50, 0); c.writeUInt16LE(deflate ? 8 : 0, 10); c.writeUInt32LE(body.length, 20); c.writeUInt32LE(data.length, 24); c.writeUInt16LE(n.length, 28); c.writeUInt32LE(at, 42);
|
|
46
|
+
locals.push(local, n, body); central.push(c, n);
|
|
47
|
+
at += 30 + n.length + body.length;
|
|
48
|
+
}
|
|
49
|
+
const dir = Buffer.concat(central), end = Buffer.alloc(22);
|
|
50
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(dir.length, 12); end.writeUInt32LE(at, 16);
|
|
51
|
+
return Buffer.concat([...locals, dir, end]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('a zip read: every file but a folder, stored or deflated; one found by name, past __MACOSX', () => {
|
|
55
|
+
const disk = Buffer.alloc(300, 7);
|
|
56
|
+
const z = zip([
|
|
57
|
+
{ name: 'disks/', data: Buffer.alloc(0) },
|
|
58
|
+
{ name: '__MACOSX/disks/._Game.dsk', data: Buffer.from('mac') },
|
|
59
|
+
{ name: 'disks/Game.dsk', data: disk, deflate: true },
|
|
60
|
+
{ name: 'README.txt', data: Buffer.from('hello') },
|
|
61
|
+
]);
|
|
62
|
+
const files = unzip(z);
|
|
63
|
+
assert.deepEqual(files.map(f => f.name), ['__MACOSX/disks/._Game.dsk', 'disks/Game.dsk', 'README.txt']);
|
|
64
|
+
assert.deepEqual(files.map(f => f.method), [0, 8, 0]);
|
|
65
|
+
assert.equal(files[2].body.toString(), 'hello');
|
|
66
|
+
const { name, body } = fromZip(z, /\.dsk$/i);
|
|
67
|
+
assert.equal(name, 'disks/Game.dsk'); assert.deepEqual(body, disk);
|
|
68
|
+
assert.throws(() => fromZip(z, /\.d64$/), /nothing in the zip matches/);
|
|
69
|
+
assert.throws(() => unzip(Buffer.alloc(40)), /not a zip/);
|
|
70
|
+
});
|
package/zip.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A zip read, in Node: its central directory, at the end, names each file, its sizes and where its local header
|
|
2
|
+
// is (a local header may leave the sizes as zero); each file's data follows its local header's 30 bytes, its name
|
|
3
|
+
// and its extra field, stored as it is (method 0) or deflated (method 8).
|
|
4
|
+
//
|
|
5
|
+
// unzip(zip) every file, [{ name, body, method }] (a folder's entry left out; a method other than
|
|
6
|
+
// 0 or 8, its body null)
|
|
7
|
+
// fromZip(zip, wanted) the first file whose name `wanted` (a RegExp) matches, outside a Mac's __MACOSX folder:
|
|
8
|
+
// { name, body }; an error when none does
|
|
9
|
+
import { inflateRawSync } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
export function unzip(zip) {
|
|
12
|
+
let end = zip.length - 22;
|
|
13
|
+
while (end >= 0 && zip.readUInt32LE(end) !== 0x06054b50) end--;
|
|
14
|
+
if (end < 0) throw new Error('not a zip');
|
|
15
|
+
const files = [];
|
|
16
|
+
for (let at = zip.readUInt32LE(end + 16), n = zip.readUInt16LE(end + 10); n--; ) {
|
|
17
|
+
const method = zip.readUInt16LE(at + 10), size = zip.readUInt32LE(at + 20), nameLength = zip.readUInt16LE(at + 28);
|
|
18
|
+
const extra = zip.readUInt16LE(at + 30), comment = zip.readUInt16LE(at + 32), local = zip.readUInt32LE(at + 42);
|
|
19
|
+
const name = zip.toString('latin1', at + 46, at + 46 + nameLength);
|
|
20
|
+
const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28), data = zip.subarray(start, start + size);
|
|
21
|
+
if (!name.endsWith('/')) files.push({ name, body: method === 0 ? data : method === 8 ? inflateRawSync(data) : null, method });
|
|
22
|
+
at += 46 + nameLength + extra + comment;
|
|
23
|
+
}
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fromZip(zip, wanted) {
|
|
28
|
+
const f = unzip(zip).find(f => wanted.test(f.name) && !f.name.startsWith('__MACOSX'));
|
|
29
|
+
if (!f) throw new Error('nothing in the zip matches ' + wanted);
|
|
30
|
+
return { name: f.name, body: f.body };
|
|
31
|
+
}
|