@asterisk-labs/rumi 0.0.0-stage → 0.27.0-alpha.2
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 +232 -0
- package/README.md +73 -2
- package/index.d.ts +143 -0
- package/index.js +1 -0
- package/package.json +47 -4
- package/playground/README.md +26 -0
- package/playground/app.js +374 -0
- package/playground/asterisk-mark.svg +22 -0
- package/playground/index.html +101 -0
- package/playground/render.js +65 -0
- package/playground/rumi-mark.svg +15 -0
- package/playground/server.mjs +80 -0
- package/playground/styles.css +145 -0
- package/src/dtypes.js +48 -0
- package/src/epsg.js +138 -0
- package/src/file_header.js +284 -0
- package/src/header.js +334 -0
- package/src/reader.js +368 -0
- package/src/source.js +149 -0
- package/src/trailer.js +182 -0
package/src/source.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
function checkedRange(offset, length) {
|
|
2
|
+
if (typeof offset !== "bigint" || offset < 0n) {
|
|
3
|
+
throw new RangeError("offset must be a non-negative bigint");
|
|
4
|
+
}
|
|
5
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
6
|
+
throw new RangeError("length must be a non-negative safe integer");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bytesFrom(value) {
|
|
11
|
+
if (value instanceof Uint8Array) return value;
|
|
12
|
+
if (value instanceof ArrayBuffer
|
|
13
|
+
|| (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer)) {
|
|
14
|
+
return new Uint8Array(value);
|
|
15
|
+
}
|
|
16
|
+
if (ArrayBuffer.isView(value)) {
|
|
17
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class MemorySource {
|
|
23
|
+
constructor(bytes) {
|
|
24
|
+
this.bytes = bytes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async read(offset, length) {
|
|
28
|
+
checkedRange(offset, length);
|
|
29
|
+
if (offset > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("memory offset is too large");
|
|
30
|
+
const start = Number(offset);
|
|
31
|
+
if (start > this.bytes.byteLength || length > this.bytes.byteLength - start) {
|
|
32
|
+
throw new RangeError("read extends past the source");
|
|
33
|
+
}
|
|
34
|
+
return this.bytes.slice(start, start + length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
size() {
|
|
38
|
+
return BigInt(this.bytes.byteLength);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class BlobSource {
|
|
43
|
+
constructor(blob) {
|
|
44
|
+
this.blob = blob;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async read(offset, length) {
|
|
48
|
+
checkedRange(offset, length);
|
|
49
|
+
if (offset > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("Blob offset is too large");
|
|
50
|
+
const start = Number(offset);
|
|
51
|
+
if (start > this.blob.size || length > this.blob.size - start) {
|
|
52
|
+
throw new RangeError("read extends past the Blob");
|
|
53
|
+
}
|
|
54
|
+
return new Uint8Array(await this.blob.slice(start, start + length).arrayBuffer());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
size() {
|
|
58
|
+
return BigInt(this.blob.size);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class HttpSource {
|
|
63
|
+
constructor(url, fetchImpl, signal) {
|
|
64
|
+
this.url = url;
|
|
65
|
+
this.fetch = (input, init) => fetchImpl.call(globalThis, input, init);
|
|
66
|
+
this.signal = signal;
|
|
67
|
+
this.total = undefined;
|
|
68
|
+
this.etag = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async read(offset, length) {
|
|
72
|
+
checkedRange(offset, length);
|
|
73
|
+
if (length === 0) return new Uint8Array();
|
|
74
|
+
const end = offset + BigInt(length) - 1n;
|
|
75
|
+
const headers = { Range: `bytes=${offset}-${end}` };
|
|
76
|
+
if (this.etag !== undefined && !this.etag.startsWith("W/")) {
|
|
77
|
+
headers["If-Match"] = this.etag;
|
|
78
|
+
}
|
|
79
|
+
const response = await this.fetch(this.url, {
|
|
80
|
+
headers,
|
|
81
|
+
signal: this.signal,
|
|
82
|
+
});
|
|
83
|
+
if (response.status === 412) throw new Error("Rumi source changed while it was being read");
|
|
84
|
+
if (response.status !== 206) {
|
|
85
|
+
throw new Error(`server did not honor the byte range (HTTP ${response.status})`);
|
|
86
|
+
}
|
|
87
|
+
const contentRange = response.headers.get("Content-Range");
|
|
88
|
+
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(contentRange ?? "");
|
|
89
|
+
if (!match || BigInt(match[1]) !== offset || BigInt(match[2]) !== end) {
|
|
90
|
+
throw new Error("server returned the wrong Content-Range");
|
|
91
|
+
}
|
|
92
|
+
const total = BigInt(match[3]);
|
|
93
|
+
if (total <= end) throw new Error("server returned an invalid Content-Range size");
|
|
94
|
+
if (this.total !== undefined && this.total !== total) {
|
|
95
|
+
throw new Error("Rumi source size changed while it was being read");
|
|
96
|
+
}
|
|
97
|
+
this.total = total;
|
|
98
|
+
|
|
99
|
+
const etag = response.headers.get("ETag") ?? undefined;
|
|
100
|
+
if (this.etag !== undefined && etag !== undefined && etag !== this.etag) {
|
|
101
|
+
throw new Error("Rumi source changed while it was being read");
|
|
102
|
+
}
|
|
103
|
+
if (this.etag === undefined && etag !== undefined) this.etag = etag;
|
|
104
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
105
|
+
if (bytes.byteLength !== length) {
|
|
106
|
+
throw new RangeError(`range returned ${bytes.byteLength} bytes, expected ${length}`);
|
|
107
|
+
}
|
|
108
|
+
return bytes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
size() {
|
|
112
|
+
if (this.total === undefined) throw new Error("source size is not known yet");
|
|
113
|
+
return this.total;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function sourceFrom(value, {
|
|
118
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
119
|
+
signal,
|
|
120
|
+
} = {}) {
|
|
121
|
+
const bytes = bytesFrom(value);
|
|
122
|
+
if (bytes) return new MemorySource(bytes);
|
|
123
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) return new BlobSource(value);
|
|
124
|
+
if (typeof value === "string" || value instanceof URL) {
|
|
125
|
+
if (typeof fetchImpl !== "function") throw new Error("fetch is not available");
|
|
126
|
+
return new HttpSource(value, fetchImpl, signal);
|
|
127
|
+
}
|
|
128
|
+
if (value && typeof value.read === "function" && typeof value.size === "function") return value;
|
|
129
|
+
throw new TypeError("source must be bytes, a Blob, a URL, or an object with read() and size()");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function readExact(source, offset, length) {
|
|
133
|
+
const bytes = await source.read(offset, length);
|
|
134
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
135
|
+
throw new TypeError("source.read() must return a Uint8Array");
|
|
136
|
+
}
|
|
137
|
+
if (bytes.byteLength !== length) {
|
|
138
|
+
throw new RangeError(`source returned ${bytes.byteLength} bytes, expected ${length}`);
|
|
139
|
+
}
|
|
140
|
+
return bytes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function sourceSize(source) {
|
|
144
|
+
const value = await source.size();
|
|
145
|
+
if (typeof value !== "bigint" || value < 0n) {
|
|
146
|
+
throw new TypeError("source.size() must return a non-negative bigint");
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
package/src/trailer.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readExact, sourceSize } from "./source.js";
|
|
2
|
+
|
|
3
|
+
const MAGIC = 0x4c494154;
|
|
4
|
+
const VERSION = 1;
|
|
5
|
+
const FIXED_SIZE = 22;
|
|
6
|
+
const INT64_MIN = -(1n << 63n);
|
|
7
|
+
const INT64_MAX = (1n << 63n) - 1n;
|
|
8
|
+
|
|
9
|
+
function floorDiv(value, divisor) {
|
|
10
|
+
return value >= 0n
|
|
11
|
+
? value / divisor
|
|
12
|
+
: -((-value + divisor - 1n) / divisor);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function roundedRatio(value, divisor) {
|
|
16
|
+
return floorDiv(2n * value + divisor, 2n * divisor);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bitLength(value) {
|
|
20
|
+
let bits = 0;
|
|
21
|
+
while (value !== 0n) {
|
|
22
|
+
value >>= 1n;
|
|
23
|
+
bits += 1;
|
|
24
|
+
}
|
|
25
|
+
return bits;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function packedValue(bytes, index, bits) {
|
|
29
|
+
if (bits === 0) return 0n;
|
|
30
|
+
const at = BigInt(index) * BigInt(bits);
|
|
31
|
+
const first = Number(at >> 3n);
|
|
32
|
+
const shift = Number(at & 7n);
|
|
33
|
+
const count = Math.ceil((shift + bits) / 8);
|
|
34
|
+
let value = 0n;
|
|
35
|
+
for (let byte = 0; byte < count; byte += 1) {
|
|
36
|
+
value |= BigInt(bytes[first + byte]) << BigInt(byte * 8);
|
|
37
|
+
}
|
|
38
|
+
return (value >> BigInt(shift)) & ((1n << BigInt(bits)) - 1n);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function residualOf(value) {
|
|
42
|
+
return (value & 1n) === 0n ? value >> 1n : -((value >> 1n) + 1n);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function freezeTime(kind, seconds) {
|
|
46
|
+
if (kind === "instant") return Object.freeze(seconds);
|
|
47
|
+
return Object.freeze(Array.from(
|
|
48
|
+
{ length: seconds.length / 2 },
|
|
49
|
+
(_, index) => Object.freeze([seconds[index * 2], seconds[index * 2 + 1]]),
|
|
50
|
+
));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readTrailer(
|
|
54
|
+
source,
|
|
55
|
+
offset,
|
|
56
|
+
bands,
|
|
57
|
+
timeCount,
|
|
58
|
+
maxMetadataBytes,
|
|
59
|
+
maxTimeCoordinates,
|
|
60
|
+
) {
|
|
61
|
+
const total = await sourceSize(source);
|
|
62
|
+
if (offset > total) throw new RangeError("Rumi trailer starts past the end of the source");
|
|
63
|
+
const length = total - offset;
|
|
64
|
+
if (length > BigInt(maxMetadataBytes)) {
|
|
65
|
+
throw new RangeError("Rumi trailer exceeds maxMetadataBytes");
|
|
66
|
+
}
|
|
67
|
+
const bytes = await readExact(source, offset, Number(length));
|
|
68
|
+
let cursor = 0;
|
|
69
|
+
function take(size) {
|
|
70
|
+
if (!Number.isSafeInteger(size) || size < 0 || cursor > bytes.byteLength - size) {
|
|
71
|
+
throw new RangeError("Rumi trailer exceeds maxMetadataBytes");
|
|
72
|
+
}
|
|
73
|
+
const value = bytes.subarray(cursor, cursor + size);
|
|
74
|
+
cursor += size;
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prefix = take(6);
|
|
79
|
+
const prefixView = new DataView(prefix.buffer, prefix.byteOffset, prefix.byteLength);
|
|
80
|
+
if (prefixView.getUint32(0, true) !== MAGIC) throw new Error("invalid Rumi trailer magic");
|
|
81
|
+
if (prefixView.getUint16(4, true) !== VERSION) throw new Error("unsupported Rumi trailer version");
|
|
82
|
+
|
|
83
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
84
|
+
const texts = [];
|
|
85
|
+
const unique = new Set();
|
|
86
|
+
for (let band = 0; band < bands; band += 1) {
|
|
87
|
+
const lengthBytes = take(2);
|
|
88
|
+
const length = new DataView(
|
|
89
|
+
lengthBytes.buffer,
|
|
90
|
+
lengthBytes.byteOffset,
|
|
91
|
+
lengthBytes.byteLength,
|
|
92
|
+
).getUint16(0, true);
|
|
93
|
+
if (length === 0) throw new Error(`band ${band} has an empty description`);
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = decoder.decode(take(length));
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`band ${band} description is not valid UTF-8`);
|
|
99
|
+
}
|
|
100
|
+
if (text.includes("\0")) throw new Error(`band ${band} description contains NUL`);
|
|
101
|
+
if (unique.has(text)) throw new Error(`band ${band} description is not unique`);
|
|
102
|
+
unique.add(text);
|
|
103
|
+
texts.push(text);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fixed = take(FIXED_SIZE);
|
|
107
|
+
const view = new DataView(fixed.buffer, fixed.byteOffset, fixed.byteLength);
|
|
108
|
+
const timeType = view.getUint8(0);
|
|
109
|
+
const timeBits = view.getUint8(1);
|
|
110
|
+
if (timeType !== 1 && timeType !== 2) throw new Error("invalid Rumi time type");
|
|
111
|
+
if (timeBits > 64) throw new Error("Rumi time_bits is greater than 64");
|
|
112
|
+
const timeEpoch = view.getBigInt64(2, true);
|
|
113
|
+
const timeStep = view.getBigInt64(10, true);
|
|
114
|
+
const timeScale = view.getUint32(18, true);
|
|
115
|
+
if (timeScale !== 1 && timeScale !== 86400) throw new Error("invalid Rumi time scale");
|
|
116
|
+
|
|
117
|
+
const coordinateCount = timeCount * (timeType === 1 ? 2 : 1);
|
|
118
|
+
if (!Number.isSafeInteger(coordinateCount)
|
|
119
|
+
|| coordinateCount > maxTimeCoordinates) {
|
|
120
|
+
throw new RangeError("Rumi time coordinates exceed maxTimeCoordinates");
|
|
121
|
+
}
|
|
122
|
+
const packedLength = Math.ceil(coordinateCount * timeBits / 8);
|
|
123
|
+
const packed = take(packedLength);
|
|
124
|
+
if (cursor !== bytes.byteLength) throw new Error("Rumi file has bytes after its trailer");
|
|
125
|
+
const spare = packedLength * 8 - coordinateCount * timeBits;
|
|
126
|
+
if (spare !== 0 && (packed[packedLength - 1] >> (8 - spare)) !== 0) {
|
|
127
|
+
throw new Error("Rumi time residual padding is not zero");
|
|
128
|
+
}
|
|
129
|
+
const coordinates = new Array(coordinateCount);
|
|
130
|
+
let widest = 0;
|
|
131
|
+
for (let index = 0; index < coordinateCount; index += 1) {
|
|
132
|
+
const encoded = packedValue(packed, index, timeBits);
|
|
133
|
+
widest = Math.max(widest, bitLength(encoded));
|
|
134
|
+
const coordinate = timeEpoch + BigInt(index) * timeStep + residualOf(encoded);
|
|
135
|
+
if (coordinate < INT64_MIN || coordinate > INT64_MAX) {
|
|
136
|
+
throw new RangeError(`Rumi time coordinate ${index} overflows int64`);
|
|
137
|
+
}
|
|
138
|
+
coordinates[index] = coordinate;
|
|
139
|
+
}
|
|
140
|
+
if (widest !== timeBits) throw new Error("Rumi time_bits is not canonical");
|
|
141
|
+
if (coordinates[0] !== timeEpoch) throw new Error("Rumi time_epoch is not canonical");
|
|
142
|
+
const expectedStep = coordinateCount < 2
|
|
143
|
+
? 0n
|
|
144
|
+
: roundedRatio(coordinates.at(-1) - coordinates[0], BigInt(coordinateCount - 1));
|
|
145
|
+
if (timeStep !== expectedStep) throw new Error("Rumi time_step is not canonical");
|
|
146
|
+
if (timeScale === 1 && coordinates.every((value) => value % 86400n === 0n)) {
|
|
147
|
+
throw new Error("Rumi time_scale is not canonical");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (timeType === 2) {
|
|
151
|
+
for (let index = 1; index < coordinates.length; index += 1) {
|
|
152
|
+
if (coordinates[index] < coordinates[index - 1]) {
|
|
153
|
+
throw new Error(`Rumi instant ${index} goes backwards`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
for (let index = 0; index < timeCount; index += 1) {
|
|
158
|
+
const start = coordinates[index * 2];
|
|
159
|
+
const end = coordinates[index * 2 + 1];
|
|
160
|
+
if (start >= end) throw new Error(`Rumi interval ${index} is empty or reversed`);
|
|
161
|
+
if (index > 0 && coordinates[index * 2 - 1] > start) {
|
|
162
|
+
throw new Error(`Rumi interval ${index} overlaps the previous interval`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scale = BigInt(timeScale);
|
|
168
|
+
const seconds = coordinates.map((value, index) => {
|
|
169
|
+
const second = value * scale;
|
|
170
|
+
if (second < INT64_MIN || second > INT64_MAX) {
|
|
171
|
+
throw new RangeError(`Rumi time coordinate ${index} in seconds overflows int64`);
|
|
172
|
+
}
|
|
173
|
+
return second;
|
|
174
|
+
});
|
|
175
|
+
const timeKind = timeType === 2 ? "instant" : "interval";
|
|
176
|
+
return Object.freeze({
|
|
177
|
+
bands: Object.freeze(texts),
|
|
178
|
+
time: freezeTime(timeKind, seconds),
|
|
179
|
+
timeKind,
|
|
180
|
+
end: offset + BigInt(cursor),
|
|
181
|
+
});
|
|
182
|
+
}
|