mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(import): one blob URL per packed KMZ image, cap KML styles and links
A KMZ could name one large packed image in thousands of <href>s, and each one made a fresh Blob copy for Cesium to decode. kmzImageResolver now keeps one blob URL per archive entry, and countKmlGeometry refuses a document with more than 2,000 <href> and <Style> elements (MAX_KML_LINKS) before Cesium reads it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d8b4b0d80f
commit
90b783c532
3 files changed
+94
-22
No files matched your search
+54
-20
@@ -42,6 +42,8 @@ export const MAX_IMPORTED_FILES = 12;
|
||||
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. */
|
||||
@@ -207,6 +209,10 @@ export function countGeoJsonGeometry(
|
||||
|
||||
/** 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).`,
|
||||
@@ -414,6 +420,37 @@ export function kmzImageType(path) {
|
||||
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
|
||||
@@ -467,25 +504,33 @@ const childText = (el, localName) =>
|
||||
|
||||
/**
|
||||
* Count placemarks and vertices in a parsed KML document before Cesium reads
|
||||
* it: every `<coordinates>` tuple and every `gx:coord`.
|
||||
* 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, over: ''|'features'|'positions'}}
|
||||
* @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 === 'Placemark') {
|
||||
if (name === 'href' || name === 'Style') {
|
||||
links += 1;
|
||||
if (links > maxLinks) return result('links');
|
||||
} else if (name === 'Placemark') {
|
||||
features += 1;
|
||||
if (features > maxFeatures)
|
||||
return { features, positions, over: 'features' };
|
||||
if (features > maxFeatures) return result('features');
|
||||
} else if (name === 'coordinates') {
|
||||
const text = el.textContent || '';
|
||||
// Tuples are separated by whitespace; count runs without splitting
|
||||
@@ -495,18 +540,16 @@ export function countKmlGeometry(
|
||||
const space = text.charCodeAt(i) <= 32;
|
||||
if (!space && !inTuple) {
|
||||
positions += 1;
|
||||
if (positions > maxPositions)
|
||||
return { features, positions, over: 'positions' };
|
||||
if (positions > maxPositions) return result('positions');
|
||||
}
|
||||
inTuple = !space;
|
||||
}
|
||||
} else if (name === 'coord') {
|
||||
positions += 1;
|
||||
if (positions > maxPositions)
|
||||
return { features, positions, over: 'positions' };
|
||||
if (positions > maxPositions) return result('positions');
|
||||
}
|
||||
}
|
||||
return { features, positions, over: '' };
|
||||
return result('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,16 +810,7 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
|
||||
const main = kmzMainDocument(entries);
|
||||
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
|
||||
text = new TextDecoder().decode(entries.get(main));
|
||||
resolveLocal = (href) => {
|
||||
const path = resolveKmzPath(href, main, entries);
|
||||
const type = path && kmzImageType(path);
|
||||
if (!type) return null;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([entries.get(path)], { type }),
|
||||
);
|
||||
urls.push(url);
|
||||
return url;
|
||||
};
|
||||
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
|
||||
} else {
|
||||
text = new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
stripGeoJsonStyleProperties,
|
||||
detectGeoFileFormat,
|
||||
importDisplayName,
|
||||
kmzImageResolver,
|
||||
kmzImageType,
|
||||
kmzMainDocument,
|
||||
parseGeoJsonText,
|
||||
@@ -406,12 +407,49 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
|
||||
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',
|
||||
|
||||
Reference in new issue
Block a user