Commit f50dc6f6 authored by Matthias Betz's avatar Matthias Betz
Browse files

remove zip support



Zip archives are rejected again by the format check with a hint to unpack
them. Removes the zip reader, its tests and helper, the zip path in
loadFile and the zip-only layer renaming (finishLayer name, sourceName).
The archive message no longer suggests packing files as .zip. The
readStream test for always-ready streams moved to the parser tests.

Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 6e9f3032
......@@ -4,11 +4,6 @@
Simply load a CityGML for the viewer to show the geometry.
A ZIP archive can be loaded directly if it contains exactly one `.gml` file
(or, without any `.gml` file, exactly one `.xml` file). The file is
decompressed while it is read, so large archives (also ZIP64) work.
Encrypted entries and Deflate64 compression are not supported.
CityGML with a coordinate system not in meters will not work.
## Controls
......@@ -32,7 +27,7 @@ Structure of `public/src/`:
- `main.js` – connects all parts
- `app/` – store and state (layers, hidden filter values, selection)
- `loading/` – CityGML parser, zip reader, file loading
- `loading/` – CityGML parser, file loading
- `viewer/` – WebGL rendering, camera, navigation
- `ui/` – file input, status, LOD panel
......
......@@ -57,13 +57,8 @@ export function reportLod(state, layerId, lod) {
return updateLayer(state, layerId, l => ({ lods: [...l.lods, lod].sort(compareLods) }));
}
// The optional name replaces the layer name, e.g. with "a.zip/entry.gml".
export function finishLayer(state, layerId, vertexCount, name) {
return updateLayer(state, layerId, () => (
name === undefined
? { status: 'ready', progress: 1, vertexCount }
: { status: 'ready', progress: 1, vertexCount, name }
));
export function finishLayer(state, layerId, vertexCount) {
return updateLayer(state, layerId, () => ({ status: 'ready', progress: 1, vertexCount }));
}
export function failLayer(state, layerId, message) {
......
......@@ -352,7 +352,7 @@ function yieldToEventLoop() {
});
}
// Signatures of formats people try to open instead of (zipped) CityGML.
// Signatures of archive formats people try to open instead of CityGML.
var ARCHIVE_SIGNATURES = [
{ name: "7z archive", bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] },
{ name: "RAR archive", bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07] },
......@@ -382,7 +382,7 @@ function checkLooksLikeXml(bytes) {
for (var s = 0; s < ARCHIVE_SIGNATURES.length; s++) {
if (startsWith(bytes, ARCHIVE_SIGNATURES[s].bytes)) {
throw new Error("The file is a " + ARCHIVE_SIGNATURES[s].name + ", which cannot be read here. " +
"Please unpack it and open the CityGML file, or pack it as a .zip archive.");
"Please unpack it and open the CityGML file.");
}
}
var i = startsWith(bytes, [0xEF, 0xBB, 0xBF]) ? 3 : 0;
......
// Loads a CityGML file, or the CityGML file inside a zip archive (see
// findCityGmlEntry), through the streaming parser. It knows neither the
// Loads a CityGML file through the streaming parser. It knows neither the
// store nor the viewer; everything goes out through the callbacks:
// onBlock(block, getSummary) block of geometry (see createParser);
// getSummary() describes what was parsed so far
// onLod(lod) first block of a LOD (0..4 or null)
// onProgress(fraction) share of the file (or zip entry) read
// Resolves with { summary, sourceName }; rejects with the load error or an
// AbortError once options.signal aborts.
import { createParser, readFile, readStream } from './citygml.js';
import { listEntries, findCityGmlEntry, openEntry } from './zipreader.js';
// Zip archives start with a local file header ("PK\3\4").
export async function isZip(file) {
const buffer = await file.slice(0, 4).arrayBuffer();
return buffer.byteLength === 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50;
}
// onProgress(fraction) share of the file read
// Resolves with { summary }; rejects with the load error or an AbortError
// once options.signal aborts.
import { createParser, readFile } from './citygml.js';
export async function loadFile(file, options = {}) {
const { onBlock = () => {}, onLod = () => {}, onProgress = () => {}, signal, chunkSize } = options;
......@@ -31,13 +23,6 @@ export async function loadFile(file, options = {}) {
});
signal?.throwIfAborted();
if (!(await isZip(file))) {
const summary = await readFile(file, parser, { signal, onProgress, chunkSize });
return { summary, sourceName: file.name };
}
// Only the zip's directory and the selected entry are read, decompressed as a stream.
const entry = findCityGmlEntry(await listEntries(file));
const stream = await openEntry(file, entry, { onProgress });
const summary = await readStream(stream, parser, { signal });
return { summary, sourceName: `${file.name}/${entry.name}` };
const summary = await readFile(file, parser, { signal, onProgress, chunkSize });
return { summary };
}
// Streaming access to entries of a zip archive (Blob/File) without loading the
// archive into memory: only the central directory is read up front, an entry
// is decompressed on the fly with the browser's DecompressionStream.
// Supports stored and deflated entries and ZIP64 (archives and entries > 4 GB).
var EOCD_SIGNATURE = 0x06054b50;
var ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
var ZIP64_EOCD_SIGNATURE = 0x06064b50;
var CENTRAL_SIGNATURE = 0x02014b50;
var LOCAL_SIGNATURE = 0x04034b50;
var EOCD_SIZE = 22;
var MAX_COMMENT = 0xFFFF;
var METHOD_NAMES = { 1: "Shrink", 6: "Implode", 9: "Deflate64", 12: "BZIP2", 14: "LZMA", 93: "Zstandard", 95: "XZ" };
function readBytes(blob, start, end) {
return blob.slice(start, end).arrayBuffer().then(function (buffer) {
return new DataView(buffer);
});
}
function uint64(view, pos) {
return view.getUint32(pos, true) + view.getUint32(pos + 4, true) * 0x100000000;
}
// Lists the entries as { name, directory, method, flags, compressedSize,
// uncompressedSize, localHeaderOffset }.
function listEntries(blob) {
var tailStart = Math.max(0, blob.size - EOCD_SIZE - MAX_COMMENT);
return readBytes(blob, tailStart, blob.size).then(function (tail) {
var eocd = -1;
for (var i = tail.byteLength - EOCD_SIZE; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD_SIGNATURE) {
eocd = i;
break;
}
}
if (eocd < 0) {
throw new Error("Not a zip archive (no end of central directory found)");
}
var count = tail.getUint16(eocd + 10, true);
var cdSize = tail.getUint32(eocd + 12, true);
var cdOffset = tail.getUint32(eocd + 16, true);
if (count !== 0xFFFF && cdSize !== 0xFFFFFFFF && cdOffset !== 0xFFFFFFFF) {
return readCentralDirectory(blob, cdOffset, cdSize, count);
}
// ZIP64: the locator directly precedes the end of central directory record.
var locator = eocd - 20;
if (locator < 0 || tail.getUint32(locator, true) !== ZIP64_LOCATOR_SIGNATURE) {
throw new Error("Broken ZIP64 archive (no ZIP64 locator found)");
}
var recordOffset = uint64(tail, locator + 8);
return readBytes(blob, recordOffset, recordOffset + 56).then(function (rec) {
if (rec.getUint32(0, true) !== ZIP64_EOCD_SIGNATURE) {
throw new Error("Broken ZIP64 archive (no ZIP64 end of central directory)");
}
return readCentralDirectory(blob, uint64(rec, 48), uint64(rec, 40), uint64(rec, 32));
});
});
}
function readCentralDirectory(blob, offset, size, count) {
return readBytes(blob, offset, offset + size).then(function (cd) {
var decoder = new TextDecoder();
var entries = [];
var pos = 0;
for (var n = 0; n < count; n++) {
if (cd.getUint32(pos, true) !== CENTRAL_SIGNATURE) {
throw new Error("Broken zip archive (bad central directory entry " + n + ")");
}
var entry = {
flags: cd.getUint16(pos + 8, true),
method: cd.getUint16(pos + 10, true),
compressedSize: cd.getUint32(pos + 20, true),
uncompressedSize: cd.getUint32(pos + 24, true),
localHeaderOffset: cd.getUint32(pos + 42, true),
};
var nameLength = cd.getUint16(pos + 28, true);
var extraLength = cd.getUint16(pos + 30, true);
var commentLength = cd.getUint16(pos + 32, true);
entry.name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + pos + 46, nameLength));
entry.directory = entry.name.endsWith("/");
applyZip64Extra(cd, pos + 46 + nameLength, extraLength, entry);
entries.push(entry);
pos += 46 + nameLength + extraLength + commentLength;
}
return entries;
});
}
// The ZIP64 extra field holds 64-bit values for exactly those fields that
// are 0xFFFFFFFF in the fixed header, in this order.
function applyZip64Extra(view, start, length, entry) {
var pos = start;
while (pos + 4 <= start + length) {
var id = view.getUint16(pos, true);
var size = view.getUint16(pos + 2, true);
if (id === 0x0001) {
var p = pos + 4;
if (entry.uncompressedSize === 0xFFFFFFFF) { entry.uncompressedSize = uint64(view, p); p += 8; }
if (entry.compressedSize === 0xFFFFFFFF) { entry.compressedSize = uint64(view, p); p += 8; }
if (entry.localHeaderOffset === 0xFFFFFFFF) { entry.localHeaderOffset = uint64(view, p); }
}
pos += 4 + size;
}
}
// Resolves with a ReadableStream of the entry's uncompressed bytes.
// opts.onProgress(fraction) reports the share of compressed bytes read.
function openEntry(blob, entry, opts) {
opts = opts || {};
var onProgress = opts.onProgress || function () { };
return Promise.resolve().then(function () {
if (entry.flags & 0x1) {
throw new Error(entry.name + " is encrypted; encrypted zip entries are not supported");
}
if (entry.method !== 0 && entry.method !== 8) {
var methodName = METHOD_NAMES[entry.method] || "method " + entry.method;
throw new Error(entry.name + " is compressed with " + methodName + ", which the browser cannot decompress; " +
"please re-pack the archive with standard Deflate compression");
}
return readBytes(blob, entry.localHeaderOffset, entry.localHeaderOffset + 30);
}).then(function (local) {
if (local.getUint32(0, true) !== LOCAL_SIGNATURE) {
throw new Error("Broken zip archive (bad local header for " + entry.name + ")");
}
// The local header has its own name and extra field lengths.
var dataStart = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
var total = entry.compressedSize;
var consumed = 0;
var counting = new TransformStream({
transform: function (chunk, controller) {
consumed += chunk.byteLength;
onProgress(total === 0 ? 1 : consumed / total);
controller.enqueue(chunk);
}
});
var stream = blob.slice(dataStart, dataStart + total).stream().pipeThrough(counting);
return entry.method === 8 ? stream.pipeThrough(new DecompressionStream("deflate-raw")) : stream;
});
}
function isIgnored(entry) {
return entry.directory || entry.name.startsWith("__MACOSX/");
}
function hasExtension(entry, extension) {
return entry.name.toLowerCase().endsWith(extension);
}
// The entry to load: the only .gml file, or, if there is no .gml file, the
// only .xml file. Throws if there is none or more than one candidate.
function findCityGmlEntry(entries) {
var files = entries.filter(function (e) { return !isIgnored(e); });
var gml = files.filter(function (e) { return hasExtension(e, ".gml"); });
var candidates = gml.length > 0 ? gml : files.filter(function (e) { return hasExtension(e, ".xml"); });
if (candidates.length === 1) {
return candidates[0];
}
if (gml.length > 1) {
throw new Error("The archive contains " + gml.length + " .gml files; only archives with one are supported:\n" +
gml.map(function (e) { return e.name; }).join("\n"));
}
throw new Error("The archive contains no CityGML file (.gml, or a single .xml)" +
(candidates.length > 1 ? "; found several .xml files:\n" + candidates.map(function (e) { return e.name; }).join("\n") : ""));
}
export { listEntries, openEntry, findCityGmlEntry };
......@@ -31,8 +31,8 @@ function open(file) {
onBlock: (block, getSummary) => viewer.addBlock(layerId, block, getSummary),
onLod: lod => store.update(reportLod, layerId, lod),
onProgress: fraction => store.update(reportProgress, layerId, fraction),
}).then(({ summary, sourceName }) => {
store.update(finishLayer, layerId, summary.vertexCount, sourceName);
}).then(({ summary }) => {
store.update(finishLayer, layerId, summary.vertexCount);
viewer.finishLayer(layerId, summary);
}).catch(error => {
if (error.name === 'AbortError') {
......
......@@ -3,7 +3,7 @@
// unchanged selection).
export function mountFileInput(container, { onFile, enabled = true }) {
const label = document.createElement('label');
label.textContent = 'Open CityGML or ZIP file ';
label.textContent = 'Open CityGML file ';
const input = document.createElement('input');
input.type = 'file';
input.id = 'file-input';
......
......@@ -280,6 +280,29 @@ test('a 7z archive is rejected right away with a hint to unpack it', async () =>
/7z archive.*unpack/is);
});
test('the archive message does not suggest another archive format', async () => {
await assert.rejects(readBytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04]),
error => !/\.zip/.test(error.message));
});
test('readStream lets timers run while reading a stream whose data is always ready', async () => {
// E.g. a decompression stream: every read resolves at once, without I/O.
const chunk = new TextEncoder().encode(' '.repeat(1024));
let chunks = 0;
const stream = new ReadableStream({
pull(controller) {
if (chunks++ < 400) controller.enqueue(chunk);
else controller.close();
}
});
let timerRanAtChunk = null;
setTimeout(() => { timerRanAtChunk = chunks; }, 0);
await CityGML.readStream(stream, CityGML.createParser(), { flushBytes: 1024 });
assert.ok(timerRanAtChunk !== null && timerRanAtChunk < 400,
`timer only ran after reading finished (at chunk ${timerRanAtChunk})`);
});
test('a gzip file is rejected right away naming the format', async () => {
await assert.rejects(readBytes([0x1F, 0x8B, 0x08, 0x00, 0x3C, 0x21]), /gzip/i);
});
......
import test from 'node:test';
import assert from 'node:assert/strict';
import { makeZip } from './zip-util.js';
import { loadFile, isZip } from '../public/src/loading/loadFile.js';
import { loadFile } from '../public/src/loading/loadFile.js';
function cityModel(body) {
return `<?xml version="1.0" encoding="UTF-8"?><core:CityModel>${body}</core:CityModel>`;
......@@ -17,32 +16,11 @@ const XML = cityModel(lod2(...Array.from({ length: 30 }, (_, i) => square(i * 2,
const file = (data, name) => Object.assign(new Blob([data]), { name });
async function collect(f, options = {}) {
const lods = [];
test('a file is parsed into blocks', async () => {
const blocks = new Map();
const result = await loadFile(f, {
...options,
onLod: lod => lods.push(lod),
onBlock: block => blocks.set(block.id, block.count),
});
return { result, lods, vertices: [...blocks.values()].reduce((a, b) => a + b, 0) };
}
test('isZip recognises the zip signature', async () => {
assert.equal(await isZip(makeZip([{ name: 'a.gml', data: XML }])), true);
assert.equal(await isZip(new Blob([XML])), false);
assert.equal(await isZip(new Blob(['PK'])), false);
});
test('a plain file and the same file in a zip give the same geometry', async () => {
const plain = await collect(file(XML, 'city.gml'));
const zipped = await collect(file(makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'data/city.gml', data: XML }]), 'city.zip'));
assert.equal(plain.result.summary.vertexCount, 30 * 6);
assert.equal(zipped.result.summary.vertexCount, 30 * 6);
assert.equal(plain.vertices, 30 * 6);
assert.equal(zipped.vertices, 30 * 6);
assert.equal(plain.result.sourceName, 'city.gml');
assert.equal(zipped.result.sourceName, 'city.zip/data/city.gml');
const result = await loadFile(file(XML, 'city.gml'), { onBlock: block => blocks.set(block.id, block.count) });
assert.equal(result.summary.vertexCount, 30 * 6);
assert.equal([...blocks.values()].reduce((a, b) => a + b, 0), 30 * 6);
});
test('each LOD is reported once although its block is delivered many times', async () => {
......@@ -71,15 +49,14 @@ test('a 7z archive is rejected with its message', async () => {
await assert.rejects(loadFile(file(sevenZip, 'a.7z')), /7z archive/);
});
test('a zip with two GML files is rejected with their names', async () => {
const zip = makeZip([{ name: 'a.gml', data: XML }, { name: 'b.gml', data: XML }]);
await assert.rejects(loadFile(file(zip, 'two.zip')), /a\.gml.*b\.gml/s);
test('a zip archive is rejected with a hint to unpack it', async () => {
// Local file header signature "PK\3\4" followed by some header bytes.
const zip = new Uint8Array([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x08, 0x00]);
await assert.rejects(loadFile(file(zip, 'city.zip')), /zip archive.*unpack/is);
});
test('an aborted load rejects with AbortError, plain and zipped', async () => {
for (const f of [file(XML, 'city.gml'), file(makeZip([{ name: 'a.gml', data: XML }]), 'a.zip')]) {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadFile(f, { signal: controller.signal }), { name: 'AbortError' });
}
test('an aborted load rejects with AbortError', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadFile(file(XML, 'city.gml'), { signal: controller.signal }), { name: 'AbortError' });
});
......@@ -77,7 +77,3 @@ test('compareLods orders 0..4 and puts null last', () => {
assert.deepEqual([null, 3, 0, 2].sort(compareLods), [0, 2, 3, null]);
});
test('finishLayer can replace the layer name, for example with the zip entry', () => {
assert.equal(finishLayer(loading(), 1, 6, 'a.zip/city.gml').layers[0].name, 'a.zip/city.gml');
assert.equal(finishLayer(loading(), 1, 6).layers[0].name, 'a.gml');
});
// Builds zip archives in memory for tests. Supports stored and deflated
// entries, directory entries and ZIP64 records (forced, even for small data).
import zlib from 'node:zlib';
const enc = new TextEncoder();
function bytes(data) {
return typeof data === 'string' ? enc.encode(data) : data;
}
// entries: [{ name, data?, method?: 0|8 (default 8), dir?: true,
// flags?: number, rawMethod?: number }]
// rawMethod writes that method id without compressing (for unsupported-method tests).
export function makeZip(entries, { zip64 = false } = {}) {
const parts = [];
const central = [];
let offset = 0;
for (const e of entries) {
const name = enc.encode(e.dir ? e.name.replace(/\/?$/, '/') : e.name);
const raw = e.dir ? new Uint8Array(0) : bytes(e.data);
const method = e.rawMethod !== undefined ? e.rawMethod : e.dir ? 0 : (e.method === undefined ? 8 : e.method);
const payload = method === 8 && e.rawMethod === undefined ? zlib.deflateRawSync(raw) : raw;
const crc = zlib.crc32(raw);
const flags = (e.flags || 0) | 0x0800;
const localExtra = zip64 ? zip64Extra([raw.length, payload.length]) : new Uint8Array(0);
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true);
local.setUint16(4, zip64 ? 45 : 20, true);
local.setUint16(6, flags, true);
local.setUint16(8, method, true);
local.setUint32(14, crc, true);
local.setUint32(18, zip64 ? 0xFFFFFFFF : payload.length, true);
local.setUint32(22, zip64 ? 0xFFFFFFFF : raw.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, localExtra.length, true);
parts.push(new Uint8Array(local.buffer), name, localExtra, payload);
const centralExtra = zip64 ? zip64Extra([raw.length, payload.length, offset]) : new Uint8Array(0);
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 45, true);
cd.setUint16(6, zip64 ? 45 : 20, true);
cd.setUint16(8, flags, true);
cd.setUint16(10, method, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, zip64 ? 0xFFFFFFFF : payload.length, true);
cd.setUint32(24, zip64 ? 0xFFFFFFFF : raw.length, true);
cd.setUint16(28, name.length, true);
cd.setUint16(30, centralExtra.length, true);
cd.setUint32(38, e.dir ? 0x10 : 0, true);
cd.setUint32(42, zip64 ? 0xFFFFFFFF : offset, true);
central.push(new Uint8Array(cd.buffer), name, centralExtra);
offset += 30 + name.length + localExtra.length + payload.length;
}
const cdOffset = offset;
const cdSize = central.reduce((n, p) => n + p.length, 0);
parts.push(...central);
offset += cdSize;
if (zip64) {
const rec = new DataView(new ArrayBuffer(56));
rec.setUint32(0, 0x06064b50, true);
setUint64(rec, 4, 44);
rec.setUint16(12, 45, true);
rec.setUint16(14, 45, true);
setUint64(rec, 24, entries.length);
setUint64(rec, 32, entries.length);
setUint64(rec, 40, cdSize);
setUint64(rec, 48, cdOffset);
const locator = new DataView(new ArrayBuffer(20));
locator.setUint32(0, 0x07064b50, true);
setUint64(locator, 8, offset);
locator.setUint32(16, 1, true);
parts.push(new Uint8Array(rec.buffer), new Uint8Array(locator.buffer));
}
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(8, zip64 ? 0xFFFF : entries.length, true);
eocd.setUint16(10, zip64 ? 0xFFFF : entries.length, true);
eocd.setUint32(12, zip64 ? 0xFFFFFFFF : cdSize, true);
eocd.setUint32(16, zip64 ? 0xFFFFFFFF : cdOffset, true);
parts.push(new Uint8Array(eocd.buffer));
return new Blob(parts);
}
function setUint64(view, pos, value) {
view.setUint32(pos, value % 0x100000000, true);
view.setUint32(pos + 4, Math.floor(value / 0x100000000), true);
}
function zip64Extra(values) {
const view = new DataView(new ArrayBuffer(4 + 8 * values.length));
view.setUint16(0, 0x0001, true);
view.setUint16(2, 8 * values.length, true);
values.forEach((v, i) => setUint64(view, 4 + 8 * i, v));
return new Uint8Array(view.buffer);
}
import test from 'node:test';
import assert from 'node:assert/strict';
import { makeZip } from './zip-util.js';
import * as ZipReader from '../public/src/loading/zipreader.js';
import * as CityGML from '../public/src/loading/citygml.js';
async function readAll(stream) {
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks).toString('utf8');
}
const TEXT = 'Grüße aus Stuttgart\n'.repeat(500);
test('listEntries returns names and sizes from the central directory', async () => {
const zip = makeZip([
{ name: 'data', dir: true },
{ name: 'data/city.gml', data: TEXT },
{ name: 'readme.txt', data: 'hello', method: 0 },
]);
const entries = await ZipReader.listEntries(zip);
assert.deepEqual(entries.map(e => [e.name, e.directory, e.uncompressedSize]), [
['data/', true, 0],
['data/city.gml', false, Buffer.byteLength(TEXT)],
['readme.txt', false, 5],
]);
});
test('a stored entry is read back unchanged', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT, method: 0 }]);
const [entry] = await ZipReader.listEntries(zip);
assert.equal(await readAll(await ZipReader.openEntry(zip, entry)), TEXT);
});
test('a deflated entry is decompressed', async () => {
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'a.gml', data: TEXT }]);
const entries = await ZipReader.listEntries(zip);
assert.ok(entries[1].compressedSize < entries[1].uncompressedSize, 'fixture should be compressed');
assert.equal(await readAll(await ZipReader.openEntry(zip, entries[1])), TEXT);
});
test('ZIP64 archives are listed and read', async () => {
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'big.gml', data: TEXT }], { zip64: true });
const entries = await ZipReader.listEntries(zip);
assert.deepEqual(entries.map(e => [e.name, e.uncompressedSize]), [['readme.txt', 1], ['big.gml', Buffer.byteLength(TEXT)]]);
assert.equal(await readAll(await ZipReader.openEntry(zip, entries[1])), TEXT);
});
test('a file that is not a zip archive is rejected', async () => {
await assert.rejects(ZipReader.listEntries(new Blob(['<CityModel/>'])), /not a zip/i);
});
test('progress counts compressed bytes up to 1', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT }]);
const [entry] = await ZipReader.listEntries(zip);
const progress = [];
await readAll(await ZipReader.openEntry(zip, entry, { onProgress: p => progress.push(p) }));
assert.ok(progress.length > 0);
assert.equal(progress[progress.length - 1], 1);
assert.ok(progress.every((p, i) => i === 0 || p >= progress[i - 1]), 'progress must not decrease');
});
test('an encrypted entry is rejected with a clear message', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT, method: 0, flags: 0x1 }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(ZipReader.openEntry(zip, entry), /encrypted/i);
});
test('an unsupported compression method is rejected with its name', async () => {
const zip = makeZip([{ name: 'a.gml', data: 'whatever', rawMethod: 9 }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(ZipReader.openEntry(zip, entry), /Deflate64/);
});
// --- choosing the CityGML entry -------------------------------------------
function entriesNamed(...names) {
return names.map(name => ({ name, directory: name.endsWith('/') }));
}
test('the single .gml file is chosen among other files', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('docs/', 'docs/readme.txt', 'metadata.xml', 'data/LoD2_city.GML'));
assert.equal(entry.name, 'data/LoD2_city.GML');
});
test('macOS resource fork copies are ignored', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('__MACOSX/._city.gml', 'city.gml'));
assert.equal(entry.name, 'city.gml');
});
test('without .gml files a single .xml file is chosen', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('LoD2_282_5602_2_SN.xml', 'readme.txt'));
assert.equal(entry.name, 'LoD2_282_5602_2_SN.xml');
});
test('several .gml files are rejected with their names', () => {
assert.throws(() => ZipReader.findCityGmlEntry(entriesNamed('a.gml', 'b.gml')), /a\.gml.*b\.gml/s);
});
test('an archive without CityGML is rejected', () => {
assert.throws(() => ZipReader.findCityGmlEntry(entriesNamed('readme.txt', 'a.xml', 'b.xml')), /no CityGML/i);
});
// --- end to end ------------------------------------------------------------
test('a CityGML file inside a zip is parsed through readStream', async () => {
const city = `<core:CityModel><gml:Polygon><gml:exterior><gml:LinearRing><gml:posList>
0 0 0 1 0 0 1 1 0 0 1 0 0 0 0</gml:posList></gml:LinearRing></gml:exterior></gml:Polygon></core:CityModel>`;
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'city.gml', data: city }]);
const entry = ZipReader.findCityGmlEntry(await ZipReader.listEntries(zip));
const summary = await CityGML.readStream(await ZipReader.openEntry(zip, entry), CityGML.createParser());
assert.equal(summary.vertexCount, 6);
});
test('readStream lets timers run while reading a stream whose data is always ready', async () => {
// Like a decompression stream: every read resolves at once, without I/O.
const chunk = new TextEncoder().encode(' '.repeat(1024));
let chunks = 0;
const stream = new ReadableStream({
pull(controller) {
if (chunks++ < 400) controller.enqueue(chunk);
else controller.close();
}
});
let timerRanAtChunk = null;
setTimeout(() => { timerRanAtChunk = chunks; }, 0);
await CityGML.readStream(stream, CityGML.createParser(), { flushBytes: 1024 });
assert.ok(timerRanAtChunk !== null && timerRanAtChunk < 400,
`timer only ran after reading finished (at chunk ${timerRanAtChunk})`);
});
test('readStream rejects with AbortError when aborted', async () => {
const controller = new AbortController();
controller.abort();
const zip = makeZip([{ name: 'city.gml', data: TEXT }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(
CityGML.readStream(await ZipReader.openEntry(zip, entry), CityGML.createParser(), { signal: controller.signal }),
{ name: 'AbortError' });
});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment