@math.gl/crs 4.2.0-alpha.5

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/wkt-crs.ts ADDED
@@ -0,0 +1,787 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ /** WKT-CRS standards and common compatibility dialects understood by the validator. */
6
+ export type WKTCRSProfile = 'wkt1' | 'wkt2:2015' | 'wkt2:2019' | 'gdal' | 'esri';
7
+
8
+ /** Delimiter used by a WKT node. */
9
+ export type WKTCRSDelimiter = 'bracket' | 'parenthesis';
10
+
11
+ /** A quoted WKT string literal. */
12
+ export type WKTCRSString = {
13
+ readonly type: 'string';
14
+ readonly value: string;
15
+ };
16
+
17
+ /** A WKT number with its source lexeme retained to preserve precision. */
18
+ export type WKTCRSNumber = {
19
+ readonly type: 'number';
20
+ readonly value: number;
21
+ readonly raw: string;
22
+ };
23
+
24
+ /** An unquoted WKT enumeration or identifier. */
25
+ export type WKTCRSEnumeration = {
26
+ readonly type: 'enumeration';
27
+ readonly value: string;
28
+ };
29
+
30
+ /** A keyword and its ordered WKT values. */
31
+ export type WKTCRSNode = {
32
+ readonly type: 'node';
33
+ readonly keyword: string;
34
+ readonly delimiter: WKTCRSDelimiter;
35
+ readonly values: readonly WKTCRSValue[];
36
+ };
37
+
38
+ /** Values accepted inside a WKT node. */
39
+ export type WKTCRSValue = WKTCRSNode | WKTCRSString | WKTCRSNumber | WKTCRSEnumeration;
40
+
41
+ /** Syntax tree for one WKT coordinate reference system or coordinate operation. */
42
+ export type WKTCRSAst = {
43
+ readonly type: 'wkt-crs';
44
+ readonly root: WKTCRSNode;
45
+ };
46
+
47
+ /** Options for parsing WKT coordinate reference systems. */
48
+ export type ParseWKTCRSOptions = {
49
+ /** Validation profile. `auto` distinguishes WKT1 from WKT2 by root keyword. */
50
+ profile?: WKTCRSProfile | 'auto';
51
+ /** Validate known profile keywords and unambiguous value shapes after parsing. */
52
+ strict?: boolean;
53
+ };
54
+
55
+ /** Options for encoding a WKT syntax tree. */
56
+ export type EncodeWKTCRSOptions = {
57
+ /** Compact output is the canonical serialization. */
58
+ format?: 'compact' | 'pretty';
59
+ /** Number of spaces used for each pretty-print indentation level. */
60
+ indent?: number;
61
+ };
62
+
63
+ /** Options for validating a WKT syntax tree. */
64
+ export type ValidateWKTCRSOptions = {
65
+ /** Validation profile. `auto` distinguishes WKT1 from WKT2 by root keyword. */
66
+ profile?: WKTCRSProfile | 'auto';
67
+ /** Permit unrecognized extension keywords. Defaults to `false`. */
68
+ allowExtensions?: boolean;
69
+ };
70
+
71
+ /** Stable validation issue codes returned by {@link validateWKTCRS}. */
72
+ export type WKTCRSValidationIssueCode =
73
+ | 'invalid-root'
74
+ | 'unknown-keyword'
75
+ | 'empty-node'
76
+ | 'invalid-value';
77
+
78
+ /** One profile validation issue in a WKT syntax tree. */
79
+ export type WKTCRSValidationIssue = {
80
+ readonly code: WKTCRSValidationIssueCode;
81
+ readonly message: string;
82
+ /** Zero-based value indices from the root node to the offending node. */
83
+ readonly path: readonly number[];
84
+ readonly keyword: string;
85
+ };
86
+
87
+ /** Syntax error containing a source offset and one-based line and column. */
88
+ export class WKTCRSSyntaxError extends SyntaxError {
89
+ readonly offset: number;
90
+ readonly line: number;
91
+ readonly column: number;
92
+
93
+ /** Create a WKT syntax error at a source location. */
94
+ constructor(message: string, source: string, offset: number) {
95
+ const location = getSourceLocation(source, offset);
96
+ super(`${message} at ${location.line}:${location.column}`);
97
+ this.name = 'WKTCRSSyntaxError';
98
+ this.offset = offset;
99
+ this.line = location.line;
100
+ this.column = location.column;
101
+ }
102
+ }
103
+
104
+ /** Error thrown when strict WKT profile validation fails. */
105
+ export class WKTCRSValidationError extends Error {
106
+ readonly issues: readonly WKTCRSValidationIssue[];
107
+
108
+ /** Create an error from one or more validation issues. */
109
+ constructor(issues: readonly WKTCRSValidationIssue[]) {
110
+ super(issues[0]?.message || 'WKT validation failed');
111
+ this.name = 'WKTCRSValidationError';
112
+ this.issues = issues;
113
+ }
114
+ }
115
+
116
+ const NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?$/;
117
+ const KEYWORD_PATTERN = /^[A-Za-z][A-Za-z0-9_]*/;
118
+
119
+ const WKT1_ROOT_KEYWORDS = new Set([
120
+ 'COMPD_CS',
121
+ 'CONCAT_MT',
122
+ 'FITTED_CS',
123
+ 'GEOCCS',
124
+ 'GEOGCS',
125
+ 'INVERSE_MT',
126
+ 'LOCAL_CS',
127
+ 'PARAM_MT',
128
+ 'PASSTHROUGH_MT',
129
+ 'PROJCS',
130
+ 'VERT_CS'
131
+ ]);
132
+
133
+ const WKT1_KEYWORDS = new Set([
134
+ ...WKT1_ROOT_KEYWORDS,
135
+ 'AUTHORITY',
136
+ 'AXIS',
137
+ 'CONCAT_MT',
138
+ 'DATUM',
139
+ 'INVERSE_MT',
140
+ 'LOCAL_DATUM',
141
+ 'PARAMETER',
142
+ 'PARAM_MT',
143
+ 'PASSTHROUGH_MT',
144
+ 'PRIMEM',
145
+ 'PROJECTION',
146
+ 'SPHEROID',
147
+ 'TOWGS84',
148
+ 'UNIT',
149
+ 'VERT_DATUM'
150
+ ]);
151
+
152
+ const GDAL_WKT1_ROOT_KEYWORDS = new Set(WKT1_ROOT_KEYWORDS);
153
+ const GDAL_WKT1_KEYWORDS = new Set([...WKT1_KEYWORDS, 'EXTENSION']);
154
+
155
+ const ESRI_WKT1_ROOT_KEYWORDS = new Set([...WKT1_ROOT_KEYWORDS, 'COMPDCS', 'LOCALCS', 'VERTCS']);
156
+ const ESRI_WKT1_KEYWORDS = new Set([...WKT1_KEYWORDS, 'COMPDCS', 'LOCALCS', 'VDATUM', 'VERTCS']);
157
+
158
+ const WKT2_2015_ROOT_KEYWORDS = new Set([
159
+ 'BOUNDCRS',
160
+ 'COMPOUNDCRS',
161
+ 'COORDINATEOPERATION',
162
+ 'ENGCRS',
163
+ 'ENGINEERINGCRS',
164
+ 'GEODCRS',
165
+ 'GEODETICCRS',
166
+ 'PARAMETRICCRS',
167
+ 'PROJCRS',
168
+ 'PROJECTEDCRS',
169
+ 'TIMECRS',
170
+ 'VERTCRS',
171
+ 'VERTICALCRS'
172
+ ]);
173
+
174
+ const WKT2_2019_ROOT_KEYWORDS = new Set([
175
+ ...WKT2_2015_ROOT_KEYWORDS,
176
+ 'CONCATENATEDOPERATION',
177
+ 'COORDINATEMETADATA',
178
+ 'DERIVEDPROJCRS',
179
+ 'GEOGCRS',
180
+ 'GEOGRAPHICCRS',
181
+ 'POINTMOTIONOPERATION'
182
+ ]);
183
+
184
+ // WKT is intentionally represented as a generic syntax tree. This set supports strict profile
185
+ // checking without restricting tolerant parsing of vendor extensions.
186
+ const WKT2_KEYWORDS = new Set([
187
+ ...WKT2_2019_ROOT_KEYWORDS,
188
+ 'ABRIDGEDTRANSFORMATION',
189
+ 'ANCHOR',
190
+ 'ANGLEUNIT',
191
+ 'AREA',
192
+ 'AXIS',
193
+ 'BASEENGCRS',
194
+ 'BASEGEODCRS',
195
+ 'BASEGEOGCRS',
196
+ 'BASEPARAMCRS',
197
+ 'BASEPROJCRS',
198
+ 'BASETIMECRS',
199
+ 'BASEVERTCRS',
200
+ 'BBOX',
201
+ 'BEARING',
202
+ 'CALENDAR',
203
+ 'CITATION',
204
+ 'CONVERSION',
205
+ 'COORDEPOCH',
206
+ 'CS',
207
+ 'DATUM',
208
+ 'DERIVINGCONVERSION',
209
+ 'DYNAMIC',
210
+ 'EDATUM',
211
+ 'ELLIPSOID',
212
+ 'ENGINEERINGDATUM',
213
+ 'ENSEMBLE',
214
+ 'ENSEMBLEACCURACY',
215
+ 'EPOCH',
216
+ 'FRAMEEPOCH',
217
+ 'GEODETICDATUM',
218
+ 'GEOIDMODEL',
219
+ 'ID',
220
+ 'INTERPOLATIONCRS',
221
+ 'LENGTHUNIT',
222
+ 'MEMBER',
223
+ 'MERIDIAN',
224
+ 'METHOD',
225
+ 'MODEL',
226
+ 'OPERATIONACCURACY',
227
+ 'ORDER',
228
+ 'PARAMETER',
229
+ 'PARAMETERFILE',
230
+ 'PARAMETRICDATUM',
231
+ 'PARAMETRICUNIT',
232
+ 'PDATUM',
233
+ 'PRIMEM',
234
+ 'PRIMEMERIDIAN',
235
+ 'REMARK',
236
+ 'SCALEUNIT',
237
+ 'SCOPE',
238
+ 'SOURCECRS',
239
+ 'STEP',
240
+ 'TARGETCRS',
241
+ 'TDATUM',
242
+ 'TEMPORALQUANTITY',
243
+ 'TIMEEXTENT',
244
+ 'TIMEORIGIN',
245
+ 'TIMEUNIT',
246
+ 'TIMEDATUM',
247
+ 'TRIAXIAL',
248
+ 'TRF',
249
+ 'URI',
250
+ 'USAGE',
251
+ 'VDATUM',
252
+ 'VERSION',
253
+ 'VERTICALDATUM',
254
+ 'VERTICALEXTENT',
255
+ 'VELOCITYGRID',
256
+ 'VRF'
257
+ ]);
258
+
259
+ const WKT2_2019_ADDITION_KEYWORDS = new Set([
260
+ 'BASEGEOGCRS',
261
+ 'CALENDAR',
262
+ 'CONCATENATEDOPERATION',
263
+ 'COORDEPOCH',
264
+ 'COORDINATEMETADATA',
265
+ 'DERIVEDPROJCRS',
266
+ 'DYNAMIC',
267
+ 'ENSEMBLE',
268
+ 'ENSEMBLEACCURACY',
269
+ 'EPOCH',
270
+ 'FRAMEEPOCH',
271
+ 'GEOGCRS',
272
+ 'GEOGRAPHICCRS',
273
+ 'GEOIDMODEL',
274
+ 'MEMBER',
275
+ 'MODEL',
276
+ 'POINTMOTIONOPERATION',
277
+ 'STEP',
278
+ 'TEMPORALQUANTITY',
279
+ 'TRF',
280
+ 'TRIAXIAL',
281
+ 'VELOCITYGRID',
282
+ 'VRF'
283
+ ]);
284
+
285
+ const WKT2_2015_KEYWORDS = new Set(
286
+ [...WKT2_KEYWORDS].filter(keyword => !WKT2_2019_ADDITION_KEYWORDS.has(keyword))
287
+ );
288
+
289
+ const PROFILE_ROOT_KEYWORDS: Record<WKTCRSProfile, ReadonlySet<string>> = {
290
+ wkt1: WKT1_ROOT_KEYWORDS,
291
+ gdal: GDAL_WKT1_ROOT_KEYWORDS,
292
+ esri: ESRI_WKT1_ROOT_KEYWORDS,
293
+ 'wkt2:2015': WKT2_2015_ROOT_KEYWORDS,
294
+ 'wkt2:2019': WKT2_2019_ROOT_KEYWORDS
295
+ };
296
+
297
+ const PROFILE_KEYWORDS: Record<WKTCRSProfile, ReadonlySet<string>> = {
298
+ wkt1: WKT1_KEYWORDS,
299
+ gdal: GDAL_WKT1_KEYWORDS,
300
+ esri: ESRI_WKT1_KEYWORDS,
301
+ 'wkt2:2015': WKT2_2015_KEYWORDS,
302
+ 'wkt2:2019': WKT2_KEYWORDS
303
+ };
304
+
305
+ /** Parse WKT1, WKT2, or a compatible vendor WKT serialization. */
306
+ export function parseWKTCRS(text: string, options?: ParseWKTCRSOptions): WKTCRSAst {
307
+ const parser = new WKTParser(text);
308
+ const ast: WKTCRSAst = {type: 'wkt-crs', root: parser.parse()};
309
+
310
+ if (options?.strict) {
311
+ const issues = validateWKTCRS(ast, {
312
+ profile: options.profile,
313
+ allowExtensions: false
314
+ });
315
+ if (issues.length > 0) {
316
+ throw new WKTCRSValidationError(issues);
317
+ }
318
+ }
319
+
320
+ return ast;
321
+ }
322
+
323
+ /** Encode a WKT syntax tree without changing value lexemes or keyword spelling. */
324
+ export function encodeWKTCRS(ast: WKTCRSAst, options?: EncodeWKTCRSOptions): string {
325
+ if (!ast || ast.type !== 'wkt-crs' || ast.root?.type !== 'node') {
326
+ throw new TypeError('encodeWKTCRS expects a WKTCRSAst');
327
+ }
328
+ const format = options?.format || 'compact';
329
+ const indent = options?.indent ?? 2;
330
+ if (!Number.isInteger(indent) || indent < 0) {
331
+ throw new RangeError('WKT indentation must be a non-negative integer');
332
+ }
333
+ validateEncodableNode(ast.root);
334
+ return encodeNode(ast.root, format, indent, 0);
335
+ }
336
+
337
+ /** Validate root keywords, known profile keywords, and unambiguous value shapes. */
338
+ export function validateWKTCRS(
339
+ ast: WKTCRSAst,
340
+ options?: ValidateWKTCRSOptions
341
+ ): WKTCRSValidationIssue[] {
342
+ if (!ast || ast.type !== 'wkt-crs' || ast.root?.type !== 'node') {
343
+ throw new TypeError('validateWKTCRS expects a WKTCRSAst');
344
+ }
345
+
346
+ const profile = resolveProfile(ast.root.keyword, options?.profile || 'auto');
347
+ const rootKeywords = PROFILE_ROOT_KEYWORDS[profile];
348
+ const knownKeywords = PROFILE_KEYWORDS[profile];
349
+ const issues: WKTCRSValidationIssue[] = [];
350
+ const rootKeyword = ast.root.keyword.toUpperCase();
351
+
352
+ if (!rootKeywords.has(rootKeyword)) {
353
+ issues.push({
354
+ code: 'invalid-root',
355
+ message: `${ast.root.keyword} is not a ${profile} root keyword`,
356
+ path: [],
357
+ keyword: ast.root.keyword
358
+ });
359
+ }
360
+
361
+ visitNode(ast.root, [], node => {
362
+ const keyword = node.node.keyword.toUpperCase();
363
+ if (!options?.allowExtensions && !knownKeywords.has(keyword)) {
364
+ issues.push({
365
+ code: 'unknown-keyword',
366
+ message: `${node.node.keyword} is not defined by the ${profile} profile`,
367
+ path: node.path,
368
+ keyword: node.node.keyword
369
+ });
370
+ }
371
+ if (node.node.values.length === 0) {
372
+ issues.push({
373
+ code: 'empty-node',
374
+ message: `${node.node.keyword} must contain at least one value`,
375
+ path: node.path,
376
+ keyword: node.node.keyword
377
+ });
378
+ }
379
+ const grammarMessage = validateNodeValues(node.node);
380
+ if (grammarMessage) {
381
+ issues.push({
382
+ code: 'invalid-value',
383
+ message: grammarMessage,
384
+ path: node.path,
385
+ keyword: node.node.keyword
386
+ });
387
+ }
388
+ });
389
+
390
+ return issues;
391
+ }
392
+
393
+ class WKTParser {
394
+ private readonly source: string;
395
+ private offset = 0;
396
+
397
+ constructor(source: string) {
398
+ this.source = source;
399
+ }
400
+
401
+ parse(): WKTCRSNode {
402
+ this.skipWhitespace();
403
+ const root = this.parseNode();
404
+ this.skipWhitespace();
405
+ if (this.offset !== this.source.length) {
406
+ this.fail('Unexpected content after WKT root');
407
+ }
408
+ return root;
409
+ }
410
+
411
+ private parseNode(): WKTCRSNode {
412
+ const keyword = this.parseKeyword();
413
+ return this.parseNodeAfterKeyword(keyword);
414
+ }
415
+
416
+ private parseNodeAfterKeyword(keyword: string): WKTCRSNode {
417
+ this.skipWhitespace();
418
+ const opening = this.source[this.offset];
419
+ if (opening !== '[' && opening !== '(') {
420
+ this.fail(`Expected '[' or '(' after ${keyword}`);
421
+ }
422
+ this.offset++;
423
+ const delimiter: WKTCRSDelimiter = opening === '[' ? 'bracket' : 'parenthesis';
424
+ const closing = opening === '[' ? ']' : ')';
425
+ const values: WKTCRSValue[] = [];
426
+ this.skipWhitespace();
427
+
428
+ if (this.source[this.offset] === closing) {
429
+ this.offset++;
430
+ return {type: 'node', keyword, delimiter, values};
431
+ }
432
+
433
+ while (this.offset < this.source.length) {
434
+ values.push(this.parseValue());
435
+ this.skipWhitespace();
436
+ const character = this.source[this.offset];
437
+ if (character === ',') {
438
+ this.offset++;
439
+ this.skipWhitespace();
440
+ if (this.source[this.offset] === closing) {
441
+ this.fail('Expected a WKT value after comma');
442
+ }
443
+ continue;
444
+ }
445
+ if (character === closing) {
446
+ this.offset++;
447
+ return {type: 'node', keyword, delimiter, values};
448
+ }
449
+ if (character === ']' || character === ')') {
450
+ this.fail(`Expected '${closing}' to close ${keyword}`);
451
+ }
452
+ this.fail(`Expected ',' or '${closing}' in ${keyword}`);
453
+ }
454
+
455
+ this.fail(`Unterminated ${keyword} node`);
456
+ }
457
+
458
+ private parseValue(): WKTCRSValue {
459
+ this.skipWhitespace();
460
+ const character = this.source[this.offset];
461
+ if (character === '"') {
462
+ return this.parseString();
463
+ }
464
+
465
+ const start = this.offset;
466
+ while (this.offset < this.source.length && !/[\s,\[\]()]/.test(this.source[this.offset])) {
467
+ this.offset++;
468
+ }
469
+ if (start === this.offset) {
470
+ this.fail('Expected a WKT value');
471
+ }
472
+ const value = this.source.slice(start, this.offset);
473
+ this.skipWhitespace();
474
+ if (this.source[this.offset] === '[' || this.source[this.offset] === '(') {
475
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(value)) {
476
+ this.fail(`Invalid WKT keyword '${value}'`);
477
+ }
478
+ return this.parseNodeAfterKeyword(value);
479
+ }
480
+ if (NUMBER_PATTERN.test(value)) {
481
+ return {type: 'number', value: Number(value), raw: value};
482
+ }
483
+ return {type: 'enumeration', value};
484
+ }
485
+
486
+ private parseKeyword(): string {
487
+ const match = this.source.slice(this.offset).match(KEYWORD_PATTERN);
488
+ if (!match) {
489
+ this.fail('Expected a WKT keyword');
490
+ }
491
+ this.offset += match[0].length;
492
+ return match[0];
493
+ }
494
+
495
+ private parseString(): WKTCRSString {
496
+ this.offset++;
497
+ let value = '';
498
+ while (this.offset < this.source.length) {
499
+ const character = this.source[this.offset++];
500
+ if (character === '"') {
501
+ if (this.source[this.offset] === '"') {
502
+ value += '"';
503
+ this.offset++;
504
+ continue;
505
+ }
506
+ return {type: 'string', value};
507
+ }
508
+ value += character;
509
+ }
510
+ this.fail('Unterminated WKT string');
511
+ }
512
+
513
+ private skipWhitespace(): void {
514
+ while (this.offset < this.source.length && /\s/.test(this.source[this.offset])) {
515
+ this.offset++;
516
+ }
517
+ }
518
+
519
+ private fail(message: string): never {
520
+ throw new WKTCRSSyntaxError(message, this.source, this.offset);
521
+ }
522
+ }
523
+
524
+ function encodeNode(
525
+ node: WKTCRSNode,
526
+ format: 'compact' | 'pretty',
527
+ indent: number,
528
+ depth: number
529
+ ): string {
530
+ const opening = node.delimiter === 'bracket' ? '[' : '(';
531
+ const closing = node.delimiter === 'bracket' ? ']' : ')';
532
+ if (node.values.length === 0) {
533
+ return `${node.keyword}${opening}${closing}`;
534
+ }
535
+ if (format === 'compact') {
536
+ return `${node.keyword}${opening}${node.values
537
+ .map(value => encodeValue(value, format, indent, depth + 1))
538
+ .join(',')}${closing}`;
539
+ }
540
+ const indentation = ' '.repeat(indent * (depth + 1));
541
+ const closingIndentation = ' '.repeat(indent * depth);
542
+ const values = node.values
543
+ .map(value => `${indentation}${encodeValue(value, format, indent, depth + 1)}`)
544
+ .join(',\n');
545
+ return `${node.keyword}${opening}\n${values}\n${closingIndentation}${closing}`;
546
+ }
547
+
548
+ function encodeValue(
549
+ value: WKTCRSValue,
550
+ format: 'compact' | 'pretty',
551
+ indent: number,
552
+ depth: number
553
+ ): string {
554
+ switch (value.type) {
555
+ case 'node':
556
+ return encodeNode(value, format, indent, depth);
557
+ case 'string':
558
+ return `"${value.value.replace(/"/g, '""')}"`;
559
+ case 'number':
560
+ return value.raw;
561
+ case 'enumeration':
562
+ return value.value;
563
+ default:
564
+ throw new TypeError('Invalid WKT value type');
565
+ }
566
+ }
567
+
568
+ function validateEncodableNode(node: WKTCRSNode): void {
569
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(node.keyword)) {
570
+ throw new TypeError(`Invalid WKT keyword: ${node.keyword}`);
571
+ }
572
+ if (node.delimiter !== 'bracket' && node.delimiter !== 'parenthesis') {
573
+ throw new TypeError(`Invalid WKT delimiter: ${node.delimiter}`);
574
+ }
575
+ if (!Array.isArray(node.values)) {
576
+ throw new TypeError(`Invalid WKT values for ${node.keyword}`);
577
+ }
578
+ for (const value of node.values) {
579
+ if (!value || typeof value !== 'object') {
580
+ throw new TypeError(`Invalid WKT value in ${node.keyword}`);
581
+ }
582
+ switch (value.type) {
583
+ case 'node':
584
+ validateEncodableNode(value);
585
+ break;
586
+ case 'string':
587
+ if (typeof value.value !== 'string') {
588
+ throw new TypeError(`Invalid WKT string in ${node.keyword}`);
589
+ }
590
+ break;
591
+ case 'number':
592
+ if (typeof value.raw !== 'string' || !NUMBER_PATTERN.test(value.raw)) {
593
+ throw new TypeError(`Invalid WKT number lexeme: ${value.raw}`);
594
+ }
595
+ break;
596
+ case 'enumeration':
597
+ if (typeof value.value !== 'string' || !value.value || /[\s,\[\]()"]/.test(value.value)) {
598
+ throw new TypeError(`Invalid WKT enumeration: ${value.value}`);
599
+ }
600
+ break;
601
+ default:
602
+ throw new TypeError(`Invalid WKT value in ${node.keyword}`);
603
+ }
604
+ }
605
+ }
606
+
607
+ function resolveProfile(rootKeyword: string, profile: WKTCRSProfile | 'auto'): WKTCRSProfile {
608
+ if (profile !== 'auto') {
609
+ return profile;
610
+ }
611
+ return WKT1_ROOT_KEYWORDS.has(rootKeyword.toUpperCase()) ? 'wkt1' : 'wkt2:2019';
612
+ }
613
+
614
+ function validateNodeValues(node: WKTCRSNode): string | null {
615
+ const keyword = node.keyword.toUpperCase();
616
+ const values = node.values;
617
+ const typeAt = (index: number, ...types: WKTCRSValue['type'][]) =>
618
+ Boolean(values[index] && types.includes(values[index].type));
619
+ const exact = (length: number, ...types: WKTCRSValue['type'][]) =>
620
+ values.length === length && types.every((type, index) => typeAt(index, type));
621
+
622
+ if (
623
+ [
624
+ 'ANGLEUNIT',
625
+ 'LENGTHUNIT',
626
+ 'PARAMETRICUNIT',
627
+ 'SCALEUNIT',
628
+ 'TIMEUNIT',
629
+ 'TEMPORALQUANTITY',
630
+ 'UNIT'
631
+ ].includes(keyword) &&
632
+ !(values.length >= 2 && typeAt(0, 'string') && typeAt(1, 'number'))
633
+ ) {
634
+ return `${node.keyword} must start with a quoted name and numeric conversion factor`;
635
+ }
636
+ if (keyword === 'CS' && !exact(2, 'enumeration', 'number')) {
637
+ return `${node.keyword} must contain a coordinate-system type and dimension`;
638
+ }
639
+ if (keyword === 'BBOX' && !exact(4, 'number', 'number', 'number', 'number')) {
640
+ return `${node.keyword} must contain four numeric bounds`;
641
+ }
642
+ if (
643
+ [
644
+ 'ENSEMBLEACCURACY',
645
+ 'EPOCH',
646
+ 'COORDEPOCH',
647
+ 'FRAMEEPOCH',
648
+ 'OPERATIONACCURACY',
649
+ 'ORDER'
650
+ ].includes(keyword) &&
651
+ !exact(1, 'number')
652
+ ) {
653
+ return `${node.keyword} must contain one number`;
654
+ }
655
+ if (
656
+ ['ANCHOR', 'AREA', 'CALENDAR', 'CITATION', 'REMARK', 'SCOPE', 'URI', 'VERSION'].includes(
657
+ keyword
658
+ ) &&
659
+ !exact(1, 'string')
660
+ ) {
661
+ return `${node.keyword} must contain one quoted string`;
662
+ }
663
+ if (
664
+ ['ELLIPSOID', 'SPHEROID', 'TRIAXIAL'].includes(keyword) &&
665
+ !(values.length >= 3 && typeAt(0, 'string') && typeAt(1, 'number') && typeAt(2, 'number'))
666
+ ) {
667
+ return `${node.keyword} must start with a quoted name and numeric ellipsoid axes`;
668
+ }
669
+ if (
670
+ ['PARAMETER'].includes(keyword) &&
671
+ !(values.length >= 2 && typeAt(0, 'string') && typeAt(1, 'number'))
672
+ ) {
673
+ return `${node.keyword} must start with a quoted name and numeric value`;
674
+ }
675
+ if (keyword === 'PARAMETERFILE' && !exact(2, 'string', 'string')) {
676
+ return `${node.keyword} must contain a quoted name and file name`;
677
+ }
678
+ if (
679
+ ['AUTHORITY', 'ID'].includes(keyword) &&
680
+ !(values.length >= 2 && typeAt(0, 'string') && typeAt(1, 'string', 'number'))
681
+ ) {
682
+ return `${node.keyword} must start with a quoted authority and identifier`;
683
+ }
684
+ if (
685
+ ['AXIS'].includes(keyword) &&
686
+ !(values.length >= 2 && typeAt(0, 'string') && typeAt(1, 'enumeration'))
687
+ ) {
688
+ return `${node.keyword} must start with a quoted name and axis direction`;
689
+ }
690
+ if (['METHOD', 'PROJECTION'].includes(keyword) && !(values.length >= 1 && typeAt(0, 'string'))) {
691
+ return `${node.keyword} must start with a quoted name`;
692
+ }
693
+ if (keyword === 'TIMEEXTENT') {
694
+ const isTemporalValue = (index: number) => typeAt(index, 'enumeration', 'string');
695
+ if (values.length !== 2 || !isTemporalValue(0) || !isTemporalValue(1)) {
696
+ return `${node.keyword} must contain two date-time or quoted values`;
697
+ }
698
+ }
699
+ if (keyword === 'VERTICALEXTENT') {
700
+ if (
701
+ values.length < 2 ||
702
+ values.length > 3 ||
703
+ !typeAt(0, 'number') ||
704
+ !typeAt(1, 'number') ||
705
+ (values.length === 3 && !typeAt(2, 'node'))
706
+ ) {
707
+ return `${node.keyword} must contain two numeric bounds and an optional unit`;
708
+ }
709
+ }
710
+ if (keyword === 'COORDINATEMETADATA') {
711
+ if (
712
+ values.length < 1 ||
713
+ values.length > 2 ||
714
+ !typeAt(0, 'node') ||
715
+ (values.length === 2 &&
716
+ (!typeAt(1, 'node') ||
717
+ !['EPOCH', 'COORDEPOCH'].includes((values[1] as WKTCRSNode).keyword.toUpperCase())))
718
+ ) {
719
+ return `${node.keyword} must contain a CRS and an optional coordinate epoch`;
720
+ }
721
+ }
722
+ if (keyword === 'BOUNDCRS') {
723
+ const childKeywords = values
724
+ .slice(0, 3)
725
+ .map(value => (value.type === 'node' ? value.keyword.toUpperCase() : ''));
726
+ if (
727
+ childKeywords[0] !== 'SOURCECRS' ||
728
+ childKeywords[1] !== 'TARGETCRS' ||
729
+ childKeywords[2] !== 'ABRIDGEDTRANSFORMATION'
730
+ ) {
731
+ return `${node.keyword} must start with SOURCECRS, TARGETCRS, and ABRIDGEDTRANSFORMATION`;
732
+ }
733
+ }
734
+ if (
735
+ [
736
+ 'COMPOUNDCRS',
737
+ 'CONCATENATEDOPERATION',
738
+ 'COORDINATEOPERATION',
739
+ 'DERIVEDPROJCRS',
740
+ 'ENGCRS',
741
+ 'ENGINEERINGCRS',
742
+ 'GEODCRS',
743
+ 'GEODETICCRS',
744
+ 'GEOGCRS',
745
+ 'GEOGRAPHICCRS',
746
+ 'PARAMETRICCRS',
747
+ 'POINTMOTIONOPERATION',
748
+ 'PROJCRS',
749
+ 'PROJECTEDCRS',
750
+ 'TIMECRS',
751
+ 'VERTCRS',
752
+ 'VERTICALCRS'
753
+ ].includes(keyword) &&
754
+ !typeAt(0, 'string')
755
+ ) {
756
+ return `${node.keyword} must start with a quoted name`;
757
+ }
758
+ return null;
759
+ }
760
+
761
+ function visitNode(
762
+ node: WKTCRSNode,
763
+ path: number[],
764
+ visitor: (entry: {node: WKTCRSNode; path: number[]}) => void
765
+ ): void {
766
+ visitor({node, path});
767
+ for (let index = 0; index < node.values.length; index++) {
768
+ const value = node.values[index];
769
+ if (value.type === 'node') {
770
+ visitNode(value, [...path, index], visitor);
771
+ }
772
+ }
773
+ }
774
+
775
+ function getSourceLocation(source: string, offset: number): {line: number; column: number} {
776
+ let line = 1;
777
+ let column = 1;
778
+ for (let index = 0; index < offset && index < source.length; index++) {
779
+ if (source[index] === '\n') {
780
+ line++;
781
+ column = 1;
782
+ } else {
783
+ column++;
784
+ }
785
+ }
786
+ return {line, column};
787
+ }