Integrate file-import contribution onto current main for HAL-526 review

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 00:50:18 +02:00
commit 7694c02b91
15 files changed
+3212 -2

No files matched your search

+852
View File
@@ -0,0 +1,852 @@
/**
* Your own geodata on the globe: GeoJSON, KML, KMZ and GPX files read in the
* browser and handed to Cesium's own data sources.
*
* Nothing here talks to the network. The file never leaves the page, and a
* KML/KMZ file cannot make the page fetch anything either: network links,
* remote icons, remote styles, models and photo/screen overlays are removed
* before Cesium sees the document (`sanitizeKmlDocument`). Images packed
* inside a KMZ are served to Cesium from blob URLs the caller revokes.
*
* What the file says about itself stays inert: descriptions, balloons,
* extended data and GeoJSON properties are dropped from every entity, names
* are kept only as short plain text, and no native Cesium label is drawn
* (`scrubImportedEntities`). GeoJSON and GPX draw in the row's color:
* simplestyle properties are dropped, including `marker-symbol`, so no icon
* asset is loaded on the file's behalf. KML and KMZ keep their own styles.
*
* An import never moves the app's shared clock: dated KML (TimeSpan,
* TimeStamp, gx:Track) is drawn for all time, and the data source exposes no
* clock for the viewer to adopt (`detachImportedTime`). GPX is read here
* rather than by Cesium (`gpxToGeoJson`), keeping each track segment as its
* own line and leaving times out.
*
* Size is checked before Cesium builds anything (`countGeoJsonGeometry`,
* `countKmlGeometry`): a file with too many features or vertices is refused
* up front instead of after the entities exist.
*
* The bounded pieces — format detection, the KMZ (zip) reader, the size and
* count limits — are plain functions so they run under `node --test`; the
* Cesium loaders need a browser DOM and are exercised by
* `scripts/qa-geo-import.mjs`.
*/
import * as Cesium from 'cesium';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
/** Most files on the globe at once. */
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most `<href>` and `<Style>` elements in one KML/KMZ document. */
export const MAX_KML_LINKS = 2_000;
/** Longest plain-text name kept from a file's features. */
export const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
const GEOJSON_TYPES = new Set([
'FeatureCollection',
'Feature',
'Point',
'MultiPoint',
'LineString',
'MultiLineString',
'Polygon',
'MultiPolygon',
'GeometryCollection',
]);
const KMZ_IMAGE_TYPES = Object.freeze({
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
});
// KML elements that fetch, embed or execute something outside the file.
const KML_REMOVED_ELEMENTS = Object.freeze([
'NetworkLink',
'NetworkLinkControl',
'ScreenOverlay',
'PhotoOverlay',
'Model',
'Tour',
]);
/** An error whose message is meant for the person who chose the file. */
export class GeoImportError extends Error {
constructor(message) {
super(message);
this.name = 'GeoImportError';
}
}
/**
* The format of a file, from its name first and its first bytes second.
* @param {string} name File name.
* @param {Uint8Array} [head] The first bytes of the file.
* @returns {'geojson'|'kml'|'kmz'|'gpx'|null}
*/
export function detectGeoFileFormat(name, head) {
const ext = /\.([a-z0-9]+)$/i.exec(String(name || ''))?.[1]?.toLowerCase();
if (ext === 'geojson') return 'geojson';
if (ext === 'kml' || ext === 'kmz' || ext === 'gpx') return ext;
if (!head?.length) return ext === 'json' ? 'geojson' : null;
if (
head[0] === 0x50 &&
head[1] === 0x4b &&
head[2] === 0x03 &&
head[3] === 0x04
)
return 'kmz';
const text = new TextDecoder()
.decode(head.subarray(0, 1024))
.replace(/^/, '')
.trimStart();
if (text.startsWith('{')) return 'geojson';
if (text.startsWith('<')) {
if (/<kml[\s>]/i.test(text)) return 'kml';
if (/<gpx[\s>]/i.test(text)) return 'gpx';
}
return ext === 'json' ? 'geojson' : null;
}
/**
* A short, printable name for the list: the file name without control
* characters, cut to 60 characters.
* @param {string} name
* @returns {string}
*/
export function importDisplayName(name) {
const clean = String(name || '')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
if (!clean) return 'Imported file';
return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean;
}
/**
* Parse and check GeoJSON text. Throws `GeoImportError` for anything that is
* not a GeoJSON object.
* @param {string} text
* @returns {object}
*/
export function parseGeoJsonText(text) {
let value;
try {
value = JSON.parse(String(text).replace(/^/, ''));
} catch {
throw new GeoImportError('That file is not valid JSON.');
}
if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
throw new GeoImportError('That JSON file is not GeoJSON.');
if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
throw new GeoImportError('That FeatureCollection has no features list.');
return value;
}
/**
* Count features and vertices in a parsed GeoJSON object without recursion
* limits or array spreading, stopping as soon as a limit is passed.
* @param {object} geojson
* @param {{maxFeatures?: number, maxPositions?: number}} [limits]
* @returns {{features: number, positions: number, over: ''|'features'|'positions'}}
*/
export function countGeoJsonGeometry(
geojson,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
} = {},
) {
let features = 0;
let positions = 0;
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection') {
const list = Array.isArray(node.features) ? node.features : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
continue;
}
if (node.type === 'Feature') {
features += 1;
if (features > maxFeatures)
return { features, positions, over: 'features' };
stack.push(node.geometry);
continue;
}
if (node.type === 'GeometryCollection') {
const list = Array.isArray(node.geometries) ? node.geometries : [];
for (let i = 0; i < list.length; i++) stack.push(list[i]);
continue;
}
if (GEOJSON_TYPES.has(node.type)) {
// A bare geometry is one feature: Cesium makes one entity for it.
if (node === geojson) features += 1;
// Walk the coordinate arrays iteratively: a position is an array whose
// first item is a number.
const coords = [node.coordinates];
while (coords.length) {
const c = coords.pop();
if (!Array.isArray(c)) continue;
if (typeof c[0] === 'number') {
positions += 1;
if (positions > maxPositions)
return { features, positions, over: 'positions' };
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
}
}
}
return { features, positions, over: '' };
}
/** The refusal for a file over a size limit, in the person's terms. */
function overLimitError({ over, features, positions }) {
if (over === 'links')
return new GeoImportError(
`That file has more than ${MAX_KML_LINKS.toLocaleString()} styles and icon links (the limit).`,
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
);
return new GeoImportError(
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
);
}
// simplestyle-spec keys Cesium honors. `marker-symbol` would load a maki icon
// asset on the file's behalf; the colors would override the row's color.
const GEOJSON_STYLE_KEYS = Object.freeze([
'marker-symbol',
'marker-color',
'marker-size',
'stroke',
'stroke-opacity',
'stroke-width',
'fill',
'fill-opacity',
]);
/**
* Drop GeoJSON simplestyle properties, so a GeoJSON file draws in its row's
* color like a GPX file and no icon is fetched for it. Mutates and returns
* the object.
* @param {object} geojson
* @returns {object}
*/
export function stripGeoJsonStyleProperties(geojson) {
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (node.type === 'FeatureCollection' && Array.isArray(node.features))
for (let i = 0; i < node.features.length; i++)
stack.push(node.features[i]);
else if (
node.type === 'Feature' &&
node.properties &&
typeof node.properties === 'object'
)
for (const key of GEOJSON_STYLE_KEYS) delete node.properties[key];
}
return geojson;
}
/** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */
async function inflateRaw(bytes, maxBytes) {
const stream = new Blob([bytes])
.stream()
.pipeThrough(new DecompressionStream('deflate-raw'));
const reader = stream.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > maxBytes)
throw new GeoImportError(
'That KMZ unpacks to more than the import limit.',
);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => {});
if (error instanceof GeoImportError) throw error;
throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
}
const out = new Uint8Array(total);
let at = 0;
for (const chunk of chunks) {
out.set(chunk, at);
at += chunk.length;
}
return out;
}
/**
* Read the entries of a zip archive (stored or deflated; no encryption, no
* ZIP64). Directory entries are skipped.
* @param {Uint8Array} bytes The whole archive.
* @param {{maxInflatedBytes?: number}} [options]
* @returns {Promise<Map<string, Uint8Array>>} Entry path → contents.
*/
export async function readZipEntries(
bytes,
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fail = (message = 'That KMZ is not a readable zip archive.') => {
throw new GeoImportError(message);
};
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
let eocd = -1;
for (
let i = bytes.length - 22;
i >= Math.max(0, bytes.length - 22 - 0xffff);
i--
) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) fail();
const count = view.getUint16(eocd + 10, true);
const dirSize = view.getUint32(eocd + 12, true);
let at = view.getUint32(eocd + 16, true);
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
fail('That KMZ uses a zip format this import does not read.');
const entries = new Map();
const names = new TextDecoder();
let inflated = 0;
for (let n = 0; n < count; n++) {
if (at + 46 > bytes.length || view.getUint32(at, true) !== 0x02014b50)
fail();
const flags = view.getUint16(at + 8, true);
const method = view.getUint16(at + 10, true);
const compressed = view.getUint32(at + 20, true);
const size = view.getUint32(at + 24, true);
const nameLength = view.getUint16(at + 28, true);
const extraLength = view.getUint16(at + 30, true);
const commentLength = view.getUint16(at + 32, true);
const local = view.getUint32(at + 42, true);
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
at += 46 + nameLength + extraLength + commentLength;
if (name.endsWith('/')) continue;
if (flags & 0x1) fail('That KMZ is encrypted.');
if (compressed === 0xffffffff || size === 0xffffffff)
fail('That KMZ uses a zip format this import does not read.');
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
fail();
const start =
local +
30 +
view.getUint16(local + 26, true) +
view.getUint16(local + 28, true);
if (start + compressed > bytes.length) fail();
const data = bytes.subarray(start, start + compressed);
let contents;
if (method === 0) contents = data;
else if (method === 8)
contents = await inflateRaw(data, maxInflatedBytes - inflated);
else fail('That KMZ uses a compression method this import does not read.');
inflated += contents.length;
if (inflated > maxInflatedBytes)
fail('That KMZ unpacks to more than the import limit.');
entries.set(name, contents);
}
return entries;
}
/**
* The KML document inside a KMZ: `doc.kml` when present, else the first
* `.kml` entry nearest the archive root.
* @param {Map<string, Uint8Array>} entries
* @returns {string|null} Entry path.
*/
export function kmzMainDocument(entries) {
if (entries.has('doc.kml')) return 'doc.kml';
const kml = [...entries.keys()]
.filter((name) => /\.kml$/i.test(name))
.sort(
(a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b),
);
return kml[0] ?? null;
}
/**
* Resolve a KMZ-relative href to an entry path, or null.
* @param {string} href As written in the KML.
* @param {string} base The KML entry's path.
* @param {Map<string, Uint8Array>} entries
* @returns {string|null}
*/
export function resolveKmzPath(href, base, entries) {
const raw = String(href || '').trim();
if (!raw || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//'))
return null;
let decoded;
try {
decoded = decodeURIComponent(raw.split(/[?#]/)[0]);
} catch {
return null;
}
const parts = base.split('/').slice(0, -1);
for (const part of decoded.replace(/\\/g, '/').split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
const path = parts.join('/');
return entries.has(path) ? path : null;
}
/** MIME type for a KMZ image entry, or null when it is not an image we serve. */
export function kmzImageType(path) {
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
return KMZ_IMAGE_TYPES[ext] ?? null;
}
const elementsNamed = (doc, localName) =>
[...doc.getElementsByTagName('*')].filter((el) => el.localName === localName);
/**
* A `resolveLocal` for `sanitizeKmlDocument`: the blob URL of an image packed
* in the KMZ, or null. Each entry gets one blob and one URL however many
* hrefs name it, so repeated icons don't copy (and decode) the image again.
* @param {Map<string, Uint8Array>} entries
* @param {string} main Path of the KMZ's KML document.
* @param {(url: string) => void} onUrl Called once per new URL (to revoke later).
* @param {(blob: Blob) => string} [createUrl]
* @returns {(href: string) => string|null}
*/
export function kmzImageResolver(
entries,
main,
onUrl,
createUrl = (blob) => URL.createObjectURL(blob),
) {
const urls = new Map();
return (href) => {
const path = resolveKmzPath(href, main, entries);
const type = path && kmzImageType(path);
if (!type) return null;
let url = urls.get(path);
if (!url) {
url = createUrl(new Blob([entries.get(path)], { type }));
urls.set(path, url);
onUrl(url);
}
return url;
};
}
/**
* Strip everything from a parsed KML document that would make the page reach
* outside the file. `resolveLocal(href)` returns a URL for an image packed in
* the same KMZ (or null); every other href is removed, so icons fall back to
* Cesium's default pin and a ground overlay without a local image is dropped.
* @param {Document} doc
* @param {(href: string) => string|null} [resolveLocal]
* @returns {{removed: number}} How many elements were taken out.
*/
export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
let removed = 0;
const drop = (el) => {
if (el.parentNode) {
el.parentNode.removeChild(el);
removed += 1;
}
};
for (const name of KML_REMOVED_ELEMENTS)
elementsNamed(doc, name).forEach(drop);
for (const href of elementsNamed(doc, 'href')) {
const value = (href.textContent || '').trim();
const local = resolveLocal(value);
if (local) href.textContent = local;
else if (/^data:image\/(png|jpeg|gif|webp|bmp);/i.test(value)) continue;
else drop(href);
}
for (const overlay of elementsNamed(doc, 'GroundOverlay')) {
if (!elementsNamed(overlay, 'href').length) drop(overlay);
}
for (const styleUrl of elementsNamed(doc, 'styleUrl')) {
if (!(styleUrl.textContent || '').trim().startsWith('#')) drop(styleUrl);
}
// Placemark balloons are HTML; the app shows no info box, and keeping their
// <img>/<iframe> sources out means no future surface can load them either.
for (const name of ['description', 'BalloonStyle', 'Snippet'])
elementsNamed(doc, name).forEach(drop);
return { removed };
}
/** Child elements of `el` with this local name (any namespace). */
const childrenNamed = (el, localName) => {
const out = [];
const kids = el?.childNodes || [];
for (let i = 0; i < kids.length; i++)
if (kids[i].nodeType === 1 && kids[i].localName === localName)
out.push(kids[i]);
return out;
};
const childText = (el, localName) =>
(childrenNamed(el, localName)[0]?.textContent || '').trim();
/**
* Count placemarks and vertices in a parsed KML document before Cesium reads
* it: every `<coordinates>` tuple and every `gx:coord`, plus the `<href>`
* and `<Style>` elements (`links`), each of which can make Cesium load and
* decode an image.
* @param {Document} doc
* @returns {{features: number, positions: number, links: number,
* over: ''|'features'|'positions'|'links'}}
*/
export function countKmlGeometry(
doc,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
maxLinks = MAX_KML_LINKS,
} = {},
) {
let features = 0;
let positions = 0;
let links = 0;
const result = (over) => ({ features, positions, links, over });
for (const el of doc.getElementsByTagName('*')) {
const name = el.localName;
if (name === 'href' || name === 'Style') {
links += 1;
if (links > maxLinks) return result('links');
} else if (name === 'Placemark') {
features += 1;
if (features > maxFeatures) return result('features');
} else if (name === 'coordinates') {
const text = el.textContent || '';
// Tuples are separated by whitespace; count runs without splitting
// a large string into a large array.
let inTuple = false;
for (let i = 0; i < text.length; i++) {
const space = text.charCodeAt(i) <= 32;
if (!space && !inTuple) {
positions += 1;
if (positions > maxPositions) return result('positions');
}
inTuple = !space;
}
} else if (name === 'coord') {
positions += 1;
if (positions > maxPositions) return result('positions');
}
}
return result('');
}
/**
* Read a parsed GPX document into GeoJSON: waypoints become points, each
* route a line and each track one MultiLineString with a line per `<trkseg>`
* (segment breaks are gaps in the recording and stay gaps). Only names are
* kept, and no times, so a dated track draws without a clock. Throws
* `GeoImportError` past the size limits.
* @param {Document} doc
* @returns {object} A FeatureCollection.
*/
export function gpxToGeoJson(
doc,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
} = {},
) {
const root = doc.documentElement;
if (root?.localName !== 'gpx')
throw new GeoImportError('That GPX file has no <gpx> document.');
const features = [];
let positions = 0;
const point = (el) => {
const lat = Number(el.getAttribute('lat'));
const lon = Number(el.getAttribute('lon'));
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
return null;
positions += 1;
if (positions > maxPositions)
throw overLimitError({
over: 'positions',
features: features.length,
positions,
});
return [lon, lat];
};
const add = (name, geometry) => {
if (features.length >= maxFeatures)
throw overLimitError({
over: 'features',
features: features.length + 1,
positions,
});
features.push({
type: 'Feature',
properties: name ? { name } : {},
geometry,
});
};
for (const wpt of childrenNamed(root, 'wpt')) {
const at = point(wpt);
if (at) add(childText(wpt, 'name'), { type: 'Point', coordinates: at });
}
for (const rte of childrenNamed(root, 'rte')) {
const line = [];
for (const pt of childrenNamed(rte, 'rtept')) {
const at = point(pt);
if (at) line.push(at);
}
if (line.length >= 2)
add(childText(rte, 'name'), { type: 'LineString', coordinates: line });
}
for (const trk of childrenNamed(root, 'trk')) {
const lines = [];
for (const seg of childrenNamed(trk, 'trkseg')) {
const line = [];
for (const pt of childrenNamed(seg, 'trkpt')) {
const at = point(pt);
if (at) line.push(at);
}
if (line.length >= 2) lines.push(line);
}
if (lines.length)
add(childText(trk, 'name'), {
type: 'MultiLineString',
coordinates: lines,
});
}
return { type: 'FeatureCollection', features };
}
/** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) {
const text = String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > MAX_IMPORT_NAME_LENGTH
? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…`
: text;
}
/**
* Make every entity of an imported data source inert: no description,
* balloon, extended data or properties, no native label, and a name that is
* short plain text. Returns how many entities were touched.
* @param {{entities: {values: object[]}}} dataSource
* @returns {number}
*/
export function scrubImportedEntities(dataSource) {
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
let name;
try {
name = importFeatureName(
typeof entity.name === 'string' ? entity.name : undefined,
);
} catch {
name = undefined;
}
entity.name = name;
entity.description = undefined;
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
}
return list.length;
}
const TRACK_SAMPLES = 2000;
/**
* Take time out of an imported data source so adding it cannot move the app's
* shared clock: the data source reports no clock (the viewer adopts the clock
* of every added source that has one), every entity is available for all
* time, and a time-dynamic position (a KML gx:Track) is drawn as the line it
* travels instead of a moving marker.
* @param {object} dataSource
* @param {{Cesium?: object, color?: object}} [options]
* @returns {object} The same data source.
*/
export function detachImportedTime(
dataSource,
{ Cesium: C = Cesium, color } = {},
) {
Object.defineProperty(dataSource, 'clock', {
configurable: true,
enumerable: false,
get: () => undefined,
set: () => {},
});
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
const span = entity.availability;
const position = entity.position;
if (position && position.isConstant === false) {
const start = span?.start;
const stop = span?.stop;
const line = [];
if (start && stop && C.JulianDate.lessThan(start, stop)) {
const seconds = C.JulianDate.secondsDifference(stop, start);
for (let n = 0; n <= TRACK_SAMPLES; n++) {
const t = C.JulianDate.addSeconds(
start,
(seconds * n) / TRACK_SAMPLES,
new C.JulianDate(),
);
const at = position.getValue(t);
if (at) line.push(at);
}
}
entity.path = undefined;
entity.model = undefined;
if (line.length >= 2 && !entity.polyline) {
entity.billboard = undefined;
entity.point = undefined;
entity.position = undefined;
entity.polyline = new C.PolylineGraphics({
positions: line,
clampToGround: true,
width: 3,
material: color || C.Color.WHITE,
});
} else {
entity.position = line.length
? new C.ConstantPositionProperty(line[0])
: undefined;
}
}
entity.availability = undefined;
}
return dataSource;
}
function parseXml(text, what) {
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
'application/xml',
);
if (doc.getElementsByTagName('parsererror').length)
throw new GeoImportError(`That ${what} file is not valid XML.`);
return doc;
}
/**
* Load one file into a Cesium data source, clamped to the ground.
* @param {File|Blob & {name?: string}} file
* @param {{viewer: Cesium.Viewer, color?: string}} options `color` is a CSS color.
* @returns {Promise<{dataSource: Cesium.DataSource, name: string,
* format: string, entityCount: number, removed: number, revoke: Function}>}
*/
export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
if (!file || !Number.isFinite(file.size))
throw new GeoImportError('Nothing to import.');
if (file.size > MAX_IMPORT_BYTES)
throw new GeoImportError(
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MB.`,
);
const bytes = new Uint8Array(await file.arrayBuffer());
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
if (!format)
throw new GeoImportError(
'Only GeoJSON, KML, KMZ and GPX files can be imported.',
);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
const revoke = () =>
urls.splice(0).forEach((url) => URL.revokeObjectURL(url));
const name = importDisplayName(file.name);
let dataSource;
let removed = 0;
try {
if (format === 'geojson' || format === 'gpx') {
let geojson;
if (format === 'gpx') {
geojson = gpxToGeoJson(
parseXml(new TextDecoder().decode(bytes), 'GPX'),
);
} else {
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
stripGeoJsonStyleProperties(geojson);
}
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
// No HTML description table is built from the file's properties.
describe: () => undefined,
});
} else {
let text;
let resolveLocal = () => null;
if (format === 'kmz') {
const entries = await readZipEntries(bytes);
const main = kmzMainDocument(entries);
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
text = new TextDecoder().decode(entries.get(main));
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
} else {
text = new TextDecoder().decode(bytes);
}
const doc = parseXml(text, format.toUpperCase());
const size = countKmlGeometry(doc);
if (size.over) throw overLimitError(size);
removed = sanitizeKmlDocument(doc, resolveLocal).removed;
dataSource = await Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
}
} catch (error) {
revoke();
if (error instanceof GeoImportError) throw error;
throw new GeoImportError(
`That ${format.toUpperCase()} file could not be read${error?.message ? `: ${error.message}` : '.'}`,
);
}
const entityCount = dataSource.entities.values.length;
if (entityCount > MAX_IMPORT_ENTITIES) {
revoke();
throw new GeoImportError(
`That file has ${entityCount.toLocaleString()} features; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
);
}
if (entityCount === 0) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
}
scrubImportedEntities(dataSource);
// Only KML has time-dynamic positions (GPX is read without times); its
// track line is drawn in KML's default white, like its other unstyled lines.
detachImportedTime(dataSource);
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
return { dataSource, name, format, entityCount, removed, revoke };
}
+542
View File
@@ -0,0 +1,542 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import * as Cesium from 'cesium';
import {
GeoImportError,
MAX_IMPORT_POSITIONS,
countGeoJsonGeometry,
countKmlGeometry,
detachImportedTime,
gpxToGeoJson,
importFeatureName,
scrubImportedEntities,
stripGeoJsonStyleProperties,
detectGeoFileFormat,
importDisplayName,
kmzImageResolver,
kmzImageType,
kmzMainDocument,
parseGeoJsonText,
readZipEntries,
resolveKmzPath,
} from './geoFileImport.js';
const bytes = (text) => new TextEncoder().encode(text);
/** A minimal zip writer: stored (0) or deflated (8) entries, optional flags. */
function zip(files) {
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data, method = 8, flags = 0, size } of files) {
const nameBytes = bytes(name);
const body = method === 8 ? new Uint8Array(deflateRawSync(data)) : data;
const local = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(6, flags, true);
lv.setUint16(8, method, true);
lv.setUint32(18, body.length, true);
lv.setUint32(22, size ?? data.length, true);
lv.setUint16(26, nameBytes.length, true);
local.set(nameBytes, 30);
const central = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(8, flags, true);
cv.setUint16(10, method, true);
cv.setUint32(20, body.length, true);
cv.setUint32(24, size ?? data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true);
central.set(nameBytes, 46);
locals.push(local, body);
centrals.push(central);
offset += local.length + body.length;
}
const dirSize = centrals.reduce((n, c) => n + c.length, 0);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, files.length, true);
ev.setUint16(10, files.length, true);
ev.setUint32(12, dirSize, true);
ev.setUint32(16, offset, true);
const parts = [...locals, ...centrals, eocd];
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
return out;
}
test('format comes from the extension, then from the first bytes', () => {
assert.equal(detectGeoFileFormat('trail.GPX'), 'gpx');
assert.equal(detectGeoFileFormat('farm.kml'), 'kml');
assert.equal(detectGeoFileFormat('farm.kmz'), 'kmz');
assert.equal(detectGeoFileFormat('parcels.geojson'), 'geojson');
assert.equal(detectGeoFileFormat('parcels.json'), 'geojson');
assert.equal(
detectGeoFileFormat('export', bytes(' {"type":"Feature"}')),
'geojson',
);
assert.equal(
detectGeoFileFormat(
'export',
bytes('<?xml version="1.0"?><kml xmlns="x">'),
),
'kml',
);
assert.equal(
detectGeoFileFormat(
'export',
bytes('<?xml version="1.0"?>\n<gpx version="1.1">'),
),
'gpx',
);
assert.equal(
detectGeoFileFormat('export', new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0])),
'kmz',
);
assert.equal(detectGeoFileFormat('notes.txt', bytes('hello')), null);
assert.equal(
detectGeoFileFormat('image.png', new Uint8Array([0x89, 0x50, 0x4e, 0x47])),
null,
);
});
test('display names are printable and bounded', () => {
assert.equal(importDisplayName(' hike\u0000.gpx '), 'hike.gpx');
assert.equal(importDisplayName(''), 'Imported file');
const long = importDisplayName(`${'x'.repeat(80)}.kml`);
assert.equal(long.length, 58);
assert.ok(long.endsWith('…'));
});
test('GeoJSON text must be a GeoJSON object', () => {
assert.equal(
parseGeoJsonText('{"type":"FeatureCollection","features":[]}').type,
'FeatureCollection',
);
assert.equal(
parseGeoJsonText('{"type":"Point","coordinates":[0,0]}').type,
'Point',
);
assert.throws(() => parseGeoJsonText('{nope'), GeoImportError);
assert.throws(() => parseGeoJsonText('{"name":"not geo"}'), /not GeoJSON/);
assert.throws(() => parseGeoJsonText('[1,2]'), /not GeoJSON/);
assert.throws(
() => parseGeoJsonText('{"type":"FeatureCollection"}'),
/no features list/,
);
});
test('a KMZ is read with stored and deflated entries, directories skipped', async () => {
const kml = bytes('<kml><Document/></kml>');
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
const archive = zip([
{ name: 'files/', data: new Uint8Array(0), method: 0 },
{ name: 'doc.kml', data: kml },
{ name: 'files/icon.png', data: png, method: 0 },
]);
const entries = await readZipEntries(archive);
assert.deepEqual([...entries.keys()], ['doc.kml', 'files/icon.png']);
assert.deepEqual(entries.get('doc.kml'), kml);
assert.deepEqual(entries.get('files/icon.png'), png);
});
test('a KMZ that inflates past the limit is refused', async () => {
const archive = zip([{ name: 'doc.kml', data: new Uint8Array(200_000) }]);
await assert.rejects(
readZipEntries(archive, { maxInflatedBytes: 100_000 }),
/more than the import limit/,
);
});
test('encrypted, damaged and non-zip archives are refused with a reason', async () => {
await assert.rejects(
readZipEntries(
zip([{ name: 'doc.kml', data: bytes('<kml/>'), flags: 1, method: 0 }]),
),
/encrypted/,
);
await assert.rejects(
readZipEntries(bytes('not a zip at all, just text')),
/not a readable zip/,
);
const truncated = zip([
{ name: 'doc.kml', data: bytes('<kml/>'), method: 0 },
]);
const view = new DataView(truncated.buffer);
view.setUint32(truncated.length - 6, 9999, true); // directory offset past the end
await assert.rejects(readZipEntries(truncated), GeoImportError);
await assert.rejects(
readZipEntries(
zip([{ name: 'doc.kml', data: bytes('<kml/>'), method: 12 }]),
),
/compression method/,
);
});
test('the main KML is doc.kml, else the shallowest .kml', () => {
const e = (...names) => new Map(names.map((n) => [n, new Uint8Array(0)]));
assert.equal(kmzMainDocument(e('a/b.kml', 'doc.kml')), 'doc.kml');
assert.equal(
kmzMainDocument(e('deep/x/z.kml', 'top.kml', 'images/a.png')),
'top.kml',
);
assert.equal(kmzMainDocument(e('images/a.png')), null);
});
test('KMZ hrefs resolve only to entries inside the archive', () => {
const entries = new Map([
['doc.kml', new Uint8Array(0)],
['files/icon one.png', new Uint8Array(0)],
['overlay.jpg', new Uint8Array(0)],
]);
assert.equal(
resolveKmzPath('files/icon%20one.png', 'doc.kml', entries),
'files/icon one.png',
);
assert.equal(
resolveKmzPath('./overlay.jpg', 'doc.kml', entries),
'overlay.jpg',
);
assert.equal(
resolveKmzPath('../overlay.jpg', 'files/sub.kml', entries),
'overlay.jpg',
);
assert.equal(
resolveKmzPath('https://example.com/overlay.jpg', 'doc.kml', entries),
null,
);
assert.equal(
resolveKmzPath('//example.com/overlay.jpg', 'doc.kml', entries),
null,
);
assert.equal(resolveKmzPath('file:///etc/passwd', 'doc.kml', entries), null);
assert.equal(resolveKmzPath('missing.png', 'doc.kml', entries), null);
assert.equal(resolveKmzPath('%E0%A4%A', 'doc.kml', entries), null);
assert.equal(kmzImageType('files/a.PNG'), 'image/png');
assert.equal(kmzImageType('files/a.svg'), null);
});
/* ── a tiny element tree with the DOM surface the readers use ─────────── */
function xmlEl(localName, attrs = {}, kids = []) {
const el = {
nodeType: 1,
localName,
childNodes: [],
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
};
for (const k of typeof kids === 'string' ? [] : kids) el.childNodes.push(k);
const text = typeof kids === 'string' ? kids : null;
Object.defineProperty(el, 'textContent', {
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
});
return el;
}
const xmlDoc = (root) => ({
documentElement: root,
getElementsByTagName(name) {
assert.equal(name, '*');
const out = [];
const walk = (el) => {
out.push(el);
el.childNodes.forEach(walk);
};
walk(root);
return out;
},
});
const trkpt = (lat, lon, time) =>
xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []);
test('GPX keeps each track segment as its own line, names as text, no times', () => {
const doc = xmlDoc(
xmlEl('gpx', {}, [
xmlEl('wpt', { lat: 37.77, lon: -122.49 }, [xmlEl('name', {}, 'Camp')]),
xmlEl('wpt', { lat: 99, lon: 0 }), // out of range: skipped
xmlEl('rte', {}, [
xmlEl('name', {}, 'Route'),
xmlEl('rtept', { lat: 1, lon: 2 }),
xmlEl('rtept', { lat: 3, lon: 4 }),
]),
xmlEl('trk', {}, [
xmlEl('name', {}, 'Morning walk'),
xmlEl('trkseg', {}, [
trkpt(10, 20, '2024-05-01T10:00:00Z'),
trkpt(10.1, 20.1, '2024-05-01T10:01:00Z'),
]),
// A pause in the recording: a new segment, not a straight jump.
xmlEl('trkseg', {}, [
trkpt(11, 21, '2024-05-01T11:00:00Z'),
trkpt(11.1, 21.1, '2024-05-01T11:01:00Z'),
trkpt(11.2, 21.2, '2024-05-01T11:02:00Z'),
]),
xmlEl('trkseg', {}, [trkpt(12, 22)]), // one point: not a line
]),
]),
);
const fc = gpxToGeoJson(doc);
assert.deepEqual(
fc.features.map((f) => [f.properties.name, f.geometry.type]),
[
['Camp', 'Point'],
['Route', 'LineString'],
['Morning walk', 'MultiLineString'],
],
);
assert.deepEqual(fc.features[2].geometry.coordinates, [
[
[20, 10],
[20.1, 10.1],
],
[
[21, 11],
[21.1, 11.1],
[21.2, 11.2],
],
]);
assert.doesNotMatch(JSON.stringify(fc), /2024|time/);
assert.throws(() => gpxToGeoJson(xmlDoc(xmlEl('kml'))), GeoImportError);
});
test('GPX past the point limit is refused while reading, before Cesium', () => {
const seg = xmlEl(
'trkseg',
{},
Array.from({ length: 30 }, (_, i) => trkpt(1 + i / 100, 2)),
);
const doc = xmlDoc(xmlEl('gpx', {}, [xmlEl('trk', {}, [seg])]));
assert.throws(
() => gpxToGeoJson(doc, { maxPositions: 20 }),
(e) => e instanceof GeoImportError && /more than .* points/.test(e.message),
);
assert.throws(
() =>
gpxToGeoJson(
xmlDoc(
xmlEl('gpx', {}, [
xmlEl('wpt', { lat: 1, lon: 1 }),
xmlEl('wpt', { lat: 2, lon: 2 }),
]),
),
{ maxFeatures: 1 },
),
/more than .* features/,
);
});
test('GeoJSON features and vertices are counted before loading, without recursion', () => {
const line = Array.from({ length: 50 }, (_, i) => [i / 10, 1]);
const fc = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'LineString', coordinates: line } },
{
type: 'Feature',
geometry: {
type: 'GeometryCollection',
geometries: [
{ type: 'Point', coordinates: [1, 2] },
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
],
},
},
{ type: 'Feature', geometry: null },
],
};
assert.deepEqual(countGeoJsonGeometry(fc), {
features: 3,
positions: 55,
over: '',
});
assert.equal(
countGeoJsonGeometry(fc, { maxPositions: 54 }).over,
'positions',
);
assert.equal(countGeoJsonGeometry(fc, { maxFeatures: 2 }).over, 'features');
assert.deepEqual(
countGeoJsonGeometry({ type: 'Point', coordinates: [1, 2] }),
{
features: 1,
positions: 1,
over: '',
},
);
// A very long line is counted iteratively and stops at the limit.
const huge = {
type: 'LineString',
coordinates: Array.from({ length: MAX_IMPORT_POSITIONS + 5 }, () => [0, 0]),
};
assert.equal(countGeoJsonGeometry(huge).over, 'positions');
});
test('KML placemarks and coordinate tuples are counted before loading', () => {
const coords = (n) =>
xmlEl(
'coordinates',
{},
Array.from({ length: n }, (_, i) => `${i},1,0`).join('\n '),
);
const doc = xmlDoc(
xmlEl('kml', {}, [
xmlEl('Placemark', {}, [xmlEl('LineString', {}, [coords(40)])]),
xmlEl('Placemark', {}, [
xmlEl('Track', {}, [
xmlEl('coord', {}, '1 2 0'),
xmlEl('coord', {}, '1 3 0'),
]),
]),
]),
);
assert.deepEqual(countKmlGeometry(doc), {
features: 2,
positions: 42,
links: 0,
over: '',
});
assert.equal(countKmlGeometry(doc, { maxPositions: 41 }).over, 'positions');
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
});
test('KML styles and icon links are counted against their own limit', () => {
const style = () =>
xmlEl('Style', {}, [
xmlEl('IconStyle', {}, [xmlEl('Icon', {}, [xmlEl('href', {}, 'a.png')])]),
]);
const doc = xmlDoc(xmlEl('kml', {}, [style(), style(), style()]));
assert.equal(countKmlGeometry(doc).links, 6);
assert.equal(countKmlGeometry(doc, { maxLinks: 5 }).over, 'links');
});
test('a KMZ image gets one blob URL however many hrefs name it', () => {
const entries = new Map([
['doc.kml', new Uint8Array(0)],
['a.png', new Uint8Array(8)],
['b.png', new Uint8Array(8)],
]);
const made = [];
const kept = [];
const resolve = kmzImageResolver(
entries,
'doc.kml',
(url) => kept.push(url),
(blob) => {
made.push(blob);
return `blob:test/${made.length}`;
},
);
for (let i = 0; i < 1000; i++) assert.equal(resolve('a.png'), 'blob:test/1');
assert.equal(resolve('./b.png'), 'blob:test/2');
assert.equal(resolve('a.png'), 'blob:test/1');
assert.equal(resolve('https://example.com/a.png'), null);
assert.equal(resolve('doc.kml'), null);
assert.equal(made.length, 2);
assert.deepEqual(kept, ['blob:test/1', 'blob:test/2']);
});
test('simplestyle is dropped: no icon fetched, the row color is what draws', () => {
const fc = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
'marker-symbol': 'rail',
'marker-color': '#f00',
stroke: '#0f0',
'stroke-width': 9,
fill: '#00f',
'fill-opacity': 1,
name: 'x',
},
geometry: null,
},
{ type: 'Feature', properties: null, geometry: null },
],
};
stripGeoJsonStyleProperties(fc);
assert.deepEqual(fc.features[0].properties, { name: 'x' });
});
test('imported entities keep only a short plain-text name: no description, label or properties', () => {
const ds = new Cesium.CustomDataSource('t');
const e = ds.entities.add({
name: 'Trail\u0007 <b>head</b>\n' + 'x'.repeat(300),
description: '<img src="https://example.invalid/x.png" onerror="alert(1)">',
label: { text: 'Trail head' },
properties: { note: '<script>alert(1)</script>' },
position: Cesium.Cartesian3.fromDegrees(1, 2),
});
e.kml = { extendedData: { note: { value: '<iframe src=//x>' } } };
ds.entities.add({ name: 42, position: Cesium.Cartesian3.fromDegrees(1, 2) });
assert.equal(scrubImportedEntities(ds), 2);
assert.equal(e.description, undefined);
assert.equal(e.label, undefined);
assert.equal(e.properties, undefined);
assert.equal(e.kml, undefined);
assert.ok(e.name.startsWith('Trail <b>head</b> x'));
assert.ok(e.name.length <= 120 && !/[\u0000-\u001f]/.test(e.name));
assert.equal(ds.entities.values[1].name, undefined);
assert.equal(importFeatureName(' '), undefined);
});
test('a dated import exposes no clock and draws for all time', () => {
// A data source with its own clock, as KmlDataSource has for a dated file.
class DatedSource extends Cesium.CustomDataSource {
get clock() {
return this._testClock;
}
}
const ds = new DatedSource('dated');
ds._testClock = new Cesium.DataSourceClock();
const start = Cesium.JulianDate.fromIso8601('2020-01-01T00:00:00Z');
const stop = Cesium.JulianDate.fromIso8601('2020-01-01T01:00:00Z');
const span = new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop }),
]);
const track = new Cesium.SampledPositionProperty();
track.addSample(start, Cesium.Cartesian3.fromDegrees(10, 10));
track.addSample(stop, Cesium.Cartesian3.fromDegrees(10.1, 10.1));
const mover = ds.entities.add({
availability: span,
position: track,
path: {},
billboard: {},
});
const pin = ds.entities.add({
availability: span,
position: Cesium.Cartesian3.fromDegrees(1, 1),
});
assert.ok(ds.clock);
detachImportedTime(ds);
assert.equal(ds.clock, undefined);
// The viewer reads `clock` from every added source; setting it is ignored.
ds.clock = new Cesium.DataSourceClock();
assert.equal(ds.clock, undefined);
assert.equal(pin.availability, undefined);
assert.ok(pin.isAvailable(Cesium.JulianDate.now()));
// The moving marker became the line it travels, visible now.
assert.equal(mover.availability, undefined);
assert.equal(mover.position, undefined);
assert.equal(mover.path, undefined);
assert.equal(mover.billboard, undefined);
const line = mover.polyline.positions.getValue(Cesium.JulianDate.now());
assert.ok(line.length > 100);
assert.ok(mover.polyline.clampToGround.getValue());
});