@jarenjs/core 0.34.2 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/geo/wkt.js CHANGED
@@ -1,23 +1,37 @@
1
1
  //@ts-check
2
2
 
3
3
  //#region Well-Known Text
4
- // A validity tester for WKT (ISO 19125 / OGC Simple Features), the text
5
- // encoding every spatial database speaks. Nothing here *parses into* a
6
- // geometry the suite's representation is GeoJSON, and a WKT string in
7
- // a JSON document is interchange data passing through — but a schema can
8
- // still assert that such a string is well-formed, which is what a
9
- // `format` needs: a plain predicate over the string itself.
4
+ // WKT (ISO 19125 / OGC Simple Features) is the text encoding every
5
+ // spatial database speaks, and this module is the round trip between it
6
+ // and the suite's representation, which is GeoJSON: `wktToGeoJson`
7
+ // parses, `geoJsonToWkt` writes, and `isValidWkt` answers yes-or-no for
8
+ // a `format` that only needs the judgment.
10
9
  //
11
- // The grammar is validated strictly: the seven geometry tags, an
12
- // optional Z/M/ZM dimension modifier, `EMPTY` or a parenthesized body,
13
- // and a consistent coordinate count per geometry. The dimension rule
14
- // follows the field rather than the letter of SFA: an unmodified tag
15
- // accepts 2 or 3 coordinates per point (PostGIS reads `POINT(1 2 3)` as
16
- // 3D), `Z` and `M` require exactly 3, `ZM` exactly 4 — but whichever
17
- // count the first point establishes, every following point must match.
18
- // Polygon rings must close and carry at least four points, as the spec
19
- // requires. No leading or trailing text is tolerated: a format that
20
- // trims would accept strings a consumer then fails on.
10
+ // **There is one grammar walk and two entry points.** Every scan
11
+ // function takes a sink: absent, it validates and allocates nothing;
12
+ // present, it appends the value it just recognized. The `wkt` format
13
+ // tester runs per value in the validator and per keystroke in the form
14
+ // layer, so making it build a geometry to answer a boolean would be a
15
+ // regression and two hand-maintained grammars for one syntax would
16
+ // drift. The differential test over the committed corpus asserts the
17
+ // two entry points accept exactly the same language.
18
+ //
19
+ // The grammar is validated strictly: the seven geometry tags and only
20
+ // those, an optional Z/M/ZM dimension modifier, `EMPTY` or a
21
+ // parenthesized body, and a consistent coordinate count per geometry.
22
+ // The dimension rule follows the field rather than the letter of SFA:
23
+ // an unmodified tag accepts 2 or 3 coordinates per point (PostGIS reads
24
+ // `POINT(1 2 3)` as 3D), `Z` and `M` require exactly 3, `ZM` exactly 4
25
+ // — but whichever count the first point establishes, every following
26
+ // point must match. Polygon rings must close and carry at least four
27
+ // points, as the spec requires. No leading or trailing text is
28
+ // tolerated: a format that trims would accept strings a consumer then
29
+ // fails on.
30
+ //
31
+ // Parsing answers what the text says and judges nothing else: a
32
+ // coordinate outside the WGS 84 bounds parses, and `isValidGeoJson` is
33
+ // the value gate — a parser that also judged would report the same
34
+ // defect twice and would make the two entry points disagree.
21
35
 
22
36
  import {
23
37
  isAsciiLetterCode,
@@ -25,6 +39,8 @@ import {
25
39
  isWhitespaceCode,
26
40
  } from '../scan.js';
27
41
 
42
+ import { eachPosition, geometryOf, isPosition } from './geojson.js';
43
+
28
44
  // character codes
29
45
  const LPAREN = 0x28;
30
46
  const RPAREN = 0x29;
@@ -33,6 +49,24 @@ const PLUS = 0x2B;
33
49
  const MINUS = 0x2D;
34
50
  const DOT = 0x2E;
35
51
 
52
+ /**
53
+ * The closed tag map, read direction: the GeoJSON type a WKT tag names,
54
+ * or null for anything else. Consulted before the body is scanned, so
55
+ * an unknown tag is refused whether it carries a body or `EMPTY`.
56
+ */
57
+ function geoJsonTypeOf(tag) {
58
+ switch (tag) {
59
+ case 'POINT': return 'Point';
60
+ case 'LINESTRING': return 'LineString';
61
+ case 'POLYGON': return 'Polygon';
62
+ case 'MULTIPOINT': return 'MultiPoint';
63
+ case 'MULTILINESTRING': return 'MultiLineString';
64
+ case 'MULTIPOLYGON': return 'MultiPolygon';
65
+ case 'GEOMETRYCOLLECTION': return 'GeometryCollection';
66
+ default: return null;
67
+ }
68
+ }
69
+
36
70
  function skipWs(text, at) {
37
71
  while (at < text.length && isWhitespaceCode(text.charCodeAt(at)))
38
72
  at++;
@@ -88,12 +122,19 @@ function scanNumber(text, at) {
88
122
  /**
89
123
  * One point: `dim.n` coordinates separated by whitespace. A zero `dim.n`
90
124
  * means the modifier allowed 2 or 3, and the first point decides which.
125
+ * With a sink, appends the position; `dim.m` drops the trailing measure,
126
+ * which is not an altitude and has no place in a GeoJSON position.
91
127
  * Returns the position after the point, or -1.
92
128
  */
93
- function scanPoint(text, at, dim) {
94
- let i = scanNumber(text, skipWs(text, at));
129
+ function scanPoint(text, at, dim, out) {
130
+ const from = skipWs(text, at);
131
+ let i = scanNumber(text, from);
95
132
  if (i < 0)
96
133
  return -1;
134
+ // `+ 0` normalizes -0 to 0: the two are the same point, but only one
135
+ // of them survives JSON.stringify, so a parser producing -0 hands the
136
+ // caller a value that changes the moment it is serialized
137
+ const position = out === null ? null : [Number(text.slice(from, i)) + 0];
97
138
  let count = 1;
98
139
  for (;;) {
99
140
  const j = skipWs(text, i);
@@ -102,6 +143,8 @@ function scanPoint(text, at, dim) {
102
143
  const k = scanNumber(text, j);
103
144
  if (k < 0)
104
145
  break;
146
+ if (position !== null)
147
+ position.push(Number(text.slice(j, k)) + 0);
105
148
  i = k;
106
149
  count++;
107
150
  }
@@ -113,15 +156,21 @@ function scanPoint(text, at, dim) {
113
156
  else if (count !== dim.n) {
114
157
  return -1;
115
158
  }
159
+ if (position !== null) {
160
+ if (dim.m)
161
+ position.length = count - 1;
162
+ out.push(position);
163
+ }
116
164
  return i;
117
165
  }
118
166
 
119
167
  /**
120
168
  * A parenthesized, comma-separated list validated by `scanItem`, with at
121
169
  * least `min` items. `first` may record where each item's scan started —
122
- * the hook ring closure uses. Returns the position after `)`, or -1.
170
+ * the hook ring closure uses and `out` is the sink each item appends
171
+ * its own value to. Returns the position after `)`, or -1.
123
172
  */
124
- function scanList(text, at, dim, min, scanItem, starts) {
173
+ function scanList(text, at, dim, min, scanItem, starts, out) {
125
174
  let i = skipWs(text, at);
126
175
  if (i >= text.length || text.charCodeAt(i) !== LPAREN)
127
176
  return -1;
@@ -131,7 +180,7 @@ function scanList(text, at, dim, min, scanItem, starts) {
131
180
  const from = skipWs(text, i);
132
181
  if (starts !== null)
133
182
  starts.push(from);
134
- i = scanItem(text, from, dim);
183
+ i = scanItem(text, from, dim, out);
135
184
  if (i < 0)
136
185
  return -1;
137
186
  count++;
@@ -147,95 +196,160 @@ function scanList(text, at, dim, min, scanItem, starts) {
147
196
  }
148
197
  }
149
198
 
199
+ /**
200
+ * A list whose items belong to a nesting level of their own: the items
201
+ * fill a fresh array and that array is what this level appends. Every
202
+ * scan function appends exactly one value to its sink, so the caller
203
+ * always reads its result at index 0.
204
+ */
205
+ function scanNested(text, at, dim, min, scanItem, out) {
206
+ const items = out === null ? null : [];
207
+ const i = scanList(text, at, dim, min, scanItem, null, items);
208
+ if (i < 0)
209
+ return -1;
210
+ if (out !== null)
211
+ out.push(items);
212
+ return i;
213
+ }
214
+
150
215
  /** A point that may also be wrapped in its own parens (MULTIPOINT). */
151
- function scanMultiPointItem(text, at, dim) {
216
+ function scanMultiPointItem(text, at, dim, out) {
152
217
  if (at < text.length && text.charCodeAt(at) === LPAREN) {
153
- const i = scanPoint(text, at + 1, dim);
218
+ const i = scanPoint(text, at + 1, dim, out);
154
219
  if (i < 0)
155
220
  return -1;
156
221
  const j = skipWs(text, i);
157
222
  return j < text.length && text.charCodeAt(j) === RPAREN ? j + 1 : -1;
158
223
  }
159
- return scanPoint(text, at, dim);
224
+ return scanPoint(text, at, dim, out);
160
225
  }
161
226
 
162
- function scanLineStringBody(text, at, dim) {
163
- return scanList(text, at, dim, 2, scanPoint, null);
227
+ function scanLineStringBody(text, at, dim, out) {
228
+ return scanNested(text, at, dim, 2, scanPoint, out);
164
229
  }
165
230
 
166
231
  /** A ring: four or more points, and the first equals the last. */
167
- function scanRing(text, at, dim) {
232
+ function scanRing(text, at, dim, out) {
168
233
  const starts = [];
169
- const i = scanList(text, at, dim, 4, scanPoint, starts);
234
+ const ring = out === null ? null : [];
235
+ const i = scanList(text, at, dim, 4, scanPoint, starts, ring);
170
236
  if (i < 0)
171
237
  return -1;
172
- // compare the first and last point textually by re-scanning both spans
238
+ // compare the first and last point textually by re-scanning both
239
+ // spans: closure is a property of the coordinates the text carries,
240
+ // including a measure the built ring does not keep
173
241
  const first = pointText(text, starts[0], dim);
174
242
  const last = pointText(text, starts[starts.length - 1], dim);
175
- return first !== null && first === last ? i : -1;
243
+ if (first === null || first !== last)
244
+ return -1;
245
+ if (out !== null)
246
+ out.push(ring);
247
+ return i;
176
248
  }
177
249
 
178
250
  /** The point's coordinates as a normalized string, for closure tests. */
179
251
  function pointText(text, at, dim) {
180
- const end = scanPoint(text, at, dim);
252
+ const end = scanPoint(text, at, dim, null);
181
253
  if (end < 0)
182
254
  return null;
183
255
  const parts = text.slice(at, end).trim().split(/\s+/);
184
256
  return parts.map(Number).join(',');
185
257
  }
186
258
 
187
- function scanPolygonBody(text, at, dim) {
188
- return scanList(text, at, dim, 1, scanRing, null);
259
+ function scanPolygonBody(text, at, dim, out) {
260
+ return scanNested(text, at, dim, 1, scanRing, out);
261
+ }
262
+
263
+ /** A collection member is a whole tagged geometry, modifier and all. */
264
+ function scanCollectionItem(text, at, dim, out) {
265
+ return scanGeometry(text, at, out);
189
266
  }
190
267
 
191
268
  /**
192
- * One tagged geometry: `TAG [Z|M|ZM] (EMPTY | body)`. Returns the
193
- * position after it, or -1.
269
+ * One tagged geometry: `TAG [Z|M|ZM] (EMPTY | body)`. With a sink,
270
+ * appends the GeoJSON geometry it recognized. Returns the position
271
+ * after it, or -1.
194
272
  */
195
- function scanGeometry(text, at) {
273
+ function scanGeometry(text, at, out) {
196
274
  const word = readWord(text, skipWs(text, at));
197
275
  if (word === null)
198
276
  return -1;
199
- const [tag, afterTag] = word;
200
- let i = afterTag;
201
- // an exact count from the modifier, or 0 for "2 or 3, first point decides"
202
- const dim = { n: 0 };
277
+ const type = geoJsonTypeOf(word[0]);
278
+ if (type === null)
279
+ return -1;
280
+ let i = word[1];
281
+ // an exact count from the modifier, or 0 for "2 or 3, first point
282
+ // decides"; `m` marks the trailing coordinate as a measure
283
+ const dim = { n: 0, m: false };
203
284
  const mod = readWord(text, skipWs(text, i));
204
285
  if (mod !== null && (mod[0] === 'Z' || mod[0] === 'M' || mod[0] === 'ZM')) {
205
286
  dim.n = mod[0] === 'ZM' ? 4 : 3;
287
+ dim.m = mod[0] !== 'Z';
206
288
  i = mod[1];
207
289
  }
208
290
  const empty = readWord(text, skipWs(text, i));
209
- if (empty !== null && empty[0] === 'EMPTY')
291
+ if (empty !== null && empty[0] === 'EMPTY') {
292
+ if (out !== null) {
293
+ out.push(type === 'GeometryCollection'
294
+ ? { type, geometries: [] }
295
+ : { type, coordinates: [] });
296
+ }
210
297
  return empty[1];
298
+ }
211
299
 
212
- switch (tag) {
213
- case 'POINT': {
300
+ const parts = out === null ? null : [];
301
+ let end;
302
+ switch (type) {
303
+ case 'Point': {
214
304
  const j = skipWs(text, i);
215
305
  if (j >= text.length || text.charCodeAt(j) !== LPAREN)
216
306
  return -1;
217
- const k = scanPoint(text, j + 1, dim);
307
+ const k = scanPoint(text, j + 1, dim, parts);
218
308
  if (k < 0)
219
309
  return -1;
220
310
  const l = skipWs(text, k);
221
- return l < text.length && text.charCodeAt(l) === RPAREN ? l + 1 : -1;
311
+ if (l >= text.length || text.charCodeAt(l) !== RPAREN)
312
+ return -1;
313
+ end = l + 1;
314
+ break;
222
315
  }
223
- case 'LINESTRING':
224
- return scanLineStringBody(text, i, dim);
225
- case 'POLYGON':
226
- return scanPolygonBody(text, i, dim);
227
- case 'MULTIPOINT':
228
- return scanList(text, i, dim, 1, scanMultiPointItem, null);
229
- case 'MULTILINESTRING':
230
- return scanList(text, i, dim, 1, scanLineStringBody, null);
231
- case 'MULTIPOLYGON':
232
- return scanList(text, i, dim, 1, scanPolygonBody, null);
233
- case 'GEOMETRYCOLLECTION':
234
- // members are whole tagged geometries with modifiers of their own
235
- return scanList(text, i, dim, 1, (t, a) => scanGeometry(t, a), null);
236
- default:
237
- return -1;
316
+ case 'LineString':
317
+ end = scanLineStringBody(text, i, dim, parts);
318
+ break;
319
+ case 'Polygon':
320
+ end = scanPolygonBody(text, i, dim, parts);
321
+ break;
322
+ case 'MultiPoint':
323
+ end = scanNested(text, i, dim, 1, scanMultiPointItem, parts);
324
+ break;
325
+ case 'MultiLineString':
326
+ end = scanNested(text, i, dim, 1, scanLineStringBody, parts);
327
+ break;
328
+ case 'MultiPolygon':
329
+ end = scanNested(text, i, dim, 1, scanPolygonBody, parts);
330
+ break;
331
+ default: // GeometryCollection
332
+ end = scanNested(text, i, dim, 1, scanCollectionItem, parts);
333
+ break;
334
+ }
335
+ if (end < 0)
336
+ return -1;
337
+ if (out !== null) {
338
+ out.push(type === 'GeometryCollection'
339
+ ? { type, geometries: parts[0] }
340
+ : { type, coordinates: parts[0] });
238
341
  }
342
+ return end;
343
+ }
344
+
345
+ /**
346
+ * The one entry into the walk: a null sink validates, an array sink
347
+ * builds. Answers whether the whole string was a single geometry.
348
+ */
349
+ function scanWkt(text, out) {
350
+ if (typeof text !== 'string' || text.length === 0 || isWhitespaceCode(text.charCodeAt(0)))
351
+ return false;
352
+ return scanGeometry(text, 0, out) === text.length;
239
353
  }
240
354
 
241
355
  /**
@@ -244,6 +358,9 @@ function scanGeometry(text, at) {
244
358
  * coordinate count consistent with the `Z`/`M`/`ZM` modifier and with
245
359
  * itself, closed polygon rings, and nothing before or after.
246
360
  *
361
+ * This is the non-allocating half of the walk {@link wktToGeoJson}
362
+ * builds with, so the two accept exactly the same language.
363
+ *
247
364
  * @param {string} text
248
365
  * @returns {boolean}
249
366
  * @example
@@ -252,11 +369,210 @@ function scanGeometry(text, at) {
252
369
  * isValidWkt('POLYGON ((0 0, 4 0, 4 4, 1 1))'); // false (open ring)
253
370
  * isValidWkt('POINT Z (1 2)'); // false (Z wants 3)
254
371
  * isValidWkt('LINESTRING (0 0, 1 1 1)'); // false (mixed dimension)
372
+ * isValidWkt('CIRCLE EMPTY'); // false (not one of the seven)
255
373
  */
256
374
  export function isValidWkt(text) {
257
- if (typeof text !== 'string' || text.length === 0 || isWhitespaceCode(text.charCodeAt(0)))
258
- return false;
259
- return scanGeometry(text, 0) === text.length;
375
+ return scanWkt(text, null);
376
+ }
377
+
378
+ /**
379
+ * The GeoJSON geometry a WKT string names, or `null` when the text is
380
+ * not well-formed WKT — the same judgment {@link isValidWkt} makes,
381
+ * from the same walk. `null` rather than a throw is the kernel's
382
+ * posture for "no answer" (`bboxOf`, `centroidOf`, `geohashBounds` all
383
+ * answer it), so no call site has to wrap this in a try.
384
+ *
385
+ * Three rules decide what a lenient parser would silently lose:
386
+ *
387
+ * - `EMPTY` becomes an empty coordinate array (`POINT EMPTY` →
388
+ * `{ type: 'Point', coordinates: [] }`), never `null`: unparseable
389
+ * and validly empty are different answers.
390
+ * - The `M` measure is dropped and `Z` is kept. RFC 7946 §3.1.1 defines
391
+ * a position's third element as altitude, and a measure is not one —
392
+ * writing it there would be a lie. `POINT ZM (1 2 3 4)` → `[1, 2, 3]`,
393
+ * `POINT M (1 2 3)` → `[1, 2]`.
394
+ * - Coordinate ranges are not judged. `POINT (999 999)` parses;
395
+ * `isValidGeoJson` is the value gate.
396
+ *
397
+ * @param {string} text
398
+ * @returns {object | null}
399
+ * @example
400
+ * wktToGeoJson('POINT (4.9041 52.3676)');
401
+ * // { type: 'Point', coordinates: [4.9041, 52.3676] }
402
+ * wktToGeoJson('POLYGON ((0 0, 4 0, 4 4, 0 0))');
403
+ * // { type: 'Polygon', coordinates: [[[0,0],[4,0],[4,4],[0,0]]] }
404
+ * wktToGeoJson('POINT EMPTY'); // { type: 'Point', coordinates: [] }
405
+ * wktToGeoJson('POLYGON ((0 0))'); // null
406
+ */
407
+ export function wktToGeoJson(text) {
408
+ const out = [];
409
+ return scanWkt(text, out) ? out[0] : null;
410
+ }
411
+
412
+ /**
413
+ * The closed tag map, write direction: the WKT tag each GeoJSON type
414
+ * takes, the nesting depth of its body, and whether its leaf positions
415
+ * are parenthesized. ISO 19125 spells a `<point text>` with its own
416
+ * parentheses — which POINT and MULTIPOINT carry and a position inside
417
+ * a line or ring does not.
418
+ */
419
+ const WKT_BODY = {
420
+ Point: { tag: 'POINT', depth: 0, wrap: true },
421
+ LineString: { tag: 'LINESTRING', depth: 1, wrap: false },
422
+ Polygon: { tag: 'POLYGON', depth: 2, wrap: false },
423
+ MultiPoint: { tag: 'MULTIPOINT', depth: 1, wrap: true },
424
+ MultiLineString: { tag: 'MULTILINESTRING', depth: 2, wrap: false },
425
+ MultiPolygon: { tag: 'MULTIPOLYGON', depth: 3, wrap: false },
426
+ };
427
+
428
+ /**
429
+ * One position. `String(n)` is the shortest round-tripping spelling and
430
+ * the one JSON.stringify uses, so a coordinate written here parses back
431
+ * to the same number; a fixed precision would move it.
432
+ */
433
+ function writePosition(position, hasZ) {
434
+ if (!Array.isArray(position) || position.length < 2)
435
+ return null;
436
+ const x = position[0];
437
+ const y = position[1];
438
+ if (typeof x !== 'number' || !Number.isFinite(x))
439
+ return null;
440
+ if (typeof y !== 'number' || !Number.isFinite(y))
441
+ return null;
442
+ if (!hasZ)
443
+ return `${x} ${y}`;
444
+ const z = position[2];
445
+ if (typeof z !== 'number' || !Number.isFinite(z))
446
+ return null;
447
+ return `${x} ${y} ${z}`;
448
+ }
449
+
450
+ /** A coordinate nest `depth` levels deep, or null if it cannot be written. */
451
+ function writeCoordinates(value, depth, hasZ, wrap) {
452
+ if (depth === 0) {
453
+ const text = writePosition(value, hasZ);
454
+ if (text === null)
455
+ return null;
456
+ return wrap ? `(${text})` : text;
457
+ }
458
+ if (!Array.isArray(value) || value.length === 0)
459
+ return null;
460
+ const parts = [];
461
+ for (let i = 0; i < value.length; i++) {
462
+ const text = writeCoordinates(value[i], depth - 1, hasZ, wrap);
463
+ if (text === null)
464
+ return null;
465
+ parts.push(text);
466
+ }
467
+ return `(${parts.join(', ')})`;
468
+ }
469
+
470
+ /** Whether every position of a value carries a third element. */
471
+ function isEvery3D(value) {
472
+ let seen = false;
473
+ let all = true;
474
+ eachPosition(value, (position) => {
475
+ seen = true;
476
+ if (position.length < 3)
477
+ all = false;
478
+ });
479
+ return seen && all;
480
+ }
481
+
482
+ /** One tagged geometry, modifier and all, or null. */
483
+ function writeGeometry(geometry, dim) {
484
+ if (geometry === null || typeof geometry !== 'object' || Array.isArray(geometry))
485
+ return null;
486
+ const type = geometry.type;
487
+ if (type === 'GeometryCollection') {
488
+ const geometries = geometry.geometries;
489
+ if (!Array.isArray(geometries))
490
+ return null;
491
+ if (geometries.length === 0)
492
+ return 'GEOMETRYCOLLECTION EMPTY';
493
+ const parts = [];
494
+ for (let i = 0; i < geometries.length; i++) {
495
+ // each member carries its own modifier, which is the only place
496
+ // WKT lets the dimension change inside one value
497
+ const text = writeGeometry(geometries[i], dim);
498
+ if (text === null)
499
+ return null;
500
+ parts.push(text);
501
+ }
502
+ return `GEOMETRYCOLLECTION (${parts.join(', ')})`;
503
+ }
504
+ if (typeof type !== 'string' || !Object.hasOwn(WKT_BODY, type))
505
+ return null;
506
+ const body = WKT_BODY[type];
507
+ const coordinates = geometry.coordinates;
508
+ if (!Array.isArray(coordinates))
509
+ return null;
510
+ if (coordinates.length === 0)
511
+ return `${body.tag} EMPTY`;
512
+ const hasZ = dim === 3 && isEvery3D(geometry);
513
+ const text = writeCoordinates(coordinates, body.depth, hasZ, body.wrap);
514
+ return text === null ? null : `${body.tag}${hasZ ? ' Z' : ''} ${text}`;
515
+ }
516
+
517
+ /**
518
+ * The geometry a value carries, following the traversal layer's
519
+ * unwrapping posture: a Feature yields its geometry, a
520
+ * FeatureCollection a GeometryCollection of its features', a bare
521
+ * position a Point.
522
+ */
523
+ function geometryToWrite(value) {
524
+ if (isPosition(value))
525
+ return { type: 'Point', coordinates: value };
526
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
527
+ return null;
528
+ if (value.type === 'FeatureCollection') {
529
+ const features = Array.isArray(value.features) ? value.features : [];
530
+ const geometries = [];
531
+ for (let i = 0; i < features.length; i++) {
532
+ // a Feature with a null geometry is a located-nowhere record;
533
+ // WKT cannot say that, so it contributes nothing to the collection
534
+ const inner = geometryToWrite(features[i]);
535
+ if (inner !== null)
536
+ geometries.push(inner);
537
+ }
538
+ return { type: 'GeometryCollection', geometries };
539
+ }
540
+ return geometryOf(value);
541
+ }
542
+
543
+ /**
544
+ * A GeoJSON value as a WKT string, or `null` for a value with no
545
+ * geometry to write. Accepts what the traversal layer accepts: a bare
546
+ * position, a geometry, a Feature or a FeatureCollection.
547
+ *
548
+ * `options.dim` is 2 (the default) or 3. At 3, a geometry whose every
549
+ * position carries a third element is written with a `Z` modifier;
550
+ * anything else is written 2D with the third elements dropped, because
551
+ * one WKT geometry carries one modifier for all of its coordinates. The
552
+ * decision is made per tagged geometry, so a GeometryCollection may mix
553
+ * 2D and 3D members — each writes its own modifier. Invalid WKT is
554
+ * never emitted.
555
+ *
556
+ * A non-finite coordinate yields `null`: a value that cannot be written
557
+ * is not written approximately.
558
+ *
559
+ * @param {any} value
560
+ * @param {{ dim?: number }} [options]
561
+ * @returns {string | null}
562
+ * @example
563
+ * geoJsonToWkt({ type: 'Point', coordinates: [4.9041, 52.3676] });
564
+ * // 'POINT (4.9041 52.3676)'
565
+ * geoJsonToWkt([4.9041, 52.3676]); // 'POINT (4.9041 52.3676)'
566
+ * geoJsonToWkt({ type: 'Point', coordinates: [] }); // 'POINT EMPTY'
567
+ * geoJsonToWkt({ type: 'Point', coordinates: [1, 2, 3] }, { dim: 3 });
568
+ * // 'POINT Z (1 2 3)'
569
+ * geoJsonToWkt({ type: 'Point', coordinates: [NaN, 2] }); // null
570
+ */
571
+ export function geoJsonToWkt(value, options = {}) {
572
+ const geometry = geometryToWrite(value);
573
+ if (geometry === null)
574
+ return null;
575
+ return writeGeometry(geometry, options.dim === 3 ? 3 : 2);
260
576
  }
261
577
 
262
578
  //#endregion
package/src/object.js CHANGED
@@ -453,6 +453,74 @@ export function isJsonObject(value) {
453
453
  return typeof value === 'object' && value !== null && !Array.isArray(value);
454
454
  }
455
455
 
456
+ /**
457
+ * True when a value is representable as JSON as it stands: `null`, a
458
+ * boolean, a finite number, a string, or an array/plain object (own
459
+ * enumerable members, prototype `Object.prototype` or `null`) whose
460
+ * members all are. Everything else — `undefined`, `bigint`, `symbol`,
461
+ * functions, `NaN`/`±Infinity`, class instances (`Error`, `Date`, `Map`,
462
+ * `Response`, DOM nodes ...) and any cycle — is not.
463
+ *
464
+ * This is the boundary predicate for "JSON only crosses": a host that
465
+ * hands a value into application state asks it once, before the value
466
+ * can carry an object the state validator would have to reject. It is
467
+ * TOTAL — a hostile accessor that throws makes the value not-JSON rather
468
+ * than propagating.
469
+ *
470
+ * @param {any} value
471
+ * @returns {boolean}
472
+ */
473
+ export function isJsonValue(value) {
474
+ try {
475
+ return isJsonValueRec(value, null);
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * @param {any} value
484
+ * @param {Set<object> | null} path - the containers on the current
485
+ * descent (a cycle is a container met twice on ONE path; a shared
486
+ * subtree met on two paths is fine JSON)
487
+ * @returns {boolean}
488
+ */
489
+ function isJsonValueRec(value, path) {
490
+ switch (typeof value) {
491
+ case 'string':
492
+ case 'boolean':
493
+ return true;
494
+ case 'number':
495
+ return Number.isFinite(value);
496
+ case 'object':
497
+ break;
498
+ default:
499
+ return false;
500
+ }
501
+ if (value === null) return true;
502
+ const isArray = Array.isArray(value);
503
+ if (!isArray) {
504
+ const proto = Object.getPrototypeOf(value);
505
+ if (proto !== Object.prototype && proto !== null) return false;
506
+ }
507
+ if (path !== null && path.has(value)) return false;
508
+ const next = path ?? new Set();
509
+ next.add(value);
510
+ if (isArray) {
511
+ for (let i = 0; i < value.length; i++) {
512
+ if (!isJsonValueRec(value[i], next)) return false;
513
+ }
514
+ }
515
+ else {
516
+ for (const key of Object.keys(value)) {
517
+ if (!isJsonValueRec(value[key], next)) return false;
518
+ }
519
+ }
520
+ next.delete(value);
521
+ return true;
522
+ }
523
+
456
524
  /**
457
525
  * Assign a member so that a key named `__proto__` becomes an own data
458
526
  * property instead of reassigning the object's prototype. Every builder
package/src/scan.js CHANGED
@@ -30,6 +30,7 @@ export const CC_0 = 0x30;
30
30
  export const CC_1 = 0x31;
31
31
  export const CC_9 = 0x39;
32
32
  export const CC_COLON = 0x3A;
33
+ export const CC_SEMICOLON = 0x3B;
33
34
  export const CC_LT = 0x3C;
34
35
  export const CC_EQ = 0x3D;
35
36
  export const CC_GT = 0x3E;