@placemarkio/tokml
Version:
Convert GeoJSON to KML
1,008 lines (900 loc) • 24.1 kB
JavaScript
/**
* @typedef {import('unist').Node} Node
*/
/**
* @typedef {Array<Node> | string} ChildrenOrValue
* List to use as `children` or value to use as `value`.
*
* @typedef {Record<string, unknown>} Props
* Other fields to add to the node.
*/
/**
* Build a node.
*
* @template {string} T
* @template {Props} P
* @template {Array<Node>} C
*
* @overload
* @param {T} type
* @returns {{type: T}}
*
* @overload
* @param {T} type
* @param {P} props
* @returns {{type: T} & P}
*
* @overload
* @param {T} type
* @param {string} value
* @returns {{type: T, value: string}}
*
* @overload
* @param {T} type
* @param {P} props
* @param {string} value
* @returns {{type: T, value: string} & P}
*
* @overload
* @param {T} type
* @param {C} children
* @returns {{type: T, children: C}}
*
* @overload
* @param {T} type
* @param {P} props
* @param {C} children
* @returns {{type: T, children: C} & P}
*
* @param {string} type
* Node type.
* @param {ChildrenOrValue | Props | null | undefined} [props]
* Fields assigned to node (default: `undefined`).
* @param {ChildrenOrValue | null | undefined} [value]
* Children of node or value of `node` (cast to string).
* @returns {Node}
* Built node.
*/
function u(type, props, value) {
/** @type {Node} */
const node = {type: String(type)};
if (
(value === undefined || value === null) &&
(typeof props === 'string' || Array.isArray(props))
) {
value = props;
} else {
Object.assign(node, props);
}
if (Array.isArray(value)) {
// @ts-expect-error: create a parent.
node.children = value;
} else if (value !== undefined && value !== null) {
// @ts-expect-error: create a literal.
node.value = String(value);
}
return node
}
/**
* @typedef CoreOptions
* @property {ReadonlyArray<string>} [subset=[]]
* Whether to only escape the given subset of characters.
* @property {boolean} [escapeOnly=false]
* Whether to only escape possibly dangerous characters.
* Those characters are `"`, `&`, `'`, `<`, `>`, and `` ` ``.
*
* @typedef FormatOptions
* @property {(code: number, next: number, options: CoreWithFormatOptions) => string} format
* Format strategy.
*
* @typedef {CoreOptions & FormatOptions & import('./util/format-smart.js').FormatSmartOptions} CoreWithFormatOptions
*/
const defaultSubsetRegex = /["&'<>`]/g;
const surrogatePairsRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
const controlCharactersRegex =
// eslint-disable-next-line no-control-regex, unicorn/no-hex-escape
/[\x01-\t\v\f\x0E-\x1F\x7F\x81\x8D\x8F\x90\x9D\xA0-\uFFFF]/g;
const regexEscapeRegex = /[|\\{}()[\]^$+*?.]/g;
/** @type {WeakMap<ReadonlyArray<string>, RegExp>} */
const subsetToRegexCache = new WeakMap();
/**
* Encode certain characters in `value`.
*
* @param {string} value
* @param {CoreWithFormatOptions} options
* @returns {string}
*/
function core(value, options) {
value = value.replace(
options.subset
? charactersToExpressionCached(options.subset)
: defaultSubsetRegex,
basic
);
if (options.subset || options.escapeOnly) {
return value
}
return (
value
// Surrogate pairs.
.replace(surrogatePairsRegex, surrogate)
// BMP control characters (C0 except for LF, CR, SP; DEL; and some more
// non-ASCII ones).
.replace(controlCharactersRegex, basic)
)
/**
* @param {string} pair
* @param {number} index
* @param {string} all
*/
function surrogate(pair, index, all) {
return options.format(
(pair.charCodeAt(0) - 0xd800) * 0x400 +
pair.charCodeAt(1) -
0xdc00 +
0x10000,
all.charCodeAt(index + 2),
options
)
}
/**
* @param {string} character
* @param {number} index
* @param {string} all
*/
function basic(character, index, all) {
return options.format(
character.charCodeAt(0),
all.charCodeAt(index + 1),
options
)
}
}
/**
* A wrapper function that caches the result of `charactersToExpression` with a WeakMap.
* This can improve performance when tooling calls `charactersToExpression` repeatedly
* with the same subset.
*
* @param {ReadonlyArray<string>} subset
* @returns {RegExp}
*/
function charactersToExpressionCached(subset) {
let cached = subsetToRegexCache.get(subset);
if (!cached) {
cached = charactersToExpression(subset);
subsetToRegexCache.set(subset, cached);
}
return cached
}
/**
* @param {ReadonlyArray<string>} subset
* @returns {RegExp}
*/
function charactersToExpression(subset) {
/** @type {Array<string>} */
const groups = [];
let index = -1;
while (++index < subset.length) {
groups.push(subset[index].replace(regexEscapeRegex, '\\$&'));
}
return new RegExp('(?:' + groups.join('|') + ')', 'g')
}
/**
* The smallest way to encode a character.
*
* @param {number} code
* @returns {string}
*/
function formatBasic(code) {
return '&#x' + code.toString(16).toUpperCase() + ';'
}
/**
* @typedef {import('./core.js').CoreOptions & import('./util/format-smart.js').FormatSmartOptions} Options
* @typedef {import('./core.js').CoreOptions} LightOptions
*/
/**
* Encode special characters in `value` as hexadecimals.
*
* @param {string} value
* Value to encode.
* @param {LightOptions} [options]
* Configuration.
* @returns {string}
* Encoded value.
*/
function stringifyEntitiesLight(value, options) {
return core(value, Object.assign({format: formatBasic}, options))
}
// eslint-disable-next-line no-control-regex -- XO is wrong.
const noncharacter = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
/**
* Escape a string.
*
* @param {string} value
* Raw string.
* @param {Array<string>} subset
* Characters to escape.
* @param {RegExp | null | undefined} [unsafe]
* Regex to scope `subset` to (optional).
* @returns {string}
* Escaped string.
*/
function escape(value, subset, unsafe) {
const result = clean(value);
return unsafe ? result.replace(unsafe, encode) : encode(result)
/**
* Actually escape characters.
*
* @param {string} value
* Raw value.
* @returns {string}
* Copy of `value`, escaped.
*/
function encode(value) {
return stringifyEntitiesLight(value, {subset})
}
}
/**
* Remove non-characters.
*
* @param {string} value
* Raw value.
* @returns {string}
* Copy of `value` with non-characters removed.
*/
function clean(value) {
return String(value || '').replace(noncharacter, '')
}
/**
* @typedef {import('xast').Cdata} Cdata
*/
const unsafe$1 = /]]>/g;
const subset$3 = ['>'];
/**
* Serialize a CDATA section.
*
* @param {Cdata} node
* xast cdata node.
* @returns {string}
* Serialized XML.
*/
function cdata(node) {
return '<![CDATA[' + escape(node.value, subset$3, unsafe$1) + ']]>'
}
/**
* @typedef {import('xast').Comment} Comment
*/
/**
* Serialize a comment.
*
* @param {Comment} node
* xast comment node.
* @returns {string}
* Serialized XML.
*/
function comment(node) {
return '<!--' + escape(node.value, ['-']) + '-->'
}
const subset$2 = ['\t', '\n', ' ', '"', '&', "'", '/', '<', '=', '>'];
/**
* Encode a node name.
*
* @param {string} value
* Raw name.
* @returns {string}
* Escaped name.
*/
function name(value) {
return escape(value, subset$2)
}
/**
* Count how often a character (or substring) is used in a string.
*
* @param {string} value
* Value to search in.
* @param {string} character
* Character (or substring) to look for.
* @return {number}
* Number of times `character` occurred in `value`.
*/
function ccount(value, character) {
const source = String(value);
if (typeof character !== 'string') {
throw new TypeError('Expected character')
}
let count = 0;
let index = source.indexOf(character);
while (index !== -1) {
count++;
index = source.indexOf(character, index + character.length);
}
return count
}
/**
* @typedef {import('./index.js').State} State
*/
/**
* Serialize an attribute value.
*
* @param {string} value
* Raw attribute value.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized attribute value.
*/
function value(value, state) {
const result = String(value);
let quote = state.options.quote || '"';
if (state.options.quoteSmart) {
const other = quote === '"' ? "'" : '"';
if (ccount(result, quote) > ccount(result, other)) {
quote = other;
}
}
return quote + escape(result, ['<', '&', quote]) + quote
}
/**
* @typedef {import('xast').Doctype} Doctype
* @typedef {import('./index.js').State} State
*/
/**
* Serialize a doctype.
*
* @param {Doctype} node
* xast doctype node.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized XML.
*/
function doctype(node, state) {
const nodeName = name(node.name);
const pub = node.public;
const sys = node.system;
let result = '<!DOCTYPE';
if (nodeName !== '') {
result += ' ' + nodeName;
}
if (pub) {
result += ' PUBLIC ' + value(pub, state);
} else if (sys) {
result += ' SYSTEM';
}
if (sys) {
result += ' ' + value(sys, state);
}
return result + '>'
}
/**
* @typedef {import('xast').Element} Element
* @typedef {import('./index.js').State} State
*/
const own$1 = {}.hasOwnProperty;
/**
* Serialize an element.
*
* @param {Element} node
* xast element node.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized XML.
*/
function element(node, state) {
const nodeName = name(node.name);
const content = all(node, state);
const attributes = node.attributes || {};
const close = content ? false : state.options.closeEmptyElements;
/** @type {Array<string>} */
const attrs = [];
/** @type {string} */
let key;
for (key in attributes) {
if (own$1.call(attributes, key)) {
const result = attributes[key];
if (result !== null && result !== undefined) {
attrs.push(name(key) + '=' + value(result, state));
}
}
}
return (
'<' +
nodeName +
(attrs.length === 0 ? '' : ' ' + attrs.join(' ')) +
(close ? (state.options.tightClose ? '' : ' ') + '/' : '') +
'>' +
content +
(close ? '' : '</' + nodeName + '>')
)
}
/**
* @typedef {import('xast').Instruction} Instruction
*/
const unsafe = /\?>/g;
const subset$1 = ['>'];
/**
* Serialize an instruction.
*
* @param {Instruction} node
* xast instruction node.
* @returns {string}
* Serialized XML.
*/
function instruction(node) {
const nodeName = name(node.name) || 'x';
const result = escape(node.value, subset$1, unsafe);
return '<?' + nodeName + (result ? ' ' + result : '') + '?>'
}
/**
* @typedef {import('xast').Text} Text
* @typedef {import('../index.js').Raw} Raw
*/
const subset = ['&', '<'];
/**
* Serialize a text.
*
* @param {Raw | Text} node
* xast text node (or raw).
* @returns {string}
* Serialized XML.
*/
function text(node) {
return escape(node.value, subset)
}
/**
* @typedef {import('../index.js').Raw} Raw
* @typedef {import('./index.js').State} State
*/
/**
* Serialize a (non-standard) raw.
*
* @param {Raw} node
* xast raw node.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized XML.
*/
function raw(node, state) {
return state.options.allowDangerousXml ? node.value : text(node)
}
/**
* @typedef {import('xast').Nodes} Nodes
* @typedef {import('xast').Parents} Parents
* @typedef {import('xast').RootContent} RootContent
* @typedef {import('./index.js').State} State
*/
const own = {}.hasOwnProperty;
const handlers = {
cdata,
comment,
doctype,
element,
instruction,
raw,
root: all,
text
};
/**
* Serialize a node.
*
* @param {Nodes} node
* xast node.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized XML.
*/
function one(node, state) {
const type = node && node.type;
if (!type) {
throw new Error('Expected node, not `' + node + '`')
}
if (!own.call(handlers, type)) {
throw new Error('Cannot compile unknown node `' + type + '`')
}
const handle = handlers[type];
// @ts-expect-error hush, node matches `type`.
const result = handle(node, state);
return result
}
/**
* Serialize all children of `parent`.
*
* @param {Parents} parent
* xast parent node.
* @param {State} state
* Info passed around about the current state.
* @returns {string}
* Serialized XML.
*/
function all(parent, state) {
/** @type {Array<RootContent>} */
const children = (parent && parent.children) || [];
let index = -1;
/** @type {Array<string>} */
const results = [];
while (++index < children.length) {
results[index] = one(children[index], state);
}
return results.join('')
}
/**
* @typedef {import('xast').Literal} Literal
* @typedef {import('xast').Nodes} Nodes
*/
/**
* Serialize a xast tree to XML.
*
* @param {Array<Nodes> | Nodes} tree
* xast node(s) to serialize.
* @param {Options | null | undefined} [options]
* Configuration (optional).
* @returns {string}
* Serialized XML.
*/
function toXml(tree, options) {
/** @type {State} */
const state = {options: {}};
// Make sure the quote is valid.
if (
typeof state.options.quote === 'string' &&
state.options.quote !== '"' &&
state.options.quote !== "'"
) {
throw new Error(
'Invalid quote `' + state.options.quote + '`, expected `\'` or `"`'
)
}
/** @type {Nodes} */
// @ts-expect-error Assume no `root` in `node`.
const node = Array.isArray(tree) ? {type: 'root', children: tree} : tree;
return one(node, state)
}
/**
* @typedef {import('xast').Element} Element
* @typedef {import('xast').Nodes} Nodes
* @typedef {import('xast').Root} Root
*/
/**
* @typedef {Element | Root} Result
* Result from a `x` call.
*
* @typedef {boolean | number | string | null | undefined} Value
* Attribute value
*
* @typedef {{[attribute: string]: Value}} Attributes
* Acceptable value for element properties.
*
* @typedef {boolean | number | string | null | undefined} PrimitiveChild
* Primitive children, either ignored (nullish), or turned into text nodes.
* @typedef {Array<Nodes | PrimitiveChild>} ArrayChild
* List of children.
* @typedef {Nodes | PrimitiveChild | ArrayChild} Child
* Acceptable child value.
*/
// Define JSX.
/**
* @typedef {import('./jsx-classic.js').Element} x.JSX.Element
* @typedef {import('./jsx-classic.js').IntrinsicAttributes} x.JSX.IntrinsicAttributes
* @typedef {import('./jsx-classic.js').IntrinsicElements} x.JSX.IntrinsicElements
* @typedef {import('./jsx-classic.js').ElementChildrenAttribute} x.JSX.ElementChildrenAttribute
*/
/**
* Create XML trees in xast.
*
* @param name
* Qualified name.
*
* Case sensitive and can contain a namespace prefix (such as `rdf:RDF`).
* When string, an `Element` is built.
* When nullish, a `Root` is built instead.
* @param attributes
* Attributes of the element or first child.
* @param children
* Children of the node.
* @returns
* `Element` or `Root`.
*/
const x =
// Note: not yet possible to use the spread `...children` in JSDoc overloads.
/**
* @type {{
* (): Root
* (name: null | undefined, ...children: Array<Child>): Root
* (name: string, attributes?: Attributes, ...children: Array<Child>): Element
* (name: string, ...children: Array<Child>): Element
* }}
*/
(
/**
* @param {string | null | undefined} [name]
* @param {Attributes | Child | null | undefined} [attributes]
* @param {Array<Child>} children
* @returns {Result}
*/
function (name, attributes, ...children) {
let index = -1;
/** @type {Result} */
let node;
if (name === undefined || name === null) {
node = {type: 'root', children: []};
// @ts-expect-error: Root builder doesn’t accept attributes.
children.unshift(attributes);
} else if (typeof name === 'string') {
node = {type: 'element', name, attributes: {}, children: []};
if (isAttributes(attributes)) {
/** @type {string} */
let key;
for (key in attributes) {
// Ignore nullish and NaN values.
if (
attributes[key] !== undefined &&
attributes[key] !== null &&
(typeof attributes[key] !== 'number' ||
!Number.isNaN(attributes[key]))
) {
node.attributes[key] = String(attributes[key]);
}
}
} else {
children.unshift(attributes);
}
} else {
throw new TypeError('Expected element name, got `' + name + '`')
}
// Handle children.
while (++index < children.length) {
addChild(node.children, children[index]);
}
return node
}
);
/**
* Add children.
*
* @param {Array<Child>} nodes
* List of nodes.
* @param {Child} value
* Child.
* @returns {undefined}
* Nothing.
*/
function addChild(nodes, value) {
let index = -1;
if (value === undefined || value === null) ; else if (typeof value === 'string' || typeof value === 'number') {
nodes.push({type: 'text', value: String(value)});
} else if (Array.isArray(value)) {
while (++index < value.length) {
addChild(nodes, value[index]);
}
} else if (typeof value === 'object' && 'type' in value) {
if (value.type === 'root') {
addChild(nodes, value.children);
} else {
nodes.push(value);
}
} else {
throw new TypeError('Expected node, nodes, string, got `' + value + '`')
}
}
/**
* Check if `value` is `Attributes`.
*
* @param {Attributes | Child} value
* Value.
* @returns {value is Attributes}
* Whether `value` is `Attributes`.
*/
function isAttributes(value) {
if (
value === null ||
value === undefined ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return false
}
return true
}
const BR = u("text", "\n");
const TAB = u("text", " ");
/**
* Convert nested folder structure to KML. This expects
* input that follows the same patterns as [toGeoJSON](https://github.com/placemark/togeojson)'s
* kmlWithFolders method: a tree of folders and features,
* starting with a root element.
*/
function foldersToKML(root) {
return toXml(u("root", [
x("kml", { xmlns: "http://www.opengis.net/kml/2.2" }, x("Document", root.children.flatMap((child) => convertChild(child)))),
]));
}
/**
* Convert a GeoJSON FeatureCollection to a string of
* KML data.
*/
function toKML(featureCollection) {
return toXml(u("root", [
x("kml", { xmlns: "http://www.opengis.net/kml/2.2" }, x("Document", featureCollection.features.flatMap((feature) => convertFeature(feature)))),
]));
}
function convertChild(child) {
switch (child.type) {
case "Feature":
return convertFeature(child);
case "folder":
return convertFolder(child);
}
}
function convertFolder(folder) {
const id = ["string", "number"].includes(typeof folder.meta.id)
? {
id: String(folder.meta.id),
}
: {};
return [
BR,
x("Folder", id, [
BR,
...folderMeta(folder.meta),
BR,
TAB,
...folder.children.flatMap((child) => convertChild(child)),
]),
];
}
const META_PROPERTIES = [
"address",
"description",
"name",
"open",
"visibility",
"phoneNumber",
];
function folderMeta(meta) {
return META_PROPERTIES.filter((p) => meta[p] !== undefined).map((p) => {
return x(p, [u("text", String(meta[p]))]);
});
}
function convertFeature(feature) {
const { id } = feature;
const idMember = ["string", "number"].includes(typeof id)
? {
id: id,
}
: {};
return [
BR,
x("Placemark", idMember, [
BR,
...propertiesToTags(feature.properties),
BR,
TAB,
...(feature.geometry ? [convertGeometry(feature.geometry)] : []),
]),
];
}
function join(position) {
return `${position[0]},${position[1]}`;
}
function coord1(coordinates) {
return x("coordinates", [u("text", join(coordinates))]);
}
function coord2(coordinates) {
return x("coordinates", [u("text", coordinates.map(join).join("\n"))]);
}
function valueToString(value) {
switch (typeof value) {
case "string": {
return value;
}
case "boolean":
case "number": {
return String(value);
}
case "object": {
try {
return JSON.stringify(value);
}
catch (_e) {
return "";
}
}
}
return "";
}
function maybeCData(value) {
if (value &&
typeof value === "object" &&
"@type" in value &&
value["@type"] === "html" &&
"value" in value &&
typeof value.value === "string") {
return u("cdata", value.value);
}
return valueToString(value);
}
function propertiesToTags(properties) {
if (!properties)
return [];
const { name, description, visibility, ...otherProperties } = properties;
return [
name && x("name", [u("text", valueToString(name))]),
description && x("description", [u("text", maybeCData(description))]),
visibility !== undefined &&
x("visibility", [u("text", visibility ? "1" : "0")]),
x("ExtendedData", Object.entries(otherProperties).flatMap(([name, value]) => [
BR,
TAB,
x("Data", { name: name }, [
x("value", [
u("text", typeof value === "string" ? value : JSON.stringify(value)),
]),
]),
])),
].filter(Boolean);
}
const linearRing = (ring) => x("LinearRing", [coord2(ring)]);
function convertMultiPoint(geometry) {
return x("MultiGeometry", geometry.coordinates.flatMap((coordinates) => [
BR,
convertGeometry({
type: "Point",
coordinates,
}),
]));
}
function convertMultiLineString(geometry) {
return x("MultiGeometry", geometry.coordinates.flatMap((coordinates) => [
BR,
convertGeometry({
type: "LineString",
coordinates,
}),
]));
}
function convertMultiPolygon(geometry) {
return x("MultiGeometry", geometry.coordinates.flatMap((coordinates) => [
BR,
convertGeometry({
type: "Polygon",
coordinates,
}),
]));
}
function convertPolygon(geometry) {
const [outerBoundary, ...innerRings] = geometry.coordinates;
return x("Polygon", [
BR,
x("outerBoundaryIs", [BR, TAB, linearRing(outerBoundary)]),
...innerRings.flatMap((innerRing) => [
BR,
x("innerBoundaryIs", [BR, TAB, linearRing(innerRing)]),
]),
]);
}
function convertGeometry(geometry) {
switch (geometry.type) {
case "Point":
return x("Point", [coord1(geometry.coordinates)]);
case "MultiPoint":
return convertMultiPoint(geometry);
case "LineString":
return x("LineString", [coord2(geometry.coordinates)]);
case "MultiLineString":
return convertMultiLineString(geometry);
case "Polygon":
return convertPolygon(geometry);
case "MultiPolygon":
return convertMultiPolygon(geometry);
case "GeometryCollection":
return x("MultiGeometry", geometry.geometries.flatMap((geometry) => [
BR,
convertGeometry(geometry),
]));
}
}
export { foldersToKML, toKML };
//# sourceMappingURL=tokml.es.mjs.map