mapshaper 0.6.121 → 0.7.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.
Files changed (86) hide show
  1. package/README.md +5 -3
  2. package/mapshaper.js +1342 -275
  3. package/package.json +5 -2
  4. package/www/assets/jetbrains-mono-regular.woff2 +0 -0
  5. package/www/assets/static-page.css +179 -0
  6. package/www/docs/_assets/cmd-search.js +213 -0
  7. package/www/docs/_assets/docs.css +712 -0
  8. package/www/docs/_assets/docs.js +75 -0
  9. package/www/docs/_assets/highlight.css +10 -0
  10. package/www/docs/essentials/command-line.html +127 -0
  11. package/www/docs/essentials/command-line.html.md +112 -0
  12. package/www/docs/essentials/web-app.html +138 -0
  13. package/www/docs/essentials/web-app.html.md +106 -0
  14. package/www/docs/examples/basics.html +276 -0
  15. package/www/docs/examples/basics.html.md +371 -0
  16. package/www/docs/examples/data/Makefile +31 -0
  17. package/www/docs/examples/data/globe.msx +0 -0
  18. package/www/docs/examples/data/globe.svg +616 -0
  19. package/www/docs/examples/data/globe.txt +21 -0
  20. package/www/docs/examples/data/globe.zip +0 -0
  21. package/www/docs/examples/data/ne_50m_admin_0_countries.geojson +1 -0
  22. package/www/docs/examples/data/ne_50m_admin_1_states_provinces_lakes.geojson +1 -0
  23. package/www/docs/examples/data/us-states.msx +0 -0
  24. package/www/docs/examples/data/us-states.svg +56 -0
  25. package/www/docs/examples/data/us-states.txt +6 -0
  26. package/www/docs/examples/data/us-states.zip +0 -0
  27. package/www/docs/examples/globe.html +108 -0
  28. package/www/docs/examples/globe.html.md +64 -0
  29. package/www/docs/examples/us-states.html +88 -0
  30. package/www/docs/examples/us-states.html.md +44 -0
  31. package/www/docs/formats/csv.html +127 -0
  32. package/www/docs/formats/csv.html.md +97 -0
  33. package/www/docs/formats/dbf.html +87 -0
  34. package/www/docs/formats/dbf.html.md +39 -0
  35. package/www/docs/formats/flatgeobuf.html +86 -0
  36. package/www/docs/formats/flatgeobuf.html.md +42 -0
  37. package/www/docs/formats/geojson.html +107 -0
  38. package/www/docs/formats/geojson.html.md +65 -0
  39. package/www/docs/formats/geopackage.html +87 -0
  40. package/www/docs/formats/geopackage.html.md +42 -0
  41. package/www/docs/formats/json.html +83 -0
  42. package/www/docs/formats/json.html.md +35 -0
  43. package/www/docs/formats/kml.html +82 -0
  44. package/www/docs/formats/kml.html.md +39 -0
  45. package/www/docs/formats/overview.html +192 -0
  46. package/www/docs/formats/overview.html.md +35 -0
  47. package/www/docs/formats/shapefile.html +123 -0
  48. package/www/docs/formats/shapefile.html.md +84 -0
  49. package/www/docs/formats/snapshot.html +87 -0
  50. package/www/docs/formats/snapshot.html.md +39 -0
  51. package/www/docs/formats/svg.html +99 -0
  52. package/www/docs/formats/svg.html.md +51 -0
  53. package/www/docs/formats/topojson.html +102 -0
  54. package/www/docs/formats/topojson.html.md +54 -0
  55. package/www/docs/gallery/index.html +80 -0
  56. package/www/docs/gallery/index.html.md +29 -0
  57. package/www/docs/guides/combining-layers.html +105 -0
  58. package/www/docs/guides/combining-layers.html.md +81 -0
  59. package/www/docs/guides/expressions.html +600 -0
  60. package/www/docs/guides/expressions.html.md +376 -0
  61. package/www/docs/guides/programmatic.html +117 -0
  62. package/www/docs/guides/programmatic.html.md +91 -0
  63. package/www/docs/guides/projections.html +158 -0
  64. package/www/docs/guides/projections.html.md +118 -0
  65. package/www/docs/guides/simplification.html +110 -0
  66. package/www/docs/guides/simplification.html.md +94 -0
  67. package/www/docs/guides/topology.html +90 -0
  68. package/www/docs/guides/topology.html.md +63 -0
  69. package/www/docs/images/simplification-detail.png +0 -0
  70. package/www/docs/images/simplification-dp.png +0 -0
  71. package/www/docs/images/simplification-mod2.png +0 -0
  72. package/www/docs/index.html +101 -0
  73. package/www/docs/index.html.md +59 -0
  74. package/www/docs/reference.html +1302 -0
  75. package/www/docs/reference.html.md +1817 -0
  76. package/www/docs/whats-new.html +76 -0
  77. package/www/docs/whats-new.html.md +53 -0
  78. package/www/index.html +30 -3
  79. package/www/llms-full.txt +4040 -0
  80. package/www/llms.txt +55 -0
  81. package/www/mapshaper-gui.js +7 -1
  82. package/www/mapshaper.js +1342 -275
  83. package/www/page.css +1 -1
  84. package/www/privacy.html +1 -112
  85. package/www/sponsor.html +4 -164
  86. package/www/terms.html +1 -112
package/www/mapshaper.js CHANGED
@@ -1269,6 +1269,10 @@
1269
1269
  LOGGING = true;
1270
1270
  }
1271
1271
 
1272
+ function disableLogging() {
1273
+ LOGGING = false;
1274
+ }
1275
+
1272
1276
  function loggingEnabled() {
1273
1277
  return !!LOGGING;
1274
1278
  }
@@ -1475,6 +1479,7 @@
1475
1479
  NonFatalError: NonFatalError,
1476
1480
  UserError: UserError,
1477
1481
  debug: debug,
1482
+ disableLogging: disableLogging,
1478
1483
  enableLogging: enableLogging,
1479
1484
  error: error,
1480
1485
  formatColumns: formatColumns,
@@ -5499,6 +5504,58 @@
5499
5504
  return parseCrsString$1(wkt1ToProj(str));
5500
5505
  }
5501
5506
 
5507
+ // Extract an EPSG (or other authority) code from a short string like
5508
+ // "epsg:4326" or "ESRI:54030". Returns {org, code} or null.
5509
+ function parseAuthorityCodeString(str) {
5510
+ if (!str || typeof str != 'string') return null;
5511
+ var match = str.match(/^([a-z]+):(\d+)$/i);
5512
+ if (!match) return null;
5513
+ var code = +match[2];
5514
+ if (!code) return null;
5515
+ return {
5516
+ org: match[1].toUpperCase(),
5517
+ code: code
5518
+ };
5519
+ }
5520
+
5521
+ // Extract the top-level AUTHORITY clause from a WKT1 .prj string.
5522
+ // Returns {org, code} or null. Skips nested AUTHORITY clauses on inner
5523
+ // elements like the datum or unit, which would otherwise produce the
5524
+ // wrong code for projected CRSes.
5525
+ function parseAuthorityCodeFromWkt(wkt) {
5526
+ if (!wkt || typeof wkt != 'string') return null;
5527
+ var depth = 0;
5528
+ var inQuote = false;
5529
+ for (var i = 0; i < wkt.length; i++) {
5530
+ var c = wkt.charAt(i);
5531
+ if (c == '"') {
5532
+ inQuote = !inQuote;
5533
+ continue;
5534
+ }
5535
+ if (inQuote) continue;
5536
+ if (c == '[' || c == '(') {
5537
+ depth++;
5538
+ continue;
5539
+ }
5540
+ if (c == ']' || c == ')') {
5541
+ depth--;
5542
+ continue;
5543
+ }
5544
+ if (depth != 1) continue;
5545
+ var slice = wkt.slice(i, i + 10).toUpperCase();
5546
+ if (slice == 'AUTHORITY[' || slice.slice(0, 10) == 'AUTHORITY(') {
5547
+ var match = wkt.slice(i).match(/^AUTHORITY\s*[[(]\s*"([^"]+)"\s*,\s*"?(\d+)"?\s*[\])]/i);
5548
+ if (match) {
5549
+ return {
5550
+ org: String(match[1]).toUpperCase(),
5551
+ code: +match[2]
5552
+ };
5553
+ }
5554
+ }
5555
+ }
5556
+ return null;
5557
+ }
5558
+
5502
5559
  var Projections = /*#__PURE__*/Object.freeze({
5503
5560
  __proto__: null,
5504
5561
  crsAreEqual: crsAreEqual,
@@ -5522,6 +5579,8 @@
5522
5579
  isWGS84: isWGS84,
5523
5580
  isWebMercator: isWebMercator,
5524
5581
  looksLikeProj4String: looksLikeProj4String,
5582
+ parseAuthorityCodeFromWkt: parseAuthorityCodeFromWkt,
5583
+ parseAuthorityCodeString: parseAuthorityCodeString,
5525
5584
  parseCrsString: parseCrsString$1,
5526
5585
  parsePrj: parsePrj,
5527
5586
  printProjections: printProjections,
@@ -11928,6 +11987,39 @@
11928
11987
  return /^\s*[{[]/.test(String(str));
11929
11988
  }
11930
11989
 
11990
+ // Heuristic: detect inline comma-delimited data passed as an -i argument.
11991
+ // Required signals (intentionally strict to avoid false positives on filenames):
11992
+ // 1. Contains a real newline OR the literal escape sequence "\n"
11993
+ // 2. The first and second non-empty lines each contain at least one comma
11994
+ // Multi-character delimiters (tab, semicolon, pipe) are not detected here;
11995
+ // only comma-delimited input is supported as inline data for now.
11996
+ function stringLooksLikeCsv(str) {
11997
+ if (typeof str !== 'string' || str.length === 0) return false;
11998
+ if (!stringHasInlineCsvNewline(str)) return false;
11999
+ var normalized = unescapeInlineCsv(str);
12000
+ var lines = normalized.split(/\r?\n/).filter(function(line) {
12001
+ return line.length > 0;
12002
+ });
12003
+ if (lines.length < 2) return false;
12004
+ return lines[0].indexOf(',') > -1 && lines[1].indexOf(',') > -1;
12005
+ }
12006
+
12007
+ // True if @str contains either a real newline or the literal two-character
12008
+ // escape sequence "\n" (backslash + n) anywhere in the string.
12009
+ function stringHasInlineCsvNewline(str) {
12010
+ return str.indexOf('\n') > -1 || /\\n/.test(str);
12011
+ }
12012
+
12013
+ // Convert literal "\n" / "\r\n" escape sequences in an inline CSV string
12014
+ // into real newline characters. If the input already contains a real newline,
12015
+ // it is returned unchanged so that backslash-n sequences inside quoted cells
12016
+ // are preserved verbatim.
12017
+ function unescapeInlineCsv(str) {
12018
+ if (typeof str !== 'string') return str;
12019
+ if (str.indexOf('\n') > -1) return str;
12020
+ return str.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
12021
+ }
12022
+
11931
12023
  function stringLooksLikeKML(str) {
11932
12024
  str = String(str);
11933
12025
  return str.includes('<kml ') && str.includes('xmlns="http://www.opengis.net/kml/');
@@ -11960,6 +12052,29 @@
11960
12052
  return file.endsWith('.' + PACKAGE_EXT);
11961
12053
  }
11962
12054
 
12055
+ // Returns true if @file has an extension that may identify a mapshaper
12056
+ // command file (e.g. "commands.txt"). Detection still requires a content
12057
+ // sniff via stringLooksLikeCommandFile().
12058
+ function isPotentialCommandFile(file) {
12059
+ var ext = getFileExtension(file || '').toLowerCase();
12060
+ return ext === 'txt';
12061
+ }
12062
+
12063
+ // True if @str looks like the content of a mapshaper command file: the first
12064
+ // non-blank, non-comment line begins with the magic word "mapshaper".
12065
+ function stringLooksLikeCommandFile(str) {
12066
+ str = String(str || '');
12067
+ // Skip a leading BOM
12068
+ if (str.charCodeAt(0) === 0xFEFF) str = str.slice(1);
12069
+ var lines = str.split(/\r?\n/);
12070
+ for (var i = 0; i < lines.length; i++) {
12071
+ var line = lines[i].trim();
12072
+ if (!line || line.charAt(0) === '#') continue;
12073
+ return /^mapshaper(\s|$)/.test(line);
12074
+ }
12075
+ return false;
12076
+ }
12077
+
11963
12078
  function isZipFile(file) {
11964
12079
  return /\.zip$/i.test(file);
11965
12080
  }
@@ -12004,13 +12119,18 @@
12004
12119
  isImportableAsBinary: isImportableAsBinary,
12005
12120
  isKmzFile: isKmzFile,
12006
12121
  isPackageFile: isPackageFile,
12122
+ isPotentialCommandFile: isPotentialCommandFile,
12007
12123
  isSupportedBinaryInputType: isSupportedBinaryInputType,
12008
12124
  isZipFile: isZipFile,
12009
12125
  looksLikeContentFile: looksLikeContentFile,
12010
12126
  looksLikeImportableFile: looksLikeImportableFile,
12127
+ stringHasInlineCsvNewline: stringHasInlineCsvNewline,
12128
+ stringLooksLikeCommandFile: stringLooksLikeCommandFile,
12129
+ stringLooksLikeCsv: stringLooksLikeCsv,
12011
12130
  stringLooksLikeJSON: stringLooksLikeJSON,
12012
12131
  stringLooksLikeKML: stringLooksLikeKML,
12013
- stringLooksLikeSVG: stringLooksLikeSVG
12132
+ stringLooksLikeSVG: stringLooksLikeSVG,
12133
+ unescapeInlineCsv: unescapeInlineCsv
12014
12134
  });
12015
12135
 
12016
12136
  // input: A file path or a buffer
@@ -12258,10 +12378,14 @@
12258
12378
  return files;
12259
12379
  };
12260
12380
 
12261
- // Expand any wildcards.
12381
+ // Expand any wildcards. Inline data strings (JSON / CSV passed directly on
12382
+ // the command line) are passed through unchanged so that "*" appearing
12383
+ // inside the data isn't interpreted as a glob.
12262
12384
  cli.expandInputFiles = function(files) {
12263
12385
  return files.reduce(function(memo, name) {
12264
- if (name.indexOf('*') > -1) {
12386
+ if (stringLooksLikeJSON(name) || stringLooksLikeCsv(name)) {
12387
+ memo.push(name);
12388
+ } else if (name.indexOf('*') > -1) {
12265
12389
  memo = memo.concat(cli.expandFileName(name));
12266
12390
  } else {
12267
12391
  memo.push(name);
@@ -13805,12 +13929,17 @@
13805
13929
  profileStart('intersectSegments');
13806
13930
  var raw = arcs.getVertexData(),
13807
13931
  intersections = [],
13932
+ // opts.limit (optional): stop searching once this many intersections have
13933
+ // been found. Used for cheap "is this dataset clean?" checks where we
13934
+ // only need a small sample.
13935
+ limit = opts.limit > 0 ? opts.limit : 0,
13808
13936
  arr;
13809
13937
  for (i=0; i<stripeCount; i++) {
13810
13938
  arr = intersectSegments(stripes[i], raw.xx, raw.yy, opts);
13811
13939
  for (j=0; j<arr.length; j++) {
13812
13940
  intersections.push(arr[j]);
13813
13941
  }
13942
+ if (limit > 0 && intersections.length >= limit) break;
13814
13943
  }
13815
13944
  profileEnd('intersectSegments');
13816
13945
  profileStart('dedupIntersections');
@@ -15593,7 +15722,7 @@
15593
15722
  if (utils.isObject(obj)) {
15594
15723
  _records[_id] = obj;
15595
15724
  } else {
15596
- stop$1("Can't assign non-object to $.properties");
15725
+ stop$1("Can't assign non-object to this.properties");
15597
15726
  }
15598
15727
  }, get: function() {
15599
15728
  var rec = _records[_id];
@@ -15726,7 +15855,7 @@
15726
15855
  if (!obj || utils.isArray(obj)) {
15727
15856
  lyr.shapes[_id] = obj || null;
15728
15857
  } else {
15729
- stop$1("Can't assign non-array to $.coordinates");
15858
+ stop$1("Can't assign non-array to this.coordinates");
15730
15859
  }
15731
15860
  }, get: function() {
15732
15861
  return lyr.shapes[_id] || null;
@@ -16106,7 +16235,7 @@
16106
16235
 
16107
16236
  // Get a copy of a layer containing a subset of the layer's features,
16108
16237
  // given a "where" expression in the options object
16109
- function getLayerSelection(lyr, arcs, opts) {
16238
+ function getLayerSelection$1(lyr, arcs, opts) {
16110
16239
  var lyr2 = utils.extend({}, lyr);
16111
16240
  var filterOpts = {
16112
16241
  expression: opts.where,
@@ -16123,11 +16252,11 @@
16123
16252
  if (!opts || !opts.where) {
16124
16253
  error('Missing required "where" parameter');
16125
16254
  }
16126
- var subsetLyr = getLayerSelection(lyr, arcs, opts);
16255
+ var subsetLyr = getLayerSelection$1(lyr, arcs, opts);
16127
16256
  var cmdOpts = utils.defaults({where: null}, opts); // prevent infinite recursion
16128
16257
  var outputLyr = commandFunc(subsetLyr, arcs, cmdOpts);
16129
16258
  var filterOpts = utils.defaults({invert: true}, opts);
16130
- var filteredLyr = getLayerSelection(lyr, arcs, filterOpts);
16259
+ var filteredLyr = getLayerSelection$1(lyr, arcs, filterOpts);
16131
16260
  var merged = cmd.mergeLayers([filteredLyr, outputLyr], {verbose: false, force: true});
16132
16261
  return merged[0];
16133
16262
  }
@@ -16147,7 +16276,7 @@
16147
16276
  // Calculate an expression across a group of features, print and return the result
16148
16277
  // Supported functions include sum(), average(), max(), min(), median(), count()
16149
16278
  // Functions receive an expression to be applied to each feature (like the -each command)
16150
- // Examples: 'sum($.area)' 'min(income)'
16279
+ // Examples: 'sum(this.area)' 'min(income)'
16151
16280
  // opts.expression Expression to evaluate
16152
16281
  // opts.where Optional filter expression (see -filter command)
16153
16282
  //
@@ -16156,7 +16285,7 @@
16156
16285
  result, compiled, defs, d;
16157
16286
  if (opts.where) {
16158
16287
  // TODO: implement no_replace option for filter() instead of this
16159
- lyr = getLayerSelection(lyr, arcs, opts);
16288
+ lyr = getLayerSelection$1(lyr, arcs, opts);
16160
16289
  msg += ' where ' + opts.where;
16161
16290
  }
16162
16291
  // Save any assigned variables to the defs object, so they will be available
@@ -16878,208 +17007,6 @@
16878
17007
  dissolvePolygonGeometry: dissolvePolygonGeometry
16879
17008
  });
16880
17009
 
16881
- // Generate a dissolved layer
16882
- // @opts.fields (optional) names of data fields (dissolves all if falsy)
16883
- // @opts.sum-fields (Array) (optional)
16884
- // @opts.copy-fields (Array) (optional)
16885
- //
16886
- cmd.dissolve = function(lyr, arcs, opts) {
16887
- var dissolveShapes, getGroupId;
16888
- opts = utils.extend({}, opts);
16889
- if (opts.where) {
16890
- return applyCommandToLayerSelection(cmd.dissolve, lyr, arcs, opts);
16891
- }
16892
- if (opts.field) opts.fields = [opts.field]; // support old "field" parameter
16893
- getGroupId = getCategoryClassifier(opts.fields, lyr.data);
16894
- if (opts.multipart || opts.group_points) {
16895
- dissolveShapes = makeMultipartShapes(lyr, getGroupId);
16896
- } else if (lyr.geometry_type == 'polygon') {
16897
- dissolveShapes = dissolvePolygonGeometry(lyr.shapes, getGroupId);
16898
- } else if (lyr.geometry_type == 'polyline') {
16899
- dissolveShapes = dissolvePolylineGeometry(lyr, getGroupId, arcs);
16900
- } else if (lyr.geometry_type == 'point') {
16901
- dissolveShapes = dissolvePointGeometry(lyr, getGroupId, opts);
16902
- }
16903
- return composeDissolveLayer(lyr, dissolveShapes, getGroupId, opts);
16904
- };
16905
-
16906
- function makeMultipartShapes(lyr, getGroupId) {
16907
- if (!lyr.shapes || !lyr.geometry_type) {
16908
- stop$1('Layer is missing geometry');
16909
- }
16910
- cloneShapes(lyr.shapes);
16911
- var shapes2 = [];
16912
- lyr.shapes.forEach(function(shp, i) {
16913
- var groupId = getGroupId(i);
16914
- if (!shp) return;
16915
- if (!shapes2[groupId]) {
16916
- shapes2[groupId] = shp;
16917
- } else {
16918
- shapes2[groupId].push.apply(shapes2[groupId], shp);
16919
- }
16920
- });
16921
- return shapes2;
16922
- }
16923
-
16924
- // @lyr: original undissolved layer
16925
- // @shapes: dissolved shapes
16926
- function composeDissolveLayer(lyr, shapes, getGroupId, opts) {
16927
- var records = null;
16928
- var lyr2;
16929
- if (lyr.data) {
16930
- records = aggregateDataRecords(lyr.data.getRecords(), getGroupId, opts);
16931
- // replace missing shapes with nulls
16932
- for (var i=0, n=records.length; i<n; i++) {
16933
- if (shapes && !shapes[i]) {
16934
- shapes[i] = null;
16935
- }
16936
- }
16937
- }
16938
- lyr2 = {
16939
- name: opts.no_replace ? null : lyr.name,
16940
- shapes: shapes,
16941
- data: records ? new DataTable(records) : null,
16942
- geometry_type: lyr.geometry_type
16943
- };
16944
- if (!opts.silent) {
16945
- printDissolveMessage(lyr, lyr2);
16946
- }
16947
- return lyr2;
16948
- }
16949
-
16950
- function printDissolveMessage(pre, post) {
16951
- var n1 = getFeatureCount(pre),
16952
- n2 = getFeatureCount(post),
16953
- msg = utils.format('Dissolved %,d feature%s into %,d feature%s',
16954
- n1, utils.pluralSuffix(n1), n2,
16955
- utils.pluralSuffix(n2));
16956
- message(msg);
16957
- }
16958
-
16959
- // Maps tile ids to shape ids (both are non-negative integers). Supports
16960
- // one-to-many mapping (a tile may belong to multiple shapes)
16961
- // Also maps shape ids to tile ids. A shape may contain multiple tiles
16962
- // Also supports 'flattening' -- removing one-to-many tile-shape mappings by
16963
- // removing all but one shape from a tile.
16964
- // Supports one-to-many mapping
16965
- function TileShapeIndex(mosaic, opts) {
16966
- // indexes for mapping tile ids to shape ids
16967
- var singleIndex = new Int32Array(mosaic.length);
16968
- utils.initializeArray(singleIndex, -1);
16969
- var multipleIndex = [];
16970
- // index that maps shape ids to tile ids
16971
- var shapeIndex = [];
16972
-
16973
- this.getTileIdsByShapeId = function(shapeId) {
16974
- var ids = shapeIndex[shapeId];
16975
- // need to filter out tile ids that have been set to -1 (indicating removal)
16976
- return ids ? ids.filter(function(id) {return id >= 0;}) : [];
16977
- };
16978
-
16979
- // assumes index has been flattened
16980
- this.getShapeIdByTileId = function(id) {
16981
- var shapeId = singleIndex[id];
16982
- return shapeId >= 0 ? shapeId : -1;
16983
- };
16984
-
16985
- // return ids of all shapes that include a tile
16986
- this.getShapeIdsByTileId = function(id) {
16987
- var singleId = singleIndex[id];
16988
- if (singleId >= 0) {
16989
- return [singleId];
16990
- }
16991
- if (singleId == -1) {
16992
- return [];
16993
- }
16994
- return multipleIndex[id];
16995
- };
16996
-
16997
- this.indexTileIdsByShapeId = function(shapeId, tileIds, weightFunction) {
16998
- shapeIndex[shapeId] = [];
16999
- for (var i=0; i<tileIds.length; i++) {
17000
- indexShapeIdByTileId(shapeId, tileIds[i], weightFunction);
17001
- }
17002
- };
17003
-
17004
- // remove many-to-one tile=>shape mappings
17005
- this.flatten = function() {
17006
- multipleIndex.forEach(function(shapeIds, tileId) {
17007
- flattenStackedTile(tileId);
17008
- });
17009
- multipleIndex = [];
17010
- };
17011
-
17012
- this.getUnusedTileIds = function() {
17013
- var ids = [];
17014
- for (var i=0, n=singleIndex.length; i<n; i++) {
17015
- if (singleIndex[i] == -1) ids.push(i);
17016
- }
17017
- return ids;
17018
- };
17019
-
17020
- // used by gap fill; assumes that flatten() has been called
17021
- this.addTileToShape = function(shapeId, tileId) {
17022
- if (shapeId in shapeIndex === false || singleIndex[tileId] != -1) {
17023
- error('Internal error');
17024
- }
17025
- singleIndex[tileId] = shapeId;
17026
- shapeIndex[shapeId].push(tileId);
17027
- };
17028
-
17029
- // add a shape id to a tile
17030
- function indexShapeIdByTileId(shapeId, tileId, weightFunction) {
17031
- var singleId = singleIndex[tileId];
17032
- if (singleId != -1 && opts.flat) {
17033
- // pick the best shape if we have a weight function
17034
- if (weightFunction && weightFunction(shapeId) > weightFunction(singleId)) {
17035
- // replace existing shape reference
17036
- removeTileFromShape(tileId, singleId); // bottleneck when overlaps are many
17037
- singleIndex[tileId] = singleId;
17038
- singleId = -1;
17039
- } else {
17040
- // keep existing shape reference
17041
- return;
17042
- }
17043
- }
17044
- if (singleId == -1) {
17045
- singleIndex[tileId] = shapeId;
17046
- } else if (singleId == -2) {
17047
- multipleIndex[tileId].push(shapeId);
17048
- } else {
17049
- multipleIndex[tileId] = [singleId, shapeId];
17050
- singleIndex[tileId] = -2;
17051
- }
17052
- shapeIndex[shapeId].push(tileId);
17053
- }
17054
-
17055
-
17056
- function flattenStackedTile(tileId) {
17057
- // TODO: select the best shape (using some metric)
17058
- var shapeIds = multipleIndex[tileId];
17059
- // if (!shapeIds || shapeIds.length > 1 === false) error('flattening error');
17060
- var selectedId = shapeIds[0];
17061
- var shapeId;
17062
- singleIndex[tileId] = selectedId; // add shape to single index
17063
- // remove tile from other stacked shapes
17064
- for (var i=0; i<shapeIds.length; i++) {
17065
- shapeId = shapeIds[i];
17066
- if (shapeId != selectedId) {
17067
- removeTileFromShape(tileId, shapeId);
17068
- }
17069
- }
17070
- }
17071
-
17072
- function removeTileFromShape(tileId, shapeId) {
17073
- var tileIds = shapeIndex[shapeId];
17074
- for (var i=0; i<tileIds.length; i++) {
17075
- if (tileIds[i] === tileId) {
17076
- tileIds[i] = -1;
17077
- break;
17078
- }
17079
- }
17080
- }
17081
- }
17082
-
17083
17010
  // Clean polygon or polyline shapes (in-place)
17084
17011
  //
17085
17012
  function cleanShapes(shapes, arcs, type) {
@@ -17391,6 +17318,29 @@
17391
17318
  profileStart('snapAndCut');
17392
17319
  var arcs = dataset.arcs;
17393
17320
  var cutOpts = snapDist > 0 ? {tolerance: snapDist} : {tolerance: 0};
17321
+
17322
+ // Probe for intersections before any modification. If the input has none,
17323
+ // every pass of the loop below is provably a no-op: snap() can only
17324
+ // consolidate FP noise around intersection points, cutPathsAtIntersections
17325
+ // inserts cut points only where segments cross, and buildTopology is gated
17326
+ // on coordsHaveChanged. So we can return early and skip a ~O(V log V) snap
17327
+ // that would snap zero points, which is the dominant cost of robust
17328
+ // dissolve on already-clean polygon input.
17329
+ //
17330
+ // limit=1 makes the probe cheap on dirty input too — it stops the stripe
17331
+ // scan after the first hit, so the probe's full cost is only paid on
17332
+ // truly clean input (where it replaces the equivalent scan that
17333
+ // cutPathsAtIntersections would otherwise do on pass 1).
17334
+ var probeOpts = {tolerance: cutOpts.tolerance, limit: 1};
17335
+ profileStart('probeIntersections');
17336
+ var probe = findSegmentIntersections(arcs, probeOpts);
17337
+ profileEnd('probeIntersections');
17338
+ if (probe.length === 0) {
17339
+ debug('[snapAndCut] skipped (no intersections)');
17340
+ profileEnd('snapAndCut');
17341
+ return false;
17342
+ }
17343
+
17394
17344
  var coordsHaveChanged = false;
17395
17345
  var snapCount = 0, dupeCount, cutCount;
17396
17346
  var maxLoops = 4, loopCount = 0;
@@ -17696,6 +17646,336 @@
17696
17646
  sortCutPoints: sortCutPoints
17697
17647
  });
17698
17648
 
17649
+ // Options that require the topology-repair algorithm and are not supported
17650
+ // by the no-repair fast path.
17651
+ var REPAIR_REQUIRED_OPTS = ['gap_fill_area', 'sliver_control', 'allow_overlaps'];
17652
+
17653
+ // Sample size used to detect intersections in no-repair mode. Detection stops
17654
+ // after this many hits, so the warning message can include sample locations
17655
+ // without paying for an exhaustive scan on badly-formed input.
17656
+ var INTERSECTION_SAMPLE_LIMIT = 10;
17657
+
17658
+ // cmd.dissolve accepts two signatures:
17659
+ // (layers, dataset, opts) — multi-layer entry used by the CLI dispatcher.
17660
+ // Polygon layers go through the topology-repairing algorithm by default,
17661
+ // or through the legacy fast algorithm when opts.no_repair is set.
17662
+ // (lyr, arcs, opts) — legacy single-layer entry, retained for backward
17663
+ // compatibility with internal callers and existing tests. Always uses the
17664
+ // legacy fast algorithm; does not perform topology repair.
17665
+ //
17666
+ cmd.dissolve = function(arg1, arg2, opts) {
17667
+ if (Array.isArray(arg1)) {
17668
+ return dissolveLayers(arg1, arg2, opts);
17669
+ }
17670
+ return dissolveSingleLayer(arg1, arg2, opts);
17671
+ };
17672
+
17673
+ function dissolveLayers(layers, dataset, optsArg) {
17674
+ var opts = utils.extend({}, optsArg);
17675
+ if (opts.field) opts.fields = [opts.field]; // support old "field" parameter
17676
+
17677
+ if (opts.no_repair) {
17678
+ var conflicting = REPAIR_REQUIRED_OPTS.filter(function(k) { return opts[k]; });
17679
+ if (conflicting.length > 0) {
17680
+ stop$1('The no-repair option is incompatible with',
17681
+ conflicting.map(function(k) { return k.replace(/_/g, '-'); }).join(', '));
17682
+ }
17683
+ }
17684
+
17685
+ var anyPolygon = layers.some(function(lyr) {
17686
+ return lyr.geometry_type == 'polygon' && layerHasPaths(lyr);
17687
+ });
17688
+
17689
+ if (anyPolygon) {
17690
+ if (opts.no_repair) {
17691
+ detectAndWarnIntersections(dataset, opts);
17692
+ } else {
17693
+ addIntersectionCuts(dataset, opts);
17694
+ }
17695
+ }
17696
+
17697
+ return layers.map(function(lyr) {
17698
+ return dissolveOneLayer(lyr, dataset, opts);
17699
+ });
17700
+ }
17701
+
17702
+ function dissolveOneLayer(lyr, dataset, opts) {
17703
+ if (opts.where) {
17704
+ return dissolveLayerWithWhereClause(lyr, dataset, opts);
17705
+ }
17706
+ if (opts.multipart || opts.group_points) {
17707
+ var classifier = getCategoryClassifier(opts.fields, lyr.data);
17708
+ return composeDissolveLayer(lyr, makeMultipartShapes(lyr, classifier), classifier, opts);
17709
+ }
17710
+ if (lyr.geometry_type == 'polygon') {
17711
+ return dissolvePolygonInLayer(lyr, dataset, opts);
17712
+ }
17713
+ if (lyr.geometry_type == 'polyline') {
17714
+ var polylineClassifier = getCategoryClassifier(opts.fields, lyr.data);
17715
+ var polylineShapes = dissolvePolylineGeometry(lyr, polylineClassifier, dataset.arcs);
17716
+ return composeDissolveLayer(lyr, polylineShapes, polylineClassifier, opts);
17717
+ }
17718
+ if (lyr.geometry_type == 'point') {
17719
+ var pointClassifier = getCategoryClassifier(opts.fields, lyr.data);
17720
+ var pointShapes = dissolvePointGeometry(lyr, pointClassifier, opts);
17721
+ return composeDissolveLayer(lyr, pointShapes, pointClassifier, opts);
17722
+ }
17723
+ // tabular (no geometry): aggregate records only
17724
+ var nullClassifier = getCategoryClassifier(opts.fields, lyr.data);
17725
+ return composeDissolveLayer(lyr, undefined, nullClassifier, opts);
17726
+ }
17727
+
17728
+ function dissolvePolygonInLayer(lyr, dataset, opts) {
17729
+ if (!layerHasPaths(lyr)) return lyr;
17730
+ if (opts.no_repair) {
17731
+ var classifier = getCategoryClassifier(opts.fields, lyr.data);
17732
+ var shapes = dissolvePolygonGeometry(lyr.shapes, classifier);
17733
+ return composeDissolveLayer(lyr, shapes, classifier, opts);
17734
+ }
17735
+ return dissolvePolygonLayer2(lyr, dataset, opts);
17736
+ }
17737
+
17738
+ function dissolveLayerWithWhereClause(lyr, dataset, opts) {
17739
+ // Run dissolve on a subset of features defined by opts.where, then merge the
17740
+ // dissolved subset back together with the unselected features.
17741
+ // Topology repair (if needed) was already performed at the dataset level by
17742
+ // dissolveLayers, so the recursive call uses no_repair=true to avoid doing
17743
+ // the work a second time on a subset of the same arcs.
17744
+ var arcs = dataset.arcs;
17745
+ var subsetLyr = getLayerSelection(lyr, arcs, opts);
17746
+ var cmdOpts = utils.defaults({where: null, no_repair: true}, opts);
17747
+ var dissolved = dissolveOneLayer(subsetLyr, dataset, cmdOpts);
17748
+ var filteredLyr = getLayerSelection(lyr, arcs, utils.defaults({invert: true}, opts));
17749
+ var merged = cmd.mergeLayers([filteredLyr, dissolved], {verbose: false, force: true});
17750
+ return merged[0];
17751
+ }
17752
+
17753
+ function getLayerSelection(lyr, arcs, opts) {
17754
+ var lyr2 = utils.extend({}, lyr);
17755
+ var filterOpts = {
17756
+ expression: opts.where,
17757
+ invert: !!opts.invert,
17758
+ verbose: false,
17759
+ no_replace: opts.no_replace
17760
+ };
17761
+ return cmd.filterFeatures(lyr2, arcs, filterOpts);
17762
+ }
17763
+
17764
+ // Detect a small sample of segment intersections; print a warning if any are
17765
+ // found. Used by the no-repair fast path to alert users that their input has
17766
+ // topology problems. Detection stops after INTERSECTION_SAMPLE_LIMIT hits, so
17767
+ // the cost is bounded for badly-formed input.
17768
+ function detectAndWarnIntersections(dataset, opts) {
17769
+ if (opts.quiet || opts.silent) return;
17770
+ if (!dataset.arcs || dataset.arcs.size() === 0) return;
17771
+ var sample = findSegmentIntersections(dataset.arcs, {limit: INTERSECTION_SAMPLE_LIMIT});
17772
+ if (sample.length === 0) return;
17773
+ var atLeast = sample.length >= INTERSECTION_SAMPLE_LIMIT ? 'at least ' : '';
17774
+ message('Warning: found ' + atLeast + sample.length +
17775
+ ' segment intersection' + (sample.length == 1 ? '' : 's') +
17776
+ '. The no-repair option assumes clean topology; output may be incorrect.');
17777
+ }
17778
+
17779
+ // Backward-compat: the legacy per-layer entry, still used by internal callers
17780
+ // and by tests that exercise the original fast algorithm directly. Retains
17781
+ // the original behavior (no topology repair, no multi-layer prep).
17782
+ function dissolveSingleLayer(lyr, arcs, opts) {
17783
+ var dissolveShapes, classifier;
17784
+ opts = utils.extend({}, opts);
17785
+ if (opts.where) {
17786
+ return applyCommandToLayerSelection(dissolveSingleLayer, lyr, arcs, opts);
17787
+ }
17788
+ if (opts.field) opts.fields = [opts.field];
17789
+ classifier = getCategoryClassifier(opts.fields, lyr.data);
17790
+ if (opts.multipart || opts.group_points) {
17791
+ dissolveShapes = makeMultipartShapes(lyr, classifier);
17792
+ } else if (lyr.geometry_type == 'polygon') {
17793
+ dissolveShapes = dissolvePolygonGeometry(lyr.shapes, classifier);
17794
+ } else if (lyr.geometry_type == 'polyline') {
17795
+ dissolveShapes = dissolvePolylineGeometry(lyr, classifier, arcs);
17796
+ } else if (lyr.geometry_type == 'point') {
17797
+ dissolveShapes = dissolvePointGeometry(lyr, classifier, opts);
17798
+ }
17799
+ return composeDissolveLayer(lyr, dissolveShapes, classifier, opts);
17800
+ }
17801
+
17802
+ function makeMultipartShapes(lyr, getGroupId) {
17803
+ if (!lyr.shapes || !lyr.geometry_type) {
17804
+ stop$1('Layer is missing geometry');
17805
+ }
17806
+ cloneShapes(lyr.shapes);
17807
+ var shapes2 = [];
17808
+ lyr.shapes.forEach(function(shp, i) {
17809
+ var groupId = getGroupId(i);
17810
+ if (!shp) return;
17811
+ if (!shapes2[groupId]) {
17812
+ shapes2[groupId] = shp;
17813
+ } else {
17814
+ shapes2[groupId].push.apply(shapes2[groupId], shp);
17815
+ }
17816
+ });
17817
+ return shapes2;
17818
+ }
17819
+
17820
+ // @lyr: original undissolved layer
17821
+ // @shapes: dissolved shapes
17822
+ function composeDissolveLayer(lyr, shapes, getGroupId, opts) {
17823
+ var records = null;
17824
+ var lyr2;
17825
+ if (lyr.data) {
17826
+ records = aggregateDataRecords(lyr.data.getRecords(), getGroupId, opts);
17827
+ // replace missing shapes with nulls
17828
+ for (var i=0, n=records.length; i<n; i++) {
17829
+ if (shapes && !shapes[i]) {
17830
+ shapes[i] = null;
17831
+ }
17832
+ }
17833
+ }
17834
+ lyr2 = {
17835
+ name: opts.no_replace ? null : lyr.name,
17836
+ shapes: shapes,
17837
+ data: records ? new DataTable(records) : null,
17838
+ geometry_type: lyr.geometry_type
17839
+ };
17840
+ if (!opts.silent) {
17841
+ printDissolveMessage(lyr, lyr2);
17842
+ }
17843
+ return lyr2;
17844
+ }
17845
+
17846
+ function printDissolveMessage(pre, post) {
17847
+ var n1 = getFeatureCount(pre),
17848
+ n2 = getFeatureCount(post),
17849
+ msg = utils.format('Dissolved %,d feature%s into %,d feature%s',
17850
+ n1, utils.pluralSuffix(n1), n2,
17851
+ utils.pluralSuffix(n2));
17852
+ message(msg);
17853
+ }
17854
+
17855
+ // Maps tile ids to shape ids (both are non-negative integers). Supports
17856
+ // one-to-many mapping (a tile may belong to multiple shapes)
17857
+ // Also maps shape ids to tile ids. A shape may contain multiple tiles
17858
+ // Also supports 'flattening' -- removing one-to-many tile-shape mappings by
17859
+ // removing all but one shape from a tile.
17860
+ // Supports one-to-many mapping
17861
+ function TileShapeIndex(mosaic, opts) {
17862
+ // indexes for mapping tile ids to shape ids
17863
+ var singleIndex = new Int32Array(mosaic.length);
17864
+ utils.initializeArray(singleIndex, -1);
17865
+ var multipleIndex = [];
17866
+ // index that maps shape ids to tile ids
17867
+ var shapeIndex = [];
17868
+
17869
+ this.getTileIdsByShapeId = function(shapeId) {
17870
+ var ids = shapeIndex[shapeId];
17871
+ // need to filter out tile ids that have been set to -1 (indicating removal)
17872
+ return ids ? ids.filter(function(id) {return id >= 0;}) : [];
17873
+ };
17874
+
17875
+ // assumes index has been flattened
17876
+ this.getShapeIdByTileId = function(id) {
17877
+ var shapeId = singleIndex[id];
17878
+ return shapeId >= 0 ? shapeId : -1;
17879
+ };
17880
+
17881
+ // return ids of all shapes that include a tile
17882
+ this.getShapeIdsByTileId = function(id) {
17883
+ var singleId = singleIndex[id];
17884
+ if (singleId >= 0) {
17885
+ return [singleId];
17886
+ }
17887
+ if (singleId == -1) {
17888
+ return [];
17889
+ }
17890
+ return multipleIndex[id];
17891
+ };
17892
+
17893
+ this.indexTileIdsByShapeId = function(shapeId, tileIds, weightFunction) {
17894
+ shapeIndex[shapeId] = [];
17895
+ for (var i=0; i<tileIds.length; i++) {
17896
+ indexShapeIdByTileId(shapeId, tileIds[i], weightFunction);
17897
+ }
17898
+ };
17899
+
17900
+ // remove many-to-one tile=>shape mappings
17901
+ this.flatten = function() {
17902
+ multipleIndex.forEach(function(shapeIds, tileId) {
17903
+ flattenStackedTile(tileId);
17904
+ });
17905
+ multipleIndex = [];
17906
+ };
17907
+
17908
+ this.getUnusedTileIds = function() {
17909
+ var ids = [];
17910
+ for (var i=0, n=singleIndex.length; i<n; i++) {
17911
+ if (singleIndex[i] == -1) ids.push(i);
17912
+ }
17913
+ return ids;
17914
+ };
17915
+
17916
+ // used by gap fill; assumes that flatten() has been called
17917
+ this.addTileToShape = function(shapeId, tileId) {
17918
+ if (shapeId in shapeIndex === false || singleIndex[tileId] != -1) {
17919
+ error('Internal error');
17920
+ }
17921
+ singleIndex[tileId] = shapeId;
17922
+ shapeIndex[shapeId].push(tileId);
17923
+ };
17924
+
17925
+ // add a shape id to a tile
17926
+ function indexShapeIdByTileId(shapeId, tileId, weightFunction) {
17927
+ var singleId = singleIndex[tileId];
17928
+ if (singleId != -1 && opts.flat) {
17929
+ // pick the best shape if we have a weight function
17930
+ if (weightFunction && weightFunction(shapeId) > weightFunction(singleId)) {
17931
+ // replace existing shape reference
17932
+ removeTileFromShape(tileId, singleId); // bottleneck when overlaps are many
17933
+ singleIndex[tileId] = singleId;
17934
+ singleId = -1;
17935
+ } else {
17936
+ // keep existing shape reference
17937
+ return;
17938
+ }
17939
+ }
17940
+ if (singleId == -1) {
17941
+ singleIndex[tileId] = shapeId;
17942
+ } else if (singleId == -2) {
17943
+ multipleIndex[tileId].push(shapeId);
17944
+ } else {
17945
+ multipleIndex[tileId] = [singleId, shapeId];
17946
+ singleIndex[tileId] = -2;
17947
+ }
17948
+ shapeIndex[shapeId].push(tileId);
17949
+ }
17950
+
17951
+
17952
+ function flattenStackedTile(tileId) {
17953
+ // TODO: select the best shape (using some metric)
17954
+ var shapeIds = multipleIndex[tileId];
17955
+ // if (!shapeIds || shapeIds.length > 1 === false) error('flattening error');
17956
+ var selectedId = shapeIds[0];
17957
+ var shapeId;
17958
+ singleIndex[tileId] = selectedId; // add shape to single index
17959
+ // remove tile from other stacked shapes
17960
+ for (var i=0; i<shapeIds.length; i++) {
17961
+ shapeId = shapeIds[i];
17962
+ if (shapeId != selectedId) {
17963
+ removeTileFromShape(tileId, shapeId);
17964
+ }
17965
+ }
17966
+ }
17967
+
17968
+ function removeTileFromShape(tileId, shapeId) {
17969
+ var tileIds = shapeIndex[shapeId];
17970
+ for (var i=0; i<tileIds.length; i++) {
17971
+ if (tileIds[i] === tileId) {
17972
+ tileIds[i] = -1;
17973
+ break;
17974
+ }
17975
+ }
17976
+ }
17977
+ }
17978
+
17699
17979
  // Support for timing using T.start() and T.stop()
17700
17980
  var T$1 = {
17701
17981
  stack: [],
@@ -21250,18 +21530,18 @@ ${svg}
21250
21530
  validateSvgDataFields: validateSvgDataFields
21251
21531
  });
21252
21532
 
21253
- // import { isKmzFile } from '../io/mapshaper-file-types';
21254
-
21255
21533
  function exportKML(dataset, opts) {
21256
21534
  var toKML = require$1("@placemarkio/tokml").toKML;
21257
21535
  var geojsonOpts = Object.assign({combine_layers: true, geojson_type: 'FeatureCollection'}, opts);
21258
21536
  var geojson = exportDatasetAsGeoJSON(dataset, geojsonOpts);
21259
21537
  var kml = toKML(geojson);
21260
- // TODO: add KMZ output
21261
- // var useKmz = opts.file && isKmzFile(opts.file);
21262
- var ofile = opts.file || getOutputFileBase(dataset) + '.kml';
21538
+ var useKmz = !!(opts.file && isKmzFile(opts.file));
21539
+ var ofile = opts.file || getOutputFileBase(dataset) + (useKmz ? '.kmz' : '.kml');
21540
+ var content = useKmz
21541
+ ? zipSync([{ filename: 'doc.kml', content: kml }])
21542
+ : kml;
21263
21543
  return [{
21264
- content: kml,
21544
+ content: content,
21265
21545
  filename: ofile
21266
21546
  }];
21267
21547
  }
@@ -24819,13 +25099,19 @@ ${svg}
24819
25099
  // Keep behavior consistent with other exporters that honor explicit output filename.
24820
25100
  extension = getFileExtension(opts.file) || extension;
24821
25101
  }
25102
+ var crsMeta = resolveOutputCRS(dataset);
24822
25103
  return dataset.layers.map(function(lyr) {
24823
25104
  var geojson = getFeatureCollection(lyr, dataset, opts);
24824
25105
  var content = serialize(geojson);
24825
- content = setOutputCRS(content, dataset.info && dataset.info.flatgeobuf_crs);
25106
+ var filename = lyr.name + '.' + extension;
25107
+ if (crsMeta) {
25108
+ content = rewriteHeaderWithCRS(content, crsMeta);
25109
+ } else {
25110
+ message('Wrote', filename, 'without a CRS in the FlatGeobuf header (mapshaper could not derive an EPSG code for this dataset). Downstream tools may misinterpret the coordinates or refuse to load the file.');
25111
+ }
24826
25112
  return {
24827
25113
  content: content,
24828
- filename: lyr.name + '.' + extension
25114
+ filename: filename
24829
25115
  };
24830
25116
  });
24831
25117
  }
@@ -24841,10 +25127,55 @@ ${svg}
24841
25127
  };
24842
25128
  }
24843
25129
 
24844
- function setOutputCRS(content, crs) {
24845
- var crsMeta = normalizeCRS(crs);
24846
- if (!crsMeta) return content;
24847
- return rewriteHeaderWithCRS(content, crsMeta);
25130
+ // Try several strategies to derive an EPSG code for the dataset. Returns
25131
+ // a CRS-meta object suitable for buildHeaderWithCRS(), or null if no
25132
+ // EPSG code could be found. We don't write WKT-only CRSes -- mapshaper
25133
+ // can't reliably round-trip them and many readers ignore the WKT field.
25134
+ function resolveOutputCRS(dataset) {
25135
+ var info = (dataset && dataset.info) || {};
25136
+ var meta;
25137
+
25138
+ // 1. Round-tripped from another FlatGeobuf
25139
+ meta = normalizeCRS(info.flatgeobuf_crs);
25140
+ if (meta) return meta;
25141
+
25142
+ // 2. Round-tripped from a GeoPackage with an EPSG-coded SRS
25143
+ if (info.geopackage_crs &&
25144
+ String(info.geopackage_crs.organization || '').toUpperCase() === 'EPSG') {
25145
+ meta = normalizeCRS({
25146
+ org: 'EPSG',
25147
+ code: info.geopackage_crs.organization_coordsys_id || info.geopackage_crs.srs_id
25148
+ });
25149
+ if (meta) return meta;
25150
+ }
25151
+
25152
+ // 3. Explicit "epsg:NNNN" / "esri:NNNN" string set by -proj or alike
25153
+ meta = normalizeCRS(parseAuthorityCodeString(info.crs_string));
25154
+ if (meta) return meta;
25155
+
25156
+ // 4. AUTHORITY["EPSG", N] in a .prj/WKT1 string (typically from a Shapefile)
25157
+ meta = normalizeCRS(parseAuthorityCodeFromWkt(info.wkt1));
25158
+ if (meta) return meta;
25159
+
25160
+ // 5. Recognized CRS object: WGS-84 (any encoding) or Web Mercator.
25161
+ // getDatasetCrsInfo() also auto-detects WGS-84 from lat/lng-like bounds,
25162
+ // which is how GeoJSON sources end up with a usable CRS object.
25163
+ var crsInfo;
25164
+ try {
25165
+ crsInfo = getDatasetCrsInfo(dataset);
25166
+ } catch (e) {
25167
+ crsInfo = null;
25168
+ }
25169
+ if (crsInfo && crsInfo.crs) {
25170
+ if (isWGS84(crsInfo.crs)) {
25171
+ return normalizeCRS({org: 'EPSG', code: 4326});
25172
+ }
25173
+ if (isWebMercator(crsInfo.crs)) {
25174
+ return normalizeCRS({org: 'EPSG', code: 3857});
25175
+ }
25176
+ }
25177
+
25178
+ return null;
24848
25179
  }
24849
25180
 
24850
25181
  function normalizeCRS(crs) {
@@ -25487,10 +25818,22 @@ ${svg}
25487
25818
  var bounds = getLayerBounds(target.layer, target.dataset.arcs);
25488
25819
  opts = Object.assign({svg_bbox: bounds.toArray()}, opts);
25489
25820
  }
25490
- // convert target fmt to dataset fmt
25491
- var datasets = targets.map(function(target) {
25492
- return utils.defaults({layers: target.layers}, target.dataset);
25493
- });
25821
+ var format = getOutputFormat(targets[0].dataset, opts);
25822
+ var datasets;
25823
+ if (format == PACKAGE_EXT && !runningInBrowser()) {
25824
+ // CLI .msx export captures the whole session: every dataset/layer in the
25825
+ // catalog ships in the snapshot, not just the -target subset. Targeted
25826
+ // layers come back visible (pinned) and stacked in the order matched by
25827
+ // -target; untargeted layers come along for the ride, hidden and parked
25828
+ // at the bottom of the GUI stack. This matches GUI snapshot semantics
25829
+ // and lets `mapshaper a.shp b.shp -target a -o foo.msx` produce a
25830
+ // shareable bundle without losing b.shp.
25831
+ datasets = prepareCatalogForCliPackExport(catalog, targets);
25832
+ } else {
25833
+ datasets = targets.map(function(target) {
25834
+ return utils.defaults({layers: target.layers}, target.dataset);
25835
+ });
25836
+ }
25494
25837
  return exportDatasets(datasets, opts);
25495
25838
  }
25496
25839
 
@@ -25737,6 +26080,62 @@ ${svg}
25737
26080
  }
25738
26081
  }
25739
26082
 
26083
+ // Prepare the full catalog for a CLI `-o foo.msx` export. Returns a
26084
+ // shallow-copied datasets/layers tree (the live model is left alone) where:
26085
+ //
26086
+ // - every dataset and every layer in the catalog is present, not just the
26087
+ // -target subset, so .msx round-trips the entire working session;
26088
+ // - layers matched by -target are marked `pinned: true` so the GUI shows
26089
+ // them on load (no `&display-all` URL flag needed);
26090
+ // - those targeted layers get menu_order values that follow the linear
26091
+ // -target list (first targeted = bottom of the stack, last = top),
26092
+ // matching the SVG draw order from the same -target line;
26093
+ // - untargeted layers get menu_order values below every targeted layer,
26094
+ // so they sit at the bottom of the GUI panel out of the way (they're
26095
+ // hidden, but if the user pins one later it doesn't pop above the
26096
+ // intended stack).
26097
+ //
26098
+ // Intra-dataset array order is preserved so re-importing with `mapshaper
26099
+ // foo.msx -target * -o bar.svg` still iterates layers in the order they
26100
+ // appeared during the original run.
26101
+ function prepareCatalogForCliPackExport(catalog, targets) {
26102
+ var targeted = new Set();
26103
+ targets.forEach(function(t) {
26104
+ t.layers.forEach(function(lyr) { targeted.add(lyr); });
26105
+ });
26106
+ var datasets = catalog.getDatasets();
26107
+ // Count untargeted layers globally so we can offset targeted layers'
26108
+ // menu_order to sit above them in a single consecutive range.
26109
+ var untargetedCount = 0;
26110
+ datasets.forEach(function(d) {
26111
+ d.layers.forEach(function(lyr) {
26112
+ if (!targeted.has(lyr)) untargetedCount++;
26113
+ });
26114
+ });
26115
+ var untargetedSeq = 0;
26116
+ return datasets.map(function(dataset) {
26117
+ var layers = dataset.layers.map(function(lyr) {
26118
+ var isTargeted = targeted.has(lyr);
26119
+ var menuOrder;
26120
+ if (isTargeted && lyr.target_id != null && lyr.target_id >= 0) {
26121
+ // target_id is 0-based across the whole -target list; offset past
26122
+ // the untargeted block so targeted layers occupy [untargetedCount+1
26123
+ // .. untargetedCount+N].
26124
+ menuOrder = untargetedCount + lyr.target_id + 1;
26125
+ } else {
26126
+ // Untargeted (or untargeted-shaped target_id): pack into the bottom
26127
+ // of the global stack, in catalog walk order.
26128
+ menuOrder = ++untargetedSeq;
26129
+ }
26130
+ return utils.defaults({
26131
+ pinned: isTargeted,
26132
+ menu_order: menuOrder
26133
+ }, lyr);
26134
+ });
26135
+ return utils.defaults({layers: layers}, dataset);
26136
+ });
26137
+ }
26138
+
25740
26139
  var Export = /*#__PURE__*/Object.freeze({
25741
26140
  __proto__: null,
25742
26141
  assignUniqueFileNames: assignUniqueFileNames,
@@ -26874,26 +27273,71 @@ ${svg}
26874
27273
  // show help if only a command name is given
26875
27274
  argv.unshift('-help'); // kludge (assumes -help <command> syntax)
26876
27275
  } else if (argv.length > 0 && !tokenLooksLikeCommand(argv[0]) && _default) {
26877
- // if there are arguments before the first explicit command, use the default command
26878
- argv.unshift('-' + _default);
27276
+ // if there are arguments before the first explicit command, use the default
27277
+ // command. If _default is a function, let it inspect/mutate argv directly
27278
+ // (this lets callers route by file type, e.g. .txt -> -run).
27279
+ if (typeof _default == 'function') {
27280
+ _default(argv);
27281
+ } else {
27282
+ argv.unshift('-' + _default);
27283
+ }
26879
27284
  }
26880
27285
 
27286
+ // snapshot the argv so we can record the source tokens consumed by each
27287
+ // command (used by the late-binding {{...}} interpolator)
27288
+ var argvSnapshot = argv.slice();
27289
+ var totalLen = argvSnapshot.length;
27290
+
26881
27291
  while (argv.length > 0) {
27292
+ var consumedBefore = totalLen - argv.length;
26882
27293
  cmdName = readCommandName(argv);
26883
27294
  if (!cmdName) {
26884
27295
  stop$1("Invalid command:", argv[0]);
26885
27296
  }
26886
27297
  cmdDef = findCommandDefn(cmdName, commandDefs) || null;
27298
+ // Look ahead at the option tokens this command will consume. If any
27299
+ // contains a {{...}} placeholder, the late-binding interpolator (in
27300
+ // mapshaper-run-commands.mjs) will re-parse this command after
27301
+ // substitution, so we skip validation here to avoid premature errors
27302
+ // on un-interpolated tokens.
27303
+ var lookaheadTokens = peekCommandTokens(argv);
27304
+ var deferValidate = lookaheadTokens.some(tokenContainsPlaceholder);
26887
27305
  if (!cmdDef) {
26888
27306
  cmd = parseUnknownCommandOptions(argv, cmdName);
26889
27307
  } else {
26890
- cmd = parseCommandOptions(argv, cmdDef);
26891
- }
27308
+ cmd = parseCommandOptions(argv, cmdDef, deferValidate);
27309
+ }
27310
+ var consumedAfter = totalLen - argv.length;
27311
+ // Stash the source tokens for the late-binding {{...}} interpolator.
27312
+ // Defined as non-enumerable so existing tests that deep-equal parsed
27313
+ // commands aren't affected.
27314
+ Object.defineProperty(cmd, '_tokens', {
27315
+ value: argvSnapshot.slice(consumedBefore, consumedAfter),
27316
+ enumerable: false,
27317
+ writable: true,
27318
+ configurable: true
27319
+ });
26892
27320
  commands.push(cmd);
26893
27321
  }
26894
27322
  return commands;
26895
27323
 
26896
- function parseCommandOptions(argv, cmdDef) {
27324
+ // Return the tokens (without removing them) that the next command would
27325
+ // consume. Boundary is the next command name, matching the rule in
27326
+ // parseCommandOptions.
27327
+ function peekCommandTokens(argv) {
27328
+ var out = [];
27329
+ for (var i = 0; i < argv.length; i++) {
27330
+ if (tokenLooksLikeCommand(argv[i])) break;
27331
+ out.push(argv[i]);
27332
+ }
27333
+ return out;
27334
+ }
27335
+
27336
+ function tokenContainsPlaceholder(s) {
27337
+ return typeof s == 'string' && s.indexOf('{{') !== -1;
27338
+ }
27339
+
27340
+ function parseCommandOptions(argv, cmdDef, deferValidate) {
26897
27341
  var cmd = {
26898
27342
  name: cmdDef.name,
26899
27343
  options: {},
@@ -26905,10 +27349,10 @@ ${svg}
26905
27349
  }
26906
27350
 
26907
27351
  try {
26908
- if (cmd._.length > 0) {
27352
+ if (cmd._.length > 0 && !deferValidate) {
26909
27353
  readDefaultOptionValue(cmd, cmdDef);
26910
27354
  }
26911
- if (cmdDef.validate) {
27355
+ if (cmdDef.validate && !deferValidate) {
26912
27356
  cmdDef.validate(cmd);
26913
27357
  }
26914
27358
  delete cmd.options._; // kludge to remove -o placeholder option
@@ -27385,7 +27829,16 @@ ${svg}
27385
27829
 
27386
27830
  parser.section('I/O commands');
27387
27831
 
27388
- parser.default('i');
27832
+ // When the command line begins with a non-command argument, route .txt files
27833
+ // to "-run <path>" (command files) and everything else to "-i <path>" (data
27834
+ // files). The actual file-or-expression check happens inside cmd.run.
27835
+ parser.default(function(argv) {
27836
+ if (isPotentialCommandFile(argv[0])) {
27837
+ argv.unshift('-run');
27838
+ } else {
27839
+ argv.unshift('-i');
27840
+ }
27841
+ });
27389
27842
 
27390
27843
  parser.command('i')
27391
27844
  .describe('input one or more files')
@@ -27402,6 +27855,10 @@ ${svg}
27402
27855
  describe: 'import files to separate layers with shared topology',
27403
27856
  type: 'flag'
27404
27857
  })
27858
+ .option('batch-mode', {
27859
+ describe: 'apply subsequent commands separately to each input file',
27860
+ type: 'flag'
27861
+ })
27405
27862
  .option('merge-files', {
27406
27863
  // describe: 'merge features from compatible files into the same layer',
27407
27864
  type: 'flag'
@@ -28033,7 +28490,7 @@ ${svg}
28033
28490
  });
28034
28491
 
28035
28492
  parser.command('dissolve')
28036
- .describe('merge features within a layer')
28493
+ .describe('merge features within a layer (repairs polygon topology)')
28037
28494
  .example('Dissolve all polygons in a feature layer into a single polygon\n' +
28038
28495
  '$ mapshaper states.shp -dissolve -o country.shp')
28039
28496
  .example('Generate state-level polygons by dissolving a layer of counties\n' +
@@ -28060,19 +28517,33 @@ ${svg}
28060
28517
  type: 'flag',
28061
28518
  describe: '[points] use 2D math to find centroids of latlong points'
28062
28519
  })
28520
+ .option('gap-fill-area', {
28521
+ describe: '[polygons] threshold for filling gaps, e.g. 1.5km2',
28522
+ type: 'area'
28523
+ })
28524
+ .option('sliver-control', sliverControlOpt)
28525
+ .option('allow-overlaps', {
28526
+ describe: '[polygons] allow output polygons to overlap (disables gap fill)',
28527
+ type: 'flag'
28528
+ })
28529
+ .option('no-repair', {
28530
+ describe: '[polygons] skip topology repair (faster; assumes clean input)',
28531
+ type: 'flag'
28532
+ })
28533
+ .option('snap-interval', snapIntervalOpt)
28534
+ .option('no-snap', noSnapOpt)
28063
28535
  .option('name', nameOpt)
28064
28536
  .option('target', targetOpt)
28065
28537
  .option('no-replace', noReplaceOpt);
28066
28538
 
28067
28539
 
28540
+ // -dissolve2 is now an alias for -dissolve (the topology repair behavior of
28541
+ // -dissolve2 is now the default behavior of -dissolve). Kept for backward
28542
+ // compatibility; prints a deprecation notice when used.
28068
28543
  parser.command('dissolve2')
28069
- .describe('merge adjacent polygons (repairs overlaps and gaps)')
28544
+ .describe('alias for -dissolve (deprecated)')
28070
28545
  .option('field', {}) // old arg handled by dissolve function
28071
28546
  .option('fields', dissolveFieldsOpt)
28072
- // UPDATE: Use -mosaic command for debugging
28073
- //.option('mosaic', {type: 'flag'}) // debugging option
28074
- //.option('arcs', {type: 'flag'}) // debugging option
28075
- //.option('tiles', {type: 'flag'}) // debugging option
28076
28547
  .option('calc', calcOpt)
28077
28548
  .option('sum-fields', sumFieldsOpt)
28078
28549
  .option('copy-fields', copyFieldsOpt)
@@ -28175,7 +28646,7 @@ ${svg}
28175
28646
  parser.command('each')
28176
28647
  .describe('create/update/delete data fields using a JS expression')
28177
28648
  .example('Add two calculated data fields to a layer of U.S. counties\n' +
28178
- '$ mapshaper counties.shp -each \'STATE_FIPS=CNTY_FIPS.substr(0, 2), AREA=$.area\'')
28649
+ '$ mapshaper counties.shp -each \'STATE_FIPS=CNTY_FIPS.substr(0, 2), AREA=this.area\'')
28179
28650
  .option('expression', {
28180
28651
  DEFAULT: true,
28181
28652
  describe: 'JS expression to apply to each target feature'
@@ -28330,7 +28801,8 @@ ${svg}
28330
28801
  describe: 'create a polygon to match the outline of the graticule',
28331
28802
  type: 'flag'
28332
28803
  })
28333
- .option('name', nameOpt);
28804
+ .option('name', nameOpt)
28805
+ .option('target', targetOpt);
28334
28806
 
28335
28807
 
28336
28808
  // for testing grid update
@@ -28740,7 +29212,7 @@ ${svg}
28740
29212
  })
28741
29213
  .option('interval', {
28742
29214
  // alias: 'i',
28743
- describe: 'output resolution as a distance (e.g. 100)',
29215
+ describe: 'output resolution as a distance (e.g. 100m)',
28744
29216
  type: 'distance'
28745
29217
  })
28746
29218
  /*
@@ -29352,10 +29824,11 @@ ${svg}
29352
29824
  });
29353
29825
 
29354
29826
  parser.command('run')
29355
- .describe('create commands on-the-fly and run them')
29827
+ .describe('run commands from a command file or JS expression')
29356
29828
  .option('expression', {
29357
29829
  DEFAULT: true,
29358
- describe: 'JS expression or template to generate command(s)'
29830
+ label: '<file|expression>',
29831
+ describe: 'path to a .txt command file, or a JS expression/template'
29359
29832
  })
29360
29833
  // deprecated
29361
29834
  .option('commands', {alias_to: 'expression'})
@@ -29502,7 +29975,7 @@ ${svg}
29502
29975
  parser.command('calc')
29503
29976
  .describe('calculate statistics about the features in a layer')
29504
29977
  .example('Calculate the total area of a polygon layer\n' +
29505
- '$ mapshaper polygons.shp -calc \'sum($.area)\'')
29978
+ '$ mapshaper polygons.shp -calc \'sum(this.area)\'')
29506
29979
  .example('Count census blocks in NY with zero population\n' +
29507
29980
  '$ mapshaper ny-census-blocks.shp -calc \'count()\' where=\'POPULATION == 0\'')
29508
29981
  .validate(validateExpressionOpt)
@@ -29527,6 +30000,14 @@ ${svg}
29527
30000
  }
29528
30001
  });
29529
30002
 
30003
+ parser.command('defaults')
30004
+ .describe('set {{VAR}} interpolation variables only if not already set')
30005
+ .option('values', {
30006
+ DEFAULT: {
30007
+ multi_arg: true
30008
+ }
30009
+ });
30010
+
29530
30011
  parser.command('encodings')
29531
30012
  .describe('print list of supported text encodings (for .dbf import)');
29532
30013
 
@@ -29571,6 +30052,14 @@ ${svg}
29571
30052
  parser.command('quiet')
29572
30053
  .describe('inhibit console messages');
29573
30054
 
30055
+ parser.command('vars')
30056
+ .describe('define variables for {{VAR}} interpolation (overwrites)')
30057
+ .option('values', {
30058
+ DEFAULT: {
30059
+ multi_arg: true
30060
+ }
30061
+ });
30062
+
29574
30063
  parser.command('verbose')
29575
30064
  .describe('print verbose processing messages');
29576
30065
 
@@ -33418,7 +33907,12 @@ ${svg}
33418
33907
  // load external files (e.g. epsg definitions) if needed in GUI
33419
33908
  await initProjLibrary({crs: o.crs_string});
33420
33909
  o.crs = parseCrsString$1(o.crs_string);
33910
+ } else if (o.wkt1) {
33911
+ // Shapefile-sourced snapshots typically carry wkt1 but no crs_string;
33912
+ // reconstitute the proj object from it so direct readers of info.crs work.
33913
+ o.crs = parsePrj(o.wkt1);
33421
33914
  } else if (o.prj) {
33915
+ // legacy field name; older snapshots may have stored the .prj content here
33422
33916
  o.crs = parsePrj(o.prj);
33423
33917
  }
33424
33918
  return o;
@@ -33506,22 +34000,39 @@ ${svg}
33506
34000
  return target;
33507
34001
  };
33508
34002
 
33509
- // replace any JSON data objects with filenames and cache the data
34003
+ // Replace any inline data strings (JSON objects/arrays or comma-delimited
34004
+ // text) with synthetic filenames and stash the content in @cache so the
34005
+ // downstream importer can read it as if it had come from a file.
33510
34006
  function convertDataObjects(files, cache) {
33511
- var names = files.map(str => stringLooksLikeJSON(str) ? 'layer.json' : null).filter(Boolean);
33512
- if (names.length === 0) return;
33513
- if (names.length > 1) {
33514
- // make unique names if importing multiple objects
33515
- names = utils.uniqifyNames(names, formatVersionedFileName);
34007
+ var slots = files.map(classifyInlineData);
34008
+ var inlineCount = slots.filter(Boolean).length;
34009
+ if (inlineCount === 0) return;
34010
+ if (inlineCount > 1) {
34011
+ // ensure unique filenames when multiple inline strings are passed together
34012
+ var names = slots.filter(Boolean).map(function(s) { return s.filename; });
34013
+ var unique = utils.uniqifyNames(names, formatVersionedFileName);
34014
+ var idx = 0;
34015
+ slots.forEach(function(slot) {
34016
+ if (slot) slot.filename = unique[idx++];
34017
+ });
33516
34018
  }
33517
- files.forEach((str, i) => {
33518
- if (!stringLooksLikeJSON(str)) return;
33519
- var name = names.shift();
33520
- cache[name] = str;
33521
- files[i] = name;
34019
+ slots.forEach(function(slot, i) {
34020
+ if (!slot) return;
34021
+ cache[slot.filename] = slot.content;
34022
+ files[i] = slot.filename;
33522
34023
  });
33523
34024
  }
33524
34025
 
34026
+ function classifyInlineData(str) {
34027
+ if (stringLooksLikeJSON(str)) {
34028
+ return {filename: 'layer.json', content: str};
34029
+ }
34030
+ if (stringLooksLikeCsv(str)) {
34031
+ return {filename: 'layer.csv', content: unescapeInlineCsv(str)};
34032
+ }
34033
+ return null;
34034
+ }
34035
+
33525
34036
  async function importMshpFile(file, catalog, opts) {
33526
34037
  var buf = cli.readFile(file, null, opts.input);
33527
34038
  var obj = await unpackSessionData(buf);
@@ -34100,6 +34611,7 @@ ${svg}
34100
34611
  var job = {
34101
34612
  catalog: catalog || new Catalog(),
34102
34613
  defs: {},
34614
+ vars: {},
34103
34615
  settings: {},
34104
34616
  input_files: []
34105
34617
  };
@@ -34135,6 +34647,7 @@ ${svg}
34135
34647
  stashVar('VERBOSE', job.settings.VERBOSE || cmd.verbose);
34136
34648
  stashVar('QUIET', job.settings.QUIET || cmd.quiet);
34137
34649
  stashVar('defs', job.defs);
34650
+ stashVar('vars', job.vars);
34138
34651
  stashVar('input_files', job.input_files);
34139
34652
  }
34140
34653
 
@@ -41741,16 +42254,239 @@ ${svg}
41741
42254
  compiled(null, defs);
41742
42255
  };
41743
42256
 
41744
- // Removes small gaps and all overlaps
41745
- cmd.dissolve2 = function(layers, dataset, opts) {
41746
- layers.forEach(requirePolygonLayer);
41747
- addIntersectionCuts(dataset, opts);
41748
- return layers.map(function(lyr) {
41749
- if (!layerHasPaths(lyr)) return lyr;
41750
- return dissolvePolygonLayer2(lyr, dataset, opts);
42257
+ // Variable name pattern. Matches simple identifiers: must start with a letter
42258
+ // or underscore, followed by letters, digits or underscores.
42259
+ var VAR_NAME_RXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
42260
+
42261
+ // Pattern that matches a {{...}} placeholder in command text. The optional
42262
+ // leading character is a backslash (escape) which keeps the placeholder
42263
+ // literal. The braces themselves cannot appear inside a placeholder.
42264
+ //
42265
+ // Group 1: the leading escape (if present)
42266
+ // Group 2: the contents between {{ and }}
42267
+ //
42268
+ var PLACEHOLDER_RXP = /(\\?)\{\{([^{}]+?)\}\}/g;
42269
+
42270
+ // Pattern matching a "KEY=value" inline -vars argument.
42271
+ var ASSIGNMENT_RXP = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
42272
+
42273
+ // Returns true if @s is a valid mapshaper variable name.
42274
+ function isValidVarName(s) {
42275
+ return typeof s == 'string' && VAR_NAME_RXP.test(s);
42276
+ }
42277
+
42278
+ // Returns true if @str might contain a {{...}} placeholder. This is a cheap
42279
+ // fast-path test used to skip re-parsing for commands that have no
42280
+ // placeholders. False positives (e.g. literal "{{" inside a quoted JS
42281
+ // expression) just trigger an interpolation pass that finds nothing to do.
42282
+ function containsPlaceholder(str) {
42283
+ return typeof str == 'string' && str.indexOf('{{') !== -1;
42284
+ }
42285
+
42286
+ // Validate the parsed contents of a -vars JSON file. The file must contain a
42287
+ // flat object whose values are primitive (string, number, boolean, null).
42288
+ // Throws on invalid input. Returns the same object.
42289
+ function validateVarsObject(obj, source) {
42290
+ source = source || '<vars>';
42291
+ if (!utils.isObject(obj) || Array.isArray(obj)) {
42292
+ stop$1('Invalid vars file (' + source + '): expected an object at the top level');
42293
+ }
42294
+ Object.keys(obj).forEach(function(key) {
42295
+ if (!isValidVarName(key)) {
42296
+ stop$1('Invalid var name in ' + source + ': ' + JSON.stringify(key));
42297
+ }
42298
+ var v = obj[key];
42299
+ if (v !== null && typeof v != 'string' && typeof v != 'number' &&
42300
+ typeof v != 'boolean') {
42301
+ stop$1('Invalid value for var "' + key + '" in ' + source +
42302
+ ': expected a string, number, boolean or null');
42303
+ }
42304
+ });
42305
+ return obj;
42306
+ }
42307
+
42308
+ // Resolve a single -vars argument. Each argument is either:
42309
+ // * an inline assignment "KEY=value" (key must be a valid var name)
42310
+ // * a path to a JSON file containing a flat object of vars
42311
+ //
42312
+ // @arg: the raw argument string
42313
+ // @cache: optional input cache (passed to cli.readFile so files dropped into
42314
+ // the GUI can be resolved by name)
42315
+ // @merge: target object that receives the resolved entries
42316
+ //
42317
+ function resolveVarsArg(arg, cache, merge) {
42318
+ var assignment = ASSIGNMENT_RXP.exec(arg);
42319
+ if (assignment) {
42320
+ merge[assignment[1]] = assignment[2];
42321
+ return;
42322
+ }
42323
+ // Treat as a JSON file path
42324
+ cli.checkFileExists(arg, cache);
42325
+ var content = cli.readFile(arg, 'utf8', cache);
42326
+ var obj;
42327
+ try {
42328
+ obj = JSON.parse(content);
42329
+ } catch(e) {
42330
+ stop$1('Failed to parse vars file (' + arg + '): ' + e.message);
42331
+ }
42332
+ validateVarsObject(obj, arg);
42333
+ Object.keys(obj).forEach(function(key) {
42334
+ merge[key] = obj[key];
42335
+ });
42336
+ }
42337
+
42338
+ // Resolve an array of -vars arguments into a flat scope object. Later
42339
+ // arguments override earlier ones.
42340
+ function parseVarsArgs(args, cache) {
42341
+ var scope = {};
42342
+ if (!Array.isArray(args)) return scope;
42343
+ args.forEach(function(arg) {
42344
+ resolveVarsArg(arg, cache, scope);
42345
+ });
42346
+ return scope;
42347
+ }
42348
+
42349
+ // Look up env.* variables. Throws in environments without process.env.
42350
+ function lookupEnvVar(name) {
42351
+ if (typeof process == 'undefined' || !process.env) {
42352
+ stop$1('Environment variables are not available in this context');
42353
+ }
42354
+ return process.env[name];
42355
+ }
42356
+
42357
+ // Resolve a single placeholder expression to a string. Recognised forms:
42358
+ // VAR -> vars[VAR] if present, else defs[VAR]
42359
+ // env.VAR -> process.env[VAR]
42360
+ //
42361
+ // vars is the templating-scope object (-vars / -defaults writes).
42362
+ // defs is the expression-scope object (-define / -calc / -include /
42363
+ // -require / -colorizer writes). The fallback exists so that
42364
+ // "-define base = 'out'" -> "-o {{base}}.geojson" and
42365
+ // "-calc 'N = count()'" -> "-if '{{N}} > 100'" keep working without
42366
+ // the user having to know which scope a value lives in.
42367
+ //
42368
+ // Throws on undefined names, invalid syntax, or non-primitive values.
42369
+ //
42370
+ function resolvePlaceholder(expr, vars, defs) {
42371
+ expr = expr.trim();
42372
+ var envMatch = /^env\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
42373
+ var val, source;
42374
+ if (envMatch) {
42375
+ val = lookupEnvVar(envMatch[1]);
42376
+ if (val === undefined || val === null) {
42377
+ stop$1('Undefined environment variable: ' + envMatch[1]);
42378
+ }
42379
+ return String(val);
42380
+ }
42381
+ if (!isValidVarName(expr)) {
42382
+ stop$1('Invalid variable reference: {{' + expr + '}}');
42383
+ }
42384
+ if (vars && expr in vars) {
42385
+ source = vars;
42386
+ } else if (defs && expr in defs) {
42387
+ source = defs;
42388
+ } else {
42389
+ stop$1('Undefined variable: ' + expr);
42390
+ }
42391
+ val = source[expr];
42392
+ if (val === null || val === undefined) {
42393
+ stop$1('Undefined variable: ' + expr);
42394
+ }
42395
+ if (typeof val != 'string' && typeof val != 'number' &&
42396
+ typeof val != 'boolean') {
42397
+ stop$1('Variable {{' + expr + '}} is not a primitive value (got ' +
42398
+ (typeof val) + ')');
42399
+ }
42400
+ return String(val);
42401
+ }
42402
+
42403
+ // Substitute {{...}} placeholders in @str.
42404
+ //
42405
+ // Two call signatures, kept for backward compatibility:
42406
+ // interpolateString(str, vars, defs) -- preferred, two-store form
42407
+ // interpolateString(str, defs) -- legacy, single-store form
42408
+ //
42409
+ // In the legacy form, the second argument is treated as the expression
42410
+ // scope (defs); there is no template scope. New callers should use the
42411
+ // two-store form.
42412
+ //
42413
+ // Placeholders preceded by a backslash are left literal (with the
42414
+ // backslash removed). Substitution is single-pass (no recursion) so
42415
+ // values containing "{{...}}" do not trigger further interpolation.
42416
+ //
42417
+ function interpolateString(str, varsOrDefs, defsArg) {
42418
+ if (typeof str != 'string') return str;
42419
+ var vars, defs;
42420
+ if (arguments.length >= 3) {
42421
+ vars = varsOrDefs;
42422
+ defs = defsArg;
42423
+ } else {
42424
+ vars = null;
42425
+ defs = varsOrDefs;
42426
+ }
42427
+ return str.replace(PLACEHOLDER_RXP, function(match, escape, expr) {
42428
+ if (escape === '\\') return '{{' + expr + '}}';
42429
+ return resolvePlaceholder(expr, vars, defs);
42430
+ });
42431
+ }
42432
+
42433
+ // -vars KEY=value [KEY=value ...] inline assignments
42434
+ // -vars file.json [more ...] load primitives from a flat JSON object
42435
+ // Mixed forms allowed; later args override earlier ones.
42436
+ //
42437
+ // Writes into job.vars, the templating-scope object read by {{X}}
42438
+ // interpolation. Values written here are NOT visible by bare name in JS
42439
+ // expressions (-each, -filter, -define, etc.); use -define for that.
42440
+ // {{X}} substitution falls back to job.defs if a name is missing from
42441
+ // vars, so values set by -define / -calc / -include are still
42442
+ // referenceable from {{X}}.
42443
+ cmd.vars = function(job, opts) {
42444
+ var values = (opts && opts.values) || [];
42445
+ if (!values.length) {
42446
+ stop$1('-vars requires one or more KEY=value or file.json arguments');
42447
+ }
42448
+ var parsed = parseVarsArgs(values, opts && opts.input);
42449
+ if (!job.vars) job.vars = {};
42450
+ Object.keys(parsed).forEach(function(key) {
42451
+ job.vars[key] = parsed[key];
42452
+ });
42453
+ };
42454
+
42455
+ // -defaults KEY=value [KEY=value ...] set-if-unset
42456
+ // Same syntax as -vars, but a key is only assigned if it is not already
42457
+ // present in job.vars. Lets a command file declare overridable defaults
42458
+ // that a CLI -vars can pre-empt.
42459
+ cmd.defaults = function(job, opts) {
42460
+ var values = (opts && opts.values) || [];
42461
+ if (!values.length) {
42462
+ stop$1('-defaults requires one or more KEY=value or file.json arguments');
42463
+ }
42464
+ var parsed = parseVarsArgs(values, opts && opts.input);
42465
+ if (!job.vars) job.vars = {};
42466
+ Object.keys(parsed).forEach(function(key) {
42467
+ if (!(key in job.vars)) {
42468
+ job.vars[key] = parsed[key];
42469
+ }
41751
42470
  });
41752
42471
  };
41753
42472
 
42473
+ // -dissolve2 is now an alias for -dissolve. The repair-on-by-default behavior
42474
+ // of -dissolve2 has been promoted to be the default behavior of -dissolve;
42475
+ // the legacy fast-dissolve algorithm is available via -dissolve no-repair.
42476
+ //
42477
+ // This alias prints a deprecation notice and forwards to cmd.dissolve.
42478
+ //
42479
+ var deprecationWarned = false;
42480
+
42481
+ cmd.dissolve2 = function(layers, dataset, opts) {
42482
+ if (!deprecationWarned && !(opts && opts.quiet)) {
42483
+ message('This command has been merged into -dissolve and is deprecated. ' +
42484
+ 'Use -dissolve (or -dissolve no-repair for the legacy fast algorithm).');
42485
+ deprecationWarned = true;
42486
+ }
42487
+ return cmd.dissolve(layers, dataset, opts);
42488
+ };
42489
+
41754
42490
  // Returns a function for filtering multiple source-table records
41755
42491
  // (used by -join command)
41756
42492
  function getJoinFilter(data, exp) {
@@ -48414,8 +49150,139 @@ ${svg}
48414
49150
  return parsed;
48415
49151
  }
48416
49152
 
49153
+ // Parse the text content of a mapshaper command file (e.g. "commands.txt")
49154
+ // into a single normalized command string suitable for parseCommands().
49155
+ //
49156
+ // Command file syntax (a superset of the equivalent shell command line):
49157
+ // - Optional leading "mapshaper" magic word (used by the file-type sniffer).
49158
+ // - "#" begins a comment that runs to the end of the line. Comments are
49159
+ // ignored unless the "#" appears inside a quoted string.
49160
+ // - Newlines are command separators (unless they fall inside a quoted
49161
+ // string). A trailing backslash on a line is stripped, so shell-style
49162
+ // "\" line continuations are accepted but not required.
49163
+ // - Commands must begin with "-" (e.g. "-i", "-target"). Lines that do
49164
+ // not start with "-" are treated as continuations of the previous
49165
+ // command.
49166
+ // - As on the CLI, an initial command is implied for the first bare token
49167
+ // after the optional "mapshaper" word: a .txt file routes to
49168
+ // "-run <path>" (command file), and any other bare token routes to
49169
+ // "-i <token>" (data file).
49170
+ //
49171
+ // "{{VAR}}" placeholders are substituted at execution time, against the
49172
+ // live job.vars object (with job.defs as a fallback for values written by
49173
+ // -define / -calc / -include). See mapshaper-vars-utils.mjs and the
49174
+ // late-binding hook in mapshaper-run-commands.mjs.
49175
+ //
49176
+ function parseCommandFileContent(content) {
49177
+ if (typeof content != 'string') {
49178
+ content = String(content || '');
49179
+ }
49180
+ // Strip BOM if present
49181
+ if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
49182
+ var commands = groupCommandFileLines(extractLogicalLines(content));
49183
+ return commands.join(' ');
49184
+ }
49185
+
49186
+ // Walk command file content into logical lines, respecting quoted strings and
49187
+ // stripping "#" comments. Quoted-string contents (including embedded
49188
+ // newlines) are preserved verbatim.
49189
+ function extractLogicalLines(content) {
49190
+ var lines = [];
49191
+ var current = '';
49192
+ var quote = null; // null, "'", or '"'
49193
+ var inComment = false;
49194
+
49195
+ for (var i = 0; i < content.length; i++) {
49196
+ var c = content.charAt(i);
49197
+ if (inComment) {
49198
+ if (c === '\n') {
49199
+ inComment = false;
49200
+ lines.push(current);
49201
+ current = '';
49202
+ }
49203
+ continue;
49204
+ }
49205
+ if (quote) {
49206
+ current += c;
49207
+ if (c === quote) {
49208
+ var bs = 0;
49209
+ for (var j = i - 1; j >= 0 && content.charAt(j) === '\\'; j--) bs++;
49210
+ if (bs % 2 === 0) quote = null;
49211
+ }
49212
+ continue;
49213
+ }
49214
+ if (c === '#') {
49215
+ inComment = true;
49216
+ } else if (c === "'" || c === '"') {
49217
+ quote = c;
49218
+ current += c;
49219
+ } else if (c === '\n') {
49220
+ lines.push(current);
49221
+ current = '';
49222
+ } else {
49223
+ current += c;
49224
+ }
49225
+ }
49226
+ if (current.length > 0) lines.push(current);
49227
+ if (quote) {
49228
+ stop$1('Unterminated quoted string in command file');
49229
+ }
49230
+ return lines;
49231
+ }
49232
+
49233
+ // Group an array of logical lines into command strings:
49234
+ // - Strip trailing-backslash continuations.
49235
+ // - Strip a leading "mapshaper" magic word from the first non-blank line.
49236
+ // - Lines starting with "-" begin a new command.
49237
+ // - Other lines are continuations of the previous command.
49238
+ // - The very first bare token is treated as an implicit -i (data file)
49239
+ // or -run (command file), matching CLI behavior.
49240
+ function groupCommandFileLines(lines) {
49241
+ var commands = [];
49242
+ var cur = '';
49243
+ var sawMagicWord = false;
49244
+ for (var k = 0; k < lines.length; k++) {
49245
+ var line = lines[k];
49246
+ line = line.replace(/\s*\\\s*$/, '').trim();
49247
+ if (!line) continue;
49248
+
49249
+ if (!sawMagicWord && commands.length === 0 && cur === '' &&
49250
+ /^mapshaper(\s|$)/.test(line)) {
49251
+ sawMagicWord = true;
49252
+ line = line.replace(/^mapshaper\s*/, '');
49253
+ if (!line) continue;
49254
+ }
49255
+
49256
+ if (line.charAt(0) === '-') {
49257
+ if (cur) commands.push(cur);
49258
+ cur = line;
49259
+ } else if (!cur && commands.length === 0) {
49260
+ cur = implicitFirstCommand(line);
49261
+ } else {
49262
+ cur += ' ' + line;
49263
+ }
49264
+ }
49265
+ if (cur) commands.push(cur);
49266
+ return commands;
49267
+ }
49268
+
49269
+ // Build the implicit command for a leading bare token:
49270
+ // foo.txt -> -run foo.txt
49271
+ // anything else -> -i <line>
49272
+ // Only the first whitespace-separated token of @line is sniffed for the .txt
49273
+ // extension; any trailing tokens (rare but possible after line joining) are
49274
+ // passed through unchanged.
49275
+ function implicitFirstCommand(line) {
49276
+ var firstTok = line.split(/\s+/)[0];
49277
+ if (isPotentialCommandFile(firstTok)) {
49278
+ return '-run ' + line;
49279
+ }
49280
+ return '-i ' + line;
49281
+ }
49282
+
48417
49283
  var ParseCommands = /*#__PURE__*/Object.freeze({
48418
49284
  __proto__: null,
49285
+ parseCommandFileContent: parseCommandFileContent,
48419
49286
  parseCommands: parseCommands,
48420
49287
  parseConsoleCommands: parseConsoleCommands,
48421
49288
  standardizeConsoleCommands: standardizeConsoleCommands
@@ -48438,7 +49305,9 @@ ${svg}
48438
49305
  }
48439
49306
 
48440
49307
  function commandTakesFileInput(name) {
48441
- return (name == 'i' || name == 'join' || name == 'erase' || name == 'clip' || name == 'include');
49308
+ return (name == 'i' || name == 'join' || name == 'erase' || name == 'clip' ||
49309
+ name == 'include' || name == 'vars' || name == 'defaults' ||
49310
+ name == 'run');
48442
49311
  }
48443
49312
 
48444
49313
  // TODO: implement these and other functions
@@ -48464,11 +49333,116 @@ ${svg}
48464
49333
  // flags.mosaic || flags.snap;
48465
49334
  // }
48466
49335
 
49336
+ // Maximum nesting depth for command files that load other command files
49337
+ var MAX_RUN_DEPTH = 10;
49338
+
49339
+ // Returns the text content of a command file, or null if @file does not look
49340
+ // like a mapshaper command file (wrong extension or missing magic word).
49341
+ // On match, the file content is left in the cache so a subsequent reader
49342
+ // can reuse it.
49343
+ function readCommandFile(file, cache) {
49344
+ if (!isPotentialCommandFile(file)) return null;
49345
+ cli.checkFileExists(file, cache);
49346
+ // cli.readFile(... cache) deletes the entry from the cache after reading,
49347
+ // so we put it back in case downstream code expects to find it there.
49348
+ var content = cli.readFile(file, 'utf8', cache);
49349
+ if (!stringLooksLikeCommandFile(content)) {
49350
+ if (cache) cache[file] = content;
49351
+ return null;
49352
+ }
49353
+ if (cache) cache[file] = content;
49354
+ return content;
49355
+ }
49356
+
49357
+ // Parse and execute the commands in a mapshaper command file within the
49358
+ // given job. Invoked by the -run command when its argument is a .txt file.
49359
+ //
49360
+ // @file: command file path
49361
+ // @content: command file content (string)
49362
+ // @job: parent Job object (commands run in this job)
49363
+ // @opts: options object from the parent -run command (used for input cache and
49364
+ // recursion-depth tracking)
49365
+ //
49366
+ async function runCommandFile(file, content, job, opts) {
49367
+ var depth = (opts && opts._run_depth || 0) + 1;
49368
+ if (depth > MAX_RUN_DEPTH) {
49369
+ stop$1('Command file nesting limit exceeded (' + MAX_RUN_DEPTH + ') at: ' + file);
49370
+ }
49371
+
49372
+ var cache = opts && opts.input || null;
49373
+ var commandStr;
49374
+ try {
49375
+ commandStr = parseCommandFileContent(content);
49376
+ } catch(e) {
49377
+ e.message = 'Error in command file ' + file + ': ' + e.message;
49378
+ throw e;
49379
+ }
49380
+
49381
+ if (!commandStr) {
49382
+ message('Command file contains no commands:', file);
49383
+ return;
49384
+ }
49385
+
49386
+ verbose('Running command file:', file);
49387
+
49388
+ var commands;
49389
+ try {
49390
+ commands = parseCommands(commandStr);
49391
+ } catch(e) {
49392
+ e.message = 'Error in command file ' + file + ': ' + e.message;
49393
+ throw e;
49394
+ }
49395
+
49396
+ // Forward the input cache, output array and depth tracker to nested
49397
+ // commands. This lets a command file's -i find sibling files in the same
49398
+ // cache, lets nested -o commands write to the same output collector (e.g.
49399
+ // when running under applyCommands), and lets nested -run commands respect
49400
+ // the recursion limit.
49401
+ var outputArr = opts && opts.output || null;
49402
+ commands.forEach(function(c) {
49403
+ if (commandTakesFileInput(c.name) && cache) {
49404
+ c.options.input = cache;
49405
+ }
49406
+ if (outputArr && (c.name == 'o' || c.name == 'i' || c.name == 'run' ||
49407
+ c.name == 'info' && c.options.save_to)) {
49408
+ c.options.output = outputArr;
49409
+ }
49410
+ if (c.name == 'run') {
49411
+ c.options._run_depth = depth;
49412
+ }
49413
+ });
49414
+
49415
+ await utils.promisify(runParsedCommands)(commands, job);
49416
+ }
49417
+
48467
49418
  cmd.run = async function(job, targets, opts) {
48468
- var tmp, commands, ctx;
48469
- if (!opts.expression) {
48470
- stop$1("Missing expression parameter");
49419
+ var arg = opts.expression;
49420
+ if (!arg) {
49421
+ stop$1('-run requires a command file path or a JS expression');
48471
49422
  }
49423
+ // Auto-detect a leading argument that looks like a command file (.txt).
49424
+ // If detection succeeds, treat as a command file; otherwise the argument
49425
+ // is a JS expression (the original -run behavior).
49426
+ if (isPotentialCommandFile(arg)) {
49427
+ if (opts.target) {
49428
+ stop$1('-run does not accept a target= option for command files');
49429
+ }
49430
+ await runFromFile(job, arg, opts);
49431
+ } else {
49432
+ await runFromExpression(job, targets, opts);
49433
+ }
49434
+ };
49435
+
49436
+ async function runFromFile(job, file, opts) {
49437
+ var content = readCommandFile(file, opts.input);
49438
+ if (content === null) {
49439
+ stop$1('Not a mapshaper command file (missing "mapshaper" magic word):', file);
49440
+ }
49441
+ await runCommandFile(file, content, job, opts);
49442
+ }
49443
+
49444
+ async function runFromExpression(job, targets, opts) {
49445
+ var tmp, commands, ctx;
48472
49446
  ctx = getBaseContext();
48473
49447
  // io proxy adds ability to add datasets dynamically in a required function
48474
49448
  ctx.io = getIOProxy();
@@ -48482,15 +49456,23 @@ ${svg}
48482
49456
  commands = parseCommands(tmp);
48483
49457
 
48484
49458
  // TODO: remove duplication with mapshaper-run-commands.mjs
49459
+ var outputArr = opts && opts.output || null;
48485
49460
  commands.forEach(function(cmd) {
48486
49461
  if (commandTakesFileInput(cmd.name)) {
48487
49462
  cmd.options.input = ctx.io._cache;
48488
49463
  }
49464
+ // Forward the output collector to commands that produce output, so a
49465
+ // generated -o (or info save_to=, or nested -run) writes into the same
49466
+ // output object (e.g. when running under applyCommands).
49467
+ if (outputArr && (cmd.name == 'o' || cmd.name == 'run' ||
49468
+ cmd.name == 'info' && cmd.options.save_to)) {
49469
+ cmd.options.output = outputArr;
49470
+ }
48489
49471
  });
48490
49472
 
48491
49473
  await utils.promisify(runParsedCommands)(commands, job);
48492
49474
  }
48493
- };
49475
+ }
48494
49476
 
48495
49477
  cmd.shape = function(targetDataset, opts) {
48496
49478
  var geojson, dataset;
@@ -50685,7 +51667,7 @@ ${svg}
50685
51667
  name == 'require' || name == 'run' || name == 'define' ||
50686
51668
  name == 'include' || name == 'print' || name == 'comment' || name == 'if' || name == 'elif' ||
50687
51669
  name == 'else' || name == 'endif' || name == 'stop' || name == 'add-shape' ||
50688
- name == 'scalebar';
51670
+ name == 'scalebar' || name == 'vars' || name == 'defaults';
50689
51671
  }
50690
51672
 
50691
51673
  async function runCommand(command, job) {
@@ -50820,8 +51802,14 @@ ${svg}
50820
51802
  } else if (name == 'define') {
50821
51803
  cmd.define(job.catalog, opts);
50822
51804
 
51805
+ } else if (name == 'vars') {
51806
+ cmd.vars(job, opts);
51807
+
51808
+ } else if (name == 'defaults') {
51809
+ cmd.defaults(job, opts);
51810
+
50823
51811
  } else if (name == 'dissolve') {
50824
- outputLayers = applyCommandToEachLayer(cmd.dissolve, targetLayers, arcs, opts);
51812
+ outputLayers = cmd.dissolve(targetLayers, targetDataset, opts);
50825
51813
 
50826
51814
  } else if (name == 'dissolve2') {
50827
51815
  outputLayers = cmd.dissolve2(targetLayers, targetDataset, opts);
@@ -51139,7 +52127,7 @@ ${svg}
51139
52127
  });
51140
52128
  }
51141
52129
 
51142
- var version = "0.6.121";
52130
+ var version = "0.7.1";
51143
52131
 
51144
52132
  // Parse command line args into commands and run them
51145
52133
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
@@ -51276,6 +52264,11 @@ ${svg}
51276
52264
  if (outputArr && (cmd.name == 'o' || cmd.name == 'info' && cmd.options.save_to)) {
51277
52265
  cmd.options.output = outputArr;
51278
52266
  }
52267
+ // -run may load and execute a command file; propagate the output array
52268
+ // so that -o commands nested inside it can write to it too.
52269
+ if (outputArr && cmd.name == 'run') {
52270
+ cmd.options.output = outputArr;
52271
+ }
51279
52272
  });
51280
52273
 
51281
52274
  var lastCmd = commands[commands.length - 1];
@@ -51368,7 +52361,13 @@ ${svg}
51368
52361
  utils.reduceAsync(commands, job, nextCommand, done);
51369
52362
 
51370
52363
  function nextCommand(job, cmd, next) {
51371
- runCommand(cmd, job).then(function(result) {
52364
+ var resolved;
52365
+ try {
52366
+ resolved = maybeInterpolateCommand(cmd, job);
52367
+ } catch(e) {
52368
+ return next(e);
52369
+ }
52370
+ runCommand(resolved, job).then(function(result) {
51372
52371
  next(null, result);
51373
52372
  }).catch(function(e) {
51374
52373
  next(e);
@@ -51376,6 +52375,62 @@ ${svg}
51376
52375
  }
51377
52376
  }
51378
52377
 
52378
+ // Late-binding interpolation: just before each command runs, replace any
52379
+ // {{X}} placeholders in its source tokens against the live job.vars and
52380
+ // job.defs objects (vars first, defs as fallback), then re-parse to get
52381
+ // fresh option values.
52382
+ //
52383
+ // Returns either the original cmd (no placeholders, no _tokens, or the
52384
+ // command will be skipped) or a fresh cmd object with re-parsed options.
52385
+ // The original cmd is never mutated, so commands shared across batches
52386
+ // (see divideImportCommand) still see their un-interpolated tokens.
52387
+ function maybeInterpolateCommand(cmd, job) {
52388
+ var tokens = cmd._tokens;
52389
+ if (!tokens || tokens.length === 0) return cmd;
52390
+ if (!tokens.some(containsPlaceholder)) return cmd;
52391
+ // If the command would be skipped (inactive -if branch, stopped job, etc.),
52392
+ // don't try to interpolate -- the command body never runs and unset
52393
+ // variables shouldn't error here.
52394
+ if (skipCommand(cmd.name, job)) return cmd;
52395
+
52396
+ var vars = job.vars || {};
52397
+ var defs = job.defs || {};
52398
+ var interpolated;
52399
+ try {
52400
+ interpolated = tokens.map(function(tok) {
52401
+ return interpolateString(tok, vars, defs);
52402
+ });
52403
+ } catch(e) {
52404
+ e.message = '[' + cmd.name + '] ' + e.message;
52405
+ throw e;
52406
+ }
52407
+
52408
+ var reparsed;
52409
+ try {
52410
+ reparsed = parseCommands(interpolated);
52411
+ } catch(e) {
52412
+ e.message = '[' + cmd.name + '] ' + e.message;
52413
+ throw e;
52414
+ }
52415
+ if (reparsed.length !== 1) {
52416
+ stop$1('[' + cmd.name + '] Internal error: token re-parse produced ' +
52417
+ reparsed.length + ' commands');
52418
+ }
52419
+ var newCmd = reparsed[0];
52420
+ // Preserve externally-injected options (input cache, output array,
52421
+ // _run_depth, final flag, replace flag, etc.) that aren't reproduced
52422
+ // by re-parsing the source tokens.
52423
+ Object.keys(cmd.options).forEach(function(k) {
52424
+ if (!(k in newCmd.options)) {
52425
+ newCmd.options[k] = cmd.options[k];
52426
+ }
52427
+ });
52428
+ // Keep the original tokens around so re-runs (e.g. divided import batches)
52429
+ // see the un-interpolated source.
52430
+ newCmd._tokens = tokens;
52431
+ return newCmd;
52432
+ }
52433
+
51379
52434
  function handleNonFatalError(err) {
51380
52435
  if (err && err.name == 'NonFatalError') {
51381
52436
  printError(err);
@@ -51399,6 +52454,18 @@ ${svg}
51399
52454
  return [commands];
51400
52455
  }
51401
52456
 
52457
+ // Multiple files trigger batch mode by default. This is a long-standing
52458
+ // wart: most multi-file CLI tools combine inputs by default, and silently
52459
+ // splitting into per-file pipelines is an easy way for users to get wrong
52460
+ // output without noticing. Print a one-time deprecation warning when batch
52461
+ // mode is implicit so existing scripts can migrate before the default flips
52462
+ // in a future major release.
52463
+ if (!opts.batch_mode) {
52464
+ message('Note: implicit batch processing is deprecated. Add `batch-mode` ' +
52465
+ 'to keep this behavior, or `combine-files` to import the files as a ' +
52466
+ 'group of layers. The default will change in a future release.');
52467
+ }
52468
+
51402
52469
  return opts.files.map(function(file) {
51403
52470
  var group = [{
51404
52471
  name: 'i',