mapshaper 0.7.60 → 0.7.62

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/mapshaper.js CHANGED
@@ -96,7 +96,7 @@
96
96
  }
97
97
 
98
98
  // Similar to isFinite() but does not coerce strings or other types
99
- function isFiniteNumber$1(val) {
99
+ function isFiniteNumber$2(val) {
100
100
  return isValidNumber(val) && val !== Infinity && val !== -Infinity;
101
101
  }
102
102
 
@@ -1106,7 +1106,7 @@
1106
1106
  getSortedIds, getUniqueName, groupBy,
1107
1107
  htmlEscape,
1108
1108
  indexOf, indexOn, inherit, initializeArray, intersection,
1109
- isArray, isArrayLike, isBoolean, isDate, isEven, isFiniteNumber: isFiniteNumber$1, isFunction,
1109
+ isArray, isArrayLike, isBoolean, isDate, isEven, isFiniteNumber: isFiniteNumber$2, isFunction,
1110
1110
  isInteger, isNonNegNumber, isNumber, isObject, isOdd: isOdd$1, isPromise, isString,
1111
1111
  isValidNumber,
1112
1112
  lpad, ltrim,
@@ -5882,13 +5882,13 @@
5882
5882
  return msg;
5883
5883
  }
5884
5884
 
5885
- function requirePointLayer(lyr, msg) {
5885
+ function requirePointLayer$1(lyr, msg) {
5886
5886
  if (!lyr || lyr.geometry_type !== 'point')
5887
5887
  stop$1(layerTypeMessage(lyr, "Expected a point layer", msg));
5888
5888
  }
5889
5889
 
5890
5890
  function requireSinglePointLayer(lyr, msg) {
5891
- requirePointLayer(lyr);
5891
+ requirePointLayer$1(lyr);
5892
5892
  if (countMultiPartFeatures(lyr.shapes) > 0) {
5893
5893
  stop$1(msg || 'This command requires single points; layer contains multi-point features.');
5894
5894
  }
@@ -6128,7 +6128,7 @@
6128
6128
  requireDataFields: requireDataFields,
6129
6129
  requireNotRasterLayer: requireNotRasterLayer,
6130
6130
  requirePathLayer: requirePathLayer,
6131
- requirePointLayer: requirePointLayer,
6131
+ requirePointLayer: requirePointLayer$1,
6132
6132
  requirePolygonLayer: requirePolygonLayer,
6133
6133
  requirePolylineLayer: requirePolylineLayer,
6134
6134
  requireSinglePointLayer: requireSinglePointLayer,
@@ -15025,7 +15025,8 @@
15025
15025
 
15026
15026
  // input: input file path or a Buffer containing .zip file bytes
15027
15027
  function unzipSyncNode(input) {
15028
- var zip = new require('adm-zip')(input);
15028
+ var AdmZip = require('adm-zip');
15029
+ var zip = new AdmZip(input);
15029
15030
  var index = {};
15030
15031
  zip.getEntries().forEach(function(entry) {
15031
15032
  // entry.entryName // path, including filename
@@ -15039,7 +15040,8 @@
15039
15040
  }
15040
15041
 
15041
15042
  function zipSyncNode(files) {
15042
- var zip = new require('adm-zip')();
15043
+ var AdmZip = require('adm-zip');
15044
+ var zip = new AdmZip();
15043
15045
  files.forEach(function(o) {
15044
15046
  var buf = o.content;
15045
15047
  if (buf instanceof ArrayBuffer) {
@@ -18751,10 +18753,10 @@
18751
18753
  return d;
18752
18754
  }
18753
18755
 
18754
- var cache = {};
18756
+ var cache$1 = {};
18755
18757
 
18756
18758
  function getParseRxp(fmt) {
18757
- if (fmt in cache) return cache[fmt];
18759
+ if (fmt in cache$1) return cache$1[fmt];
18758
18760
  var rxp = fmt;
18759
18761
  rxp = rxp.replace('[-]', '(?<prefix>-)?'); // optional -
18760
18762
  rxp = rxp.replace(/\[[NSEW, +-]{2,}\]/, '(?<prefix>$&)');
@@ -18778,11 +18780,11 @@
18778
18780
  rxp = '^' + rxp + '$';
18779
18781
  try {
18780
18782
  // TODO: make sure all DMS codes have been matched
18781
- cache[fmt] = new RegExp(rxp);
18783
+ cache$1[fmt] = new RegExp(rxp);
18782
18784
  } catch(e) {
18783
18785
  stop$1('Invalid DMS format string:', fmt);
18784
18786
  }
18785
- return cache[fmt];
18787
+ return cache$1[fmt];
18786
18788
  }
18787
18789
 
18788
18790
  function formatNumber(val, integers, decimals) {
@@ -20357,6 +20359,7 @@
20357
20359
  try {
20358
20360
  val = func.call(thisVal, rec, ctx);
20359
20361
  } catch(e) {
20362
+ if (opts.quiet) throw e;
20360
20363
  stop$1(e.name, "in expression [" + exp + "]:", e.message);
20361
20364
  }
20362
20365
  return val;
@@ -20378,7 +20381,10 @@
20378
20381
  try {
20379
20382
  return new Function('$$record,$$env', functionBody);
20380
20383
  } catch(e) {
20381
- // if (opts.quiet) throw e;
20384
+ // A caller that is only asking whether this string is an expression wants
20385
+ // the answer, not a report: stop() prints in the CLI and opens an alert in
20386
+ // the GUI, both of which are wrong for a question whose answer is "no".
20387
+ if (opts.quiet) throw e;
20382
20388
  stop$1(e.name, 'in expression [' + exp + ']');
20383
20389
  }
20384
20390
  }
@@ -20891,8 +20897,10 @@
20891
20897
  ctx2.height = function() {return getLayerBounds(lyr, arcs).height();};
20892
20898
  }
20893
20899
 
20894
- calc1 = compileFeatureExpression(exp, lyr, arcs, {context: ctx1,
20895
- no_assign: true, no_warn: true, no_return: true});
20900
+ // Replace && / || with commas so phase 1 evaluates every operand. Otherwise
20901
+ // short-circuit skips later capture calls (e.g. sum(a)>1 && sum(b)>1).
20902
+ calc1 = compileFeatureExpression(exp.replace(/&&|\|\|/g, ','), lyr, arcs, {
20903
+ context: ctx1, no_assign: true, no_warn: true, no_return: true});
20896
20904
  // changed data-only layer to full layer to expose layer geometry, etc
20897
20905
  // (why not do this originally?)
20898
20906
  // calc2 = compileFeatureExpression(exp, {data: lyr.data}, null,
@@ -25761,6 +25769,24 @@
25761
25769
  return text || text === 0; // accept numerical 0 as label text
25762
25770
  }
25763
25771
 
25772
+ // Whether a feature is a label at all, including one whose text is still empty.
25773
+ //
25774
+ // The GUI needs this and export does not. A label is a label from the moment it
25775
+ // is created, before anything has been typed into it, and the editor has to
25776
+ // render a node for it: with no node there is nothing to see, nothing to click
25777
+ // and nowhere to put a caret, so a label the user just made would be invisible
25778
+ // and unrecoverable. Export has no editor and drops the empty ones.
25779
+ function featureIsLabel(d) {
25780
+ return !!d && 'label-text' in d;
25781
+ }
25782
+
25783
+ var SvgFeatureUtils = /*#__PURE__*/Object.freeze({
25784
+ __proto__: null,
25785
+ featureHasLabel: featureHasLabel,
25786
+ featureHasSvgSymbol: featureHasSvgSymbol,
25787
+ featureIsLabel: featureIsLabel
25788
+ });
25789
+
25764
25790
  var assignmentRxp = /^([a-z0-9_+-]+)=(?!=)(.*)$/i; // exclude ==
25765
25791
 
25766
25792
  function splitShellTokens(str) {
@@ -26176,6 +26202,244 @@
26176
26202
  parsePattern: parsePattern
26177
26203
  });
26178
26204
 
26205
+ // public domain implementation
26206
+ // source: https://github.com/jbt/js-crypto
26207
+ function sha1(str1){
26208
+ for (
26209
+ var blockstart = 0,
26210
+ i = 0,
26211
+ W = [],
26212
+ A, B, C, D, F, G,
26213
+ H = [A=0x67452301, B=0xEFCDAB89, ~A, ~B, 0xC3D2E1F0],
26214
+ word_array = [],
26215
+ temp2,
26216
+ s = unescape(encodeURI(str1)),
26217
+ str_len = s.length;
26218
+
26219
+ i <= str_len;
26220
+ ){
26221
+ word_array[i >> 2] |= (s.charCodeAt(i)||128) << (8 * (3 - i++ % 4));
26222
+ }
26223
+ word_array[temp2 = ((str_len + 8) >> 2) | 15] = str_len << 3;
26224
+
26225
+ for (; blockstart <= temp2; blockstart += 16) {
26226
+ A = H; i = 0;
26227
+
26228
+ for (; i < 80;
26229
+ A = [[
26230
+ (G = ((s = A[0]) << 5 | s >>> 27) + A[4] + (W[i] = (i<16) ? ~~word_array[blockstart + i] : G << 1 | G >>> 31) + 1518500249) + ((B = A[1]) & (C = A[2]) | ~B & (D = A[3])),
26231
+ F = G + (B ^ C ^ D) + 341275144,
26232
+ G + (B & C | B & D | C & D) + 882459459,
26233
+ F + 1535694389
26234
+ ][0|((i++) / 20)] | 0, s, B << 30 | B >>> 2, C, D]
26235
+ ) {
26236
+ G = W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16];
26237
+ }
26238
+
26239
+ for(i = 5; i; ) H[--i] = H[i] + A[i] | 0;
26240
+ }
26241
+
26242
+ for(str1 = ''; i < 40; )str1 += (H[i >> 3] >> (7 - i++ % 8) * 4 & 15).toString(16);
26243
+ return str1;
26244
+ }
26245
+
26246
+ // How wide a label's text renders, which is the one thing about a label that
26247
+ // cannot be worked out from the data.
26248
+ //
26249
+ // Two features need it: `label-align`, which holds a block of text still while
26250
+ // the lines inside it re-justify (svg-label-align.mjs), and the path-fit check,
26251
+ // which drops a path label whose text is longer than its path
26252
+ // (svg-label-fit.mjs). Both need font metrics, which reach this module from
26253
+ // whichever of two places can supply them -- the GUI measures by rendering,
26254
+ // and Node reads the installed font files (mapshaper-text-measure.mjs) -- and
26255
+ // the answers are memoized here.
26256
+ //
26257
+ // **A measurement is not the user's data.** It is derived from values the user
26258
+ // did set -- the text and six font properties -- and it lives in this cache
26259
+ // alone: no column in the table, nothing exported, nothing for an edit to keep
26260
+ // in step with. See docs/development/label-tool-design.md.
26261
+ //
26262
+ // The cache is keyed by a fingerprint of exactly the values the width depends
26263
+ // on, which is what makes that safe: a width is a pure function of its inputs,
26264
+ // so a cache entry cannot go stale -- change the text or the font and the
26265
+ // fingerprint changes with it, and the lookup simply misses. Nothing has to be
26266
+ // invalidated, and a feature can be copied, merged, filtered or renumbered
26267
+ // without its measurement following it around, because the measurement was
26268
+ // never attached to the feature in the first place.
26269
+
26270
+ // Properties the width depends on. A change to any of them gives a different
26271
+ // fingerprint, so a measurement is never read for text it does not describe.
26272
+ //
26273
+ // Note that 'css' and 'class' are deliberately absent: both can change the
26274
+ // rendered font through a stylesheet mapshaper cannot see, so no fingerprint
26275
+ // over record values could detect it.
26276
+ var MEASURED_PROPERTIES = ['font-family', 'font-size', 'font-weight',
26277
+ 'font-style', 'font-stretch', 'letter-spacing'];
26278
+
26279
+ var HASH_LENGTH = 12;
26280
+
26281
+ // Entries are a dozen bytes of key and a number, and one per distinct text and
26282
+ // font -- typing a label adds one per keystroke, since each prefix is its own
26283
+ // string. The cap is generous enough never to be reached in an editing session
26284
+ // and small enough to bound a session that runs for days.
26285
+ var CACHE_LIMIT = 20000;
26286
+
26287
+ var cache = new Map();
26288
+ var measureFn = null;
26289
+ var measuring = false;
26290
+
26291
+ // Installed at startup, by the GUI in a browser and by mapshaper-api.mjs
26292
+ // outside one: (rec) -> width in px, or null.
26293
+ //
26294
+ // An inversion, and a deliberate one. The alternative was for the GUI to
26295
+ // measure ahead of every reader -- before each render, before each export,
26296
+ // after each edit -- which is three hooks to keep in step and a fourth for the
26297
+ // console, where a user can type -o svg without going near the export dialog.
26298
+ // A reader that can ask for a measurement needs no hooks at all, and where
26299
+ // nothing can be measured -- a font this machine has not got, a browser too
26300
+ // old to measure in -- every reader falls back exactly as it did before this
26301
+ // existed.
26302
+ function setTextMeasureFunction(fn) {
26303
+ measureFn = fn || null;
26304
+ }
26305
+
26306
+ // The width of @rec's text in px at its own font size, or null if it cannot be
26307
+ // known. Measures on demand and remembers the answer, including a failure to
26308
+ // measure, so a label that cannot be measured is not measured repeatedly.
26309
+ function getMeasuredTextWidth(rec) {
26310
+ var hash, width;
26311
+ if (!rec || !rec['label-text']) return null;
26312
+ hash = getTextWidthKey(rec);
26313
+ if (cache.has(hash)) return cache.get(hash) || null;
26314
+ // Nothing to measure with is not an answer about this text, so it is not
26315
+ // remembered as one. Caching it would mean that whatever rendered before a
26316
+ // measure function was installed -- or during any window in which none is --
26317
+ // decided the width of that text for the rest of the session.
26318
+ if (!measureFn) return null;
26319
+ width = measure(rec);
26320
+ if (cache.size >= CACHE_LIMIT) cache.clear();
26321
+ cache.set(hash, width || 0);
26322
+ return width;
26323
+ }
26324
+
26325
+ // A measurement is itself a render, so it must not ask for one: rendering an
26326
+ // aligned label reads a width, and a measurement taken in the middle of that
26327
+ // would be measuring in order to measure. The GUI's measure function drops
26328
+ // label-align for this reason; the flag is the backstop.
26329
+ function measure(rec) {
26330
+ var width;
26331
+ if (measuring) return null;
26332
+ measuring = true;
26333
+ try {
26334
+ width = measureFn(rec);
26335
+ } catch (e) {
26336
+ width = null;
26337
+ }
26338
+ measuring = false;
26339
+ return width > 0 ? width : null;
26340
+ }
26341
+
26342
+ // Fingerprint of the values a width depends on, and so the key it is kept
26343
+ // under.
26344
+ function getTextWidthKey(rec) {
26345
+ var parts = [toHashInput(rec && rec['label-text'])];
26346
+ for (var i = 0; i < MEASURED_PROPERTIES.length; i++) {
26347
+ parts.push(toHashInput(rec && rec[MEASURED_PROPERTIES[i]]));
26348
+ }
26349
+ // \n is safe as a separator: label-text may contain newlines, but it is the
26350
+ // only multi-line field and it is always first
26351
+ return sha1(parts.join('\n')).substr(0, HASH_LENGTH);
26352
+ }
26353
+
26354
+ function toHashInput(val) {
26355
+ return val === null || val === undefined ? '' : String(val);
26356
+ }
26357
+
26358
+ // For tests, and for a session that wants to measure again from scratch.
26359
+ function clearTextWidthCache() {
26360
+ cache.clear();
26361
+ }
26362
+
26363
+ function getTextWidthCacheSize() {
26364
+ return cache.size;
26365
+ }
26366
+
26367
+ var SvgLabelMetrics = /*#__PURE__*/Object.freeze({
26368
+ __proto__: null,
26369
+ clearTextWidthCache: clearTextWidthCache,
26370
+ getMeasuredTextWidth: getMeasuredTextWidth,
26371
+ getTextWidthCacheSize: getTextWidthCacheSize,
26372
+ getTextWidthKey: getTextWidthKey,
26373
+ setTextMeasureFunction: setTextMeasureFunction
26374
+ });
26375
+
26376
+ // Line justification, as a property of its own because SVG has no such thing.
26377
+ //
26378
+ // text-anchor does two jobs at once: it justifies the lines of a label, and it
26379
+ // decides where the block of them sits relative to x. A label pinned north of
26380
+ // its anchor is centred on x because that is what its position means, so a user
26381
+ // who asks for left-aligned lines and gets text-anchor=start has answered the
26382
+ // first question and silently changed the answer to the second: the block
26383
+ // slides half its own width to the right, off the point it labels.
26384
+ //
26385
+ // label-align asks only the first question. Holding the block still while the
26386
+ // lines re-justify means moving x the other way by the same amount, and the
26387
+ // amount is half or all of the block's width -- a font metric, which is why
26388
+ // this needs a measurement rather than arithmetic. The width comes from
26389
+ // svg-label-metrics.mjs, which measures in the GUI and remembers.
26390
+ //
26391
+ // With no usable measurement the lines are still re-justified and the block
26392
+ // still moves. A label that reads the way it was asked to read, in the wrong
26393
+ // place, is closer to the request than one that ignores it -- and the block was
26394
+ // moving before this property existed.
26395
+ //
26396
+ // See docs/development/label-tool-design.md.
26397
+
26398
+ // How far to the left of x the text sits, as a fraction of its own width
26399
+ var anchorOffsets = {start: 0, middle: 0.5, end: 1};
26400
+
26401
+ var alignAnchors = {left: 'start', center: 'middle', right: 'end'};
26402
+
26403
+ function parseLabelAlign(str) {
26404
+ var align = String(str).trim().toLowerCase();
26405
+ return align in alignAnchors ? align : null;
26406
+ }
26407
+
26408
+ // The text-anchor an alignment is rendered as, or null if there isn't one --
26409
+ // an unset property, or a value that reached the record from an expression or
26410
+ // a data file rather than through the commands, which reject one.
26411
+ function getAlignmentAnchor(align) {
26412
+ var parsed = align ? parseLabelAlign(align) : null;
26413
+ return parsed ? alignAnchors[parsed] : null;
26414
+ }
26415
+
26416
+ // How far x has to move to leave the block where it was, in px. Zero unless
26417
+ // the record carries an alignment that disagrees with the anchor its position
26418
+ // implies and a measurement to work from.
26419
+ //
26420
+ // @positionAnchor is supplied by the caller rather than read from the record,
26421
+ // partly to keep this module out of a cycle with the position table in
26422
+ // svg-properties.mjs, and partly because the comparison is against the anchor
26423
+ // the *position* implies: an explicit text-anchor is the thing label-align
26424
+ // replaces, so treating it as where the block belongs would hold the label in
26425
+ // a place it was never drawn.
26426
+ function getAlignmentShift(rec, positionAnchor) {
26427
+ var anchor = getAlignmentAnchor(rec && rec['label-align']);
26428
+ var width;
26429
+ if (!anchor || anchor == positionAnchor) return 0;
26430
+ width = getMeasuredTextWidth(rec);
26431
+ if (!width) return 0;
26432
+ return (anchorOffsets[anchor] - anchorOffsets[positionAnchor]) * width;
26433
+ }
26434
+
26435
+ var SvgLabelAlign = /*#__PURE__*/Object.freeze({
26436
+ __proto__: null,
26437
+ getAlignmentAnchor: getAlignmentAnchor,
26438
+ getAlignmentShift: getAlignmentShift,
26439
+ getMeasuredTextWidth: getMeasuredTextWidth,
26440
+ parseLabelAlign: parseLabelAlign
26441
+ });
26442
+
26179
26443
  // parsing hints for -style command cli options
26180
26444
  // null values indicate the lack of a function for parsing/identifying this property
26181
26445
  // (in which case a heuristic is used for distinguishing a string literal from an expression)
@@ -26183,6 +26447,7 @@
26183
26447
  // css: null,
26184
26448
  css: 'inlinecss',
26185
26449
  class: 'classname',
26450
+ 'dominant-baseline': null,
26186
26451
  dx: 'measure',
26187
26452
  dy: 'measure',
26188
26453
  fill: 'color',
@@ -26195,8 +26460,20 @@
26195
26460
  'font-weight': null,
26196
26461
  icon: null,
26197
26462
  'icon-color': 'color',
26463
+ // opacity of the symbol at a label's anchor, apart from the text's. Needed
26464
+ // because a label's own opacity properties are applied to both of the
26465
+ // elements its record produces -- see getIconStyleData().
26466
+ 'icon-opacity': 'number',
26198
26467
  'icon-size': 'number',
26468
+ // how the lines of a multi-line label line up with each other, as against
26469
+ // text-anchor, which also decides where the block of them sits -- see
26470
+ // svg-label-align.mjs
26471
+ 'label-align': 'labelalign',
26199
26472
  'label-pos': 'labelposition',
26473
+ // which side of its path a label's text sits on
26474
+ 'label-side': null,
26475
+ // where the text starts along its path; a length or a percentage
26476
+ 'label-start-offset': null,
26200
26477
  'label-text': null, // leaving this null
26201
26478
  'letter-spacing': 'measure',
26202
26479
  'line-height': 'measure',
@@ -26212,6 +26489,15 @@
26212
26489
  'text-anchor': null
26213
26490
  };
26214
26491
 
26492
+ // Properties an empty string is a value for rather than the absence of one:
26493
+ // the text of a label, which is empty while it is being typed, and the two
26494
+ // that take any string at all. See emptyValueUnsetsProperty().
26495
+ var propertiesTakingEmptyValues = {
26496
+ 'label-text': true,
26497
+ css: true,
26498
+ class: true
26499
+ };
26500
+
26215
26501
  // The -symbols command accepts some options that are not supported by -style
26216
26502
  // (different symbol types accept different combinations of properties...)
26217
26503
  var symbolPropertyTypes = utils.extend({
@@ -26263,18 +26549,25 @@
26263
26549
  'fill,font-family,font-size,text-anchor,font-weight,font-style,font-stretch,letter-spacing,dominant-baseline'.split(',')))
26264
26550
  };
26265
26551
 
26266
- var labelPositionFields = ['label-pos', 'dx', 'dy', 'text-anchor'];
26552
+ // The properties a label position stands for, and the shorthand itself.
26553
+ var labelPositionDerivedFields = ['dx', 'dy', 'text-anchor'];
26554
+ var labelPositionFields = ['label-pos'].concat(labelPositionDerivedFields);
26267
26555
 
26556
+ // dx is '0' and not 0 in the centred positions, so that every position in the
26557
+ // table has the same type. These values are normally resolved for rendering and
26558
+ // never stored, but dragging a label materializes them into its record, and a
26559
+ // column holding 0 from one position and '0.45em' from another is one
26560
+ // -merge-layers refuses.
26268
26561
  var labelPositionStyles = {
26269
- n: {dx: 0, dy: '-0.5em', 'text-anchor': 'middle'},
26270
- s: {dx: 0, dy: '1.1em', 'text-anchor': 'middle'},
26562
+ n: {dx: '0', dy: '-0.5em', 'text-anchor': 'middle'},
26563
+ s: {dx: '0', dy: '1.1em', 'text-anchor': 'middle'},
26271
26564
  e: {dx: '0.45em', dy: '0.23em', 'text-anchor': 'start'},
26272
26565
  w: {dx: '-0.45em', dy: '0.23em', 'text-anchor': 'end'},
26273
26566
  ne: {dx: '0.4em', dy: '-0.15em', 'text-anchor': 'start'},
26274
26567
  se: {dx: '0.4em', dy: '0.7em', 'text-anchor': 'start'},
26275
26568
  nw: {dx: '-0.4em', dy: '-0.15em', 'text-anchor': 'end'},
26276
26569
  sw: {dx: '-0.4em', dy: '0.7em', 'text-anchor': 'end'},
26277
- c: {dx: 0, dy: '0.25em', 'text-anchor': 'middle'}
26570
+ c: {dx: '0', dy: '0.25em', 'text-anchor': 'middle'}
26278
26571
  };
26279
26572
 
26280
26573
  // symType: point, polygon, polyline, label
@@ -26303,6 +26596,46 @@
26303
26596
  return name in stylePropertyTypes;
26304
26597
  }
26305
26598
 
26599
+ // Whether an empty value removes this property rather than being stored in it.
26600
+ //
26601
+ // True for a property with no empty value to store, like a number, a color or a
26602
+ // font weight: -style fill= takes the fill back off a feature, where it used to
26603
+ // be an error. It is the only per-property unset there is -- -style clear
26604
+ // removes every style property at once -- and the panel needs one, since a
26605
+ // control returning to its default has to be able to say so.
26606
+ //
26607
+ // False where the empty string is itself a value: inline css, a class name, and
26608
+ // the text of a label, which is empty while it is being typed. Everything else
26609
+ // with no type rule -- a font family, a text-anchor, an icon name -- has no
26610
+ // meaning for an empty string either, so storing one there would leave a column
26611
+ // of nothing behind and an attribute the renderer has to ignore.
26612
+ function emptyValueUnsetsProperty(name) {
26613
+ if (!(name in stylePropertyTypes) || name in propertiesTakingEmptyValues) {
26614
+ return false;
26615
+ }
26616
+ var type = stylePropertyTypes[name];
26617
+ return !type || parseSvgLiteralValue('', type) === null;
26618
+ }
26619
+
26620
+ // Converts a style value to the type that property is stored in -- the same
26621
+ // conversion -style applies to a literal. Returns undefined if the value is not
26622
+ // usable for the property, and the value unchanged for a property with no type
26623
+ // rule, where any string is a literal.
26624
+ //
26625
+ // -style resolves a value three ways: as a literal, as the name of a data field
26626
+ // or as an expression over the feature. A command that sets properties on a
26627
+ // single feature it is creating has no feature to read a field from, so it
26628
+ // wants the first of those on its own -- but it has to agree with -style about
26629
+ // the result, or the same value given to the two commands ends up stored as two
26630
+ // different types in one column.
26631
+ function parseStyleLiteral(name, val) {
26632
+ var type = stylePropertyTypes[name];
26633
+ var parsed;
26634
+ if (!type) return val; // no rule for this property: the value is the value
26635
+ parsed = parseSvgLiteralValue(String(val).trim(), type);
26636
+ return parsed === null ? undefined : parsed;
26637
+ }
26638
+
26306
26639
  function isSupportedSvgSymbolProperty(name) {
26307
26640
  return name in symbolPropertyTypes;
26308
26641
  }
@@ -26406,10 +26739,18 @@
26406
26739
  stop$1('Unexpected value for', name + ':', strVal);
26407
26740
  }
26408
26741
 
26742
+ // Whether @strVal works as an expression, and the function if it does.
26743
+ //
26744
+ // This is a guess being checked, so the failures are expected and have to be
26745
+ // silent: quiet keeps the expression compiler from reporting them, which in
26746
+ // the GUI means an alert over a style change that went on to work perfectly
26747
+ // well -- a label typed as "Saint-Denis" or an offset of "59.77%" is not an
26748
+ // expression, and neither is an error.
26409
26749
  function parseStyleExpression(strVal, lyr) {
26410
26750
  var func;
26411
26751
  try {
26412
- func = compileFeatureExpression(strVal, lyr, null, {no_warn: true});
26752
+ func = compileFeatureExpression(strVal, lyr, null,
26753
+ {no_warn: true, quiet: true});
26413
26754
  func(0); // check for runtime errors (e.g. undefined variables)
26414
26755
  } catch(e) {
26415
26756
  func = null;
@@ -26439,6 +26780,8 @@
26439
26780
  val = strVal; // TODO: validate
26440
26781
  } else if (type == 'labelposition') {
26441
26782
  val = parseLabelPosition(strVal);
26783
+ } else if (type == 'labelalign') {
26784
+ val = parseLabelAlign(strVal);
26442
26785
  }
26443
26786
  // else {
26444
26787
  // // unknown type -- assume literal value
@@ -26481,13 +26824,67 @@
26481
26824
  return Object.assign({'label-pos': pos}, labelPositionStyles[pos.toLowerCase()]);
26482
26825
  }
26483
26826
 
26484
- function setLabelPositionStyle(rec, pos) {
26485
- var style = getLabelPositionStyle(pos);
26486
- if (!style) return false;
26487
- labelPositionFields.forEach(function(field) {
26488
- rec[field] = style[field];
26489
- });
26490
- return true;
26827
+ // Fills in the offsets and justification a label's position stands for, for
26828
+ // rendering. Returns @rec itself when there is nothing to add, and a copy when
26829
+ // there is: a renderer must not write into the record it was handed.
26830
+ //
26831
+ // label-pos is the only one of the four that is stored. The other three used to
26832
+ // be written alongside it, which made the table below a code generator rather
26833
+ // than a lookup: four columns in the user's data where one was meant, and the
26834
+ // table's own values -- '0' next to '0.45em' -- became the types of a column
26835
+ // that -merge-layers then had to agree about.
26836
+ //
26837
+ // A value on the record wins over the position, per property, so that
26838
+ // `label-pos=n dx=3` reads as "north, nudged 3px right" rather than losing the
26839
+ // north. That is also what makes this change invisible to files written before
26840
+ // it: they carry all three alongside label-pos, with exactly the values this
26841
+ // would supply.
26842
+ function resolveLabelPosition(rec) {
26843
+ var style = rec && rec['label-pos'] ? getLabelPositionStyle(rec['label-pos']) : null;
26844
+ var out = null;
26845
+ var field, i;
26846
+ // An unusable position renders as if it were unset. The commands that set it
26847
+ // reject one, so reaching here means it was written by an expression or came
26848
+ // from a data file, where stopping the render is the wrong response.
26849
+ if (style) {
26850
+ for (i = 0; i < labelPositionDerivedFields.length; i++) {
26851
+ field = labelPositionDerivedFields[i];
26852
+ if (hasStyleValue(rec, field)) continue;
26853
+ if (!out) out = Object.assign({}, rec);
26854
+ out[field] = style[field];
26855
+ }
26856
+ }
26857
+ out = resolveLabelAlignment(out || rec) || out;
26858
+ return out || rec;
26859
+ }
26860
+
26861
+ // label-align wins over both the position's justification and a text-anchor of
26862
+ // the record's own, because it is the only one of the three that is asking
26863
+ // about justification alone. Where the block ends up is then the renderer's to
26864
+ // correct -- see getAlignmentShift().
26865
+ function resolveLabelAlignment(rec) {
26866
+ var anchor = getAlignmentAnchor(rec['label-align']);
26867
+ var out;
26868
+ if (!anchor || rec['text-anchor'] === anchor) return null;
26869
+ out = Object.assign({}, rec);
26870
+ out['text-anchor'] = anchor;
26871
+ return out;
26872
+ }
26873
+
26874
+ // The anchor a label's position implies, which is where its block of text is
26875
+ // drawn whatever the lines inside it do. 'start' is both the SVG default and
26876
+ // what an unpositioned label is drawn with.
26877
+ function getLabelPositionAnchor(rec) {
26878
+ var style = rec && rec['label-pos'] ? getLabelPositionStyle(rec['label-pos']) : null;
26879
+ return style && style['text-anchor'] || 'start';
26880
+ }
26881
+
26882
+ // Presence, not truthiness. `dy=0` is how a label cancels the vertical offset
26883
+ // its position carries, and the `rec.dy || 0` idiom used by the renderers would
26884
+ // read that as absent and hand back the offset it was written to remove.
26885
+ function hasStyleValue(rec, field) {
26886
+ var val = rec[field];
26887
+ return field in rec && val !== undefined && val !== null && val !== '';
26491
26888
  }
26492
26889
 
26493
26890
  function isSvgMeasure(o) {
@@ -26507,25 +26904,42 @@
26507
26904
  var SvgProperties = /*#__PURE__*/Object.freeze({
26508
26905
  __proto__: null,
26509
26906
  applyStyleAttributes: applyStyleAttributes,
26907
+ emptyValueUnsetsProperty: emptyValueUnsetsProperty,
26510
26908
  findStylePropertiesBySymbolGeom: findStylePropertiesBySymbolGeom,
26909
+ getLabelPositionAnchor: getLabelPositionAnchor,
26511
26910
  getLabelPositionStyle: getLabelPositionStyle,
26512
26911
  getPropertyAccessor: getPropertyAccessor,
26513
26912
  getSymbolDataAccessor: getSymbolDataAccessor,
26514
26913
  getSymbolListAccessor: getSymbolListAccessor,
26515
26914
  getSymbolPropertyAccessor: getSymbolPropertyAccessor,
26915
+ hasStyleValue: hasStyleValue,
26516
26916
  isSupportedSvgStyleProperty: isSupportedSvgStyleProperty,
26517
26917
  isSvgClassName: isSvgClassName,
26518
26918
  isSvgColor: isSvgColor,
26519
26919
  isSvgMeasure: isSvgMeasure,
26520
26920
  isSvgNumber: isSvgNumber,
26921
+ labelPositionDerivedFields: labelPositionDerivedFields,
26521
26922
  labelPositionFields: labelPositionFields,
26522
26923
  mightBeExpression: mightBeExpression,
26523
26924
  parseBoolean: parseBoolean,
26524
26925
  parseLabelPosition: parseLabelPosition,
26926
+ parseStyleLiteral: parseStyleLiteral,
26525
26927
  parseSvgMeasure: parseSvgMeasure,
26526
- setLabelPositionStyle: setLabelPositionStyle
26928
+ resolveLabelPosition: resolveLabelPosition
26527
26929
  });
26528
26930
 
26931
+ // Accepting \n (two chars) as an alternative to the newline character
26932
+ // (sometimes, '\n' is not converted to newline, e.g. in a Makefile)
26933
+ // Also accepting <br>
26934
+ var labelNewlineRxp = /\n|\\n|<br>/i;
26935
+
26936
+ // The size a label is drawn at when it carries none of its own, which is the
26937
+ // size its layer's group supplies (see getLabelTextDefaults()). Shared with
26938
+ // that function rather than written twice: the em offsets the label positions
26939
+ // use are resolved against this number, and a correction computed against a
26940
+ // different one would put the label somewhere it is not drawn.
26941
+ var DEFAULT_LABEL_FONT_SIZE = 12;
26942
+
26529
26943
  function toLabelString(val) {
26530
26944
  if (val || val === 0 || val === false) return String(val);
26531
26945
  return '';
@@ -26533,20 +26947,25 @@
26533
26947
 
26534
26948
  // Kludge for applying fill and other styles to a <text> element
26535
26949
  // (for rendering labels in the GUI with the dot in Canvas, not SVG)
26536
- function renderStyledLabel(rec) {
26950
+ function renderStyledLabel(recArg) {
26951
+ // Resolved once and used for both, so that the offsets a position implies and
26952
+ // the justification it implies cannot come from different places: text-anchor
26953
+ // is written by applyStyleAttributes() and dx/dy by renderLabel().
26954
+ var rec = resolveLabelPosition(recArg);
26537
26955
  var o = renderLabel$1(rec);
26538
26956
  applyStyleAttributes(o, 'label', rec);
26539
26957
  return o;
26540
26958
  }
26541
26959
 
26542
- function renderLabel$1(rec) {
26960
+ function renderLabel$1(recArg) {
26961
+ // Idempotent, and free on a record that has no position to resolve or has
26962
+ // already been through it, so calling it here as well costs nothing and means
26963
+ // every way into the renderer draws a label in the position it is stored in.
26964
+ var rec = resolveLabelPosition(recArg);
26543
26965
  var line = toLabelString(rec['label-text']);
26544
26966
  var morelines, obj;
26545
- // Accepting \n (two chars) as an alternative to the newline character
26546
- // (sometimes, '\n' is not converted to newline, e.g. in a Makefile)
26547
- // Also accepting <br>
26548
- var newline = /\n|\\n|<br>/i;
26549
- var dx = rec.dx || 0;
26967
+ var newline = labelNewlineRxp;
26968
+ var dx = applyAlignmentShift(rec);
26550
26969
  var dy = rec.dy || 0;
26551
26970
  var properties = {
26552
26971
  // using x, y instead of dx, dy for shift, because Illustrator doesn't apply
@@ -26581,10 +27000,96 @@
26581
27000
  return obj;
26582
27001
  }
26583
27002
 
27003
+ // The label's dx, moved to leave its block of text where its position put it
27004
+ // when label-align re-justifies the lines inside it. Returns dx untouched when
27005
+ // there is nothing to correct, so a label without an alignment keeps the value
27006
+ // it was stored with, in the units it was stored in.
27007
+ //
27008
+ // Only anchored labels are corrected. A path label's text follows its curve
27009
+ // from a start offset, so its alignment picks which part of the text sits at
27010
+ // that point -- there is no block beside an anchor to hold still, and its dx
27011
+ // means an offset from the path rather than from a point.
27012
+ function applyAlignmentShift(rec) {
27013
+ var dx = rec.dx || 0;
27014
+ var shift = getAlignmentShift(rec, getLabelPositionAnchor(rec));
27015
+ var px;
27016
+ if (!shift) return dx;
27017
+ px = toPixels(dx, rec['font-size']);
27018
+ // A dx in units the shift cannot be added to -- pt, %, anything but px and
27019
+ // em -- keeps its value, and the block moves as it did before. Correcting it
27020
+ // would mean choosing a pixel size for a unit whose whole point is that
27021
+ // something else decides.
27022
+ if (px === null) return dx;
27023
+ return roundShift(px + shift);
27024
+ }
27025
+
27026
+ // Everything a record says about where its text sits relative to its anchor,
27027
+ // resolved into numbers: the offsets the text is drawn at in px, and the
27028
+ // justification it is drawn with.
27029
+ //
27030
+ // This is what dragging a label starts from. A drag materializes the position
27031
+ // the label was in and adds its own delta to it, and the numbers it starts
27032
+ // from have to be the ones the label is actually drawn with -- otherwise the
27033
+ // text jumps on the first pixel of movement. Hence resolving it here, through
27034
+ // the same functions as the renderer above, rather than in the GUI against a
27035
+ // second copy of the rules.
27036
+ //
27037
+ // An offset in units this cannot convert -- pt, %, anything but px and em --
27038
+ // resolves to 0. The alternative is refusing the drag over a value that
27039
+ // reaches a label only from an expression or a data file, and a drag that puts
27040
+ // the text where the pointer is says more about where it went than a gesture
27041
+ // that does nothing.
27042
+ function getDrawnLabelOffset(recArg) {
27043
+ var rec = resolveLabelPosition(recArg);
27044
+ var dx = toPixels(applyAlignmentShift(rec), rec['font-size']);
27045
+ var dy = toPixels(rec.dy || 0, rec['font-size']);
27046
+ return {
27047
+ dx: dx === null ? 0 : dx,
27048
+ dy: dy === null ? 0 : dy,
27049
+ 'text-anchor': rec['text-anchor'] || ''
27050
+ };
27051
+ }
27052
+
27053
+ // A measure in px, or null if it cannot be known.
27054
+ //
27055
+ // An em value needs the font size it is relative to. Most labels do not carry
27056
+ // one: the six positions that hold text clear of its anchor offset it in ems,
27057
+ // and font-size is usually inherited from the layer's group rather than set on
27058
+ // the label -- so a missing size is the normal case here and not a reason to
27059
+ // give up. It resolves against the same default the renderer applies, which is
27060
+ // why that default is one constant and not two.
27061
+ //
27062
+ // What is left is a size that is neither a number nor absent, or a measure in
27063
+ // units the correction cannot be expressed in -- pt, %, anything but px and
27064
+ // em. Those keep their value, because correcting them would mean choosing a
27065
+ // pixel size for a unit whose whole point is that something else decides.
27066
+ function toPixels(measure, fontSizeArg) {
27067
+ var val = parseSvgMeasure(measure);
27068
+ var fontSize = fontSizeArg === undefined || fontSizeArg === null ||
27069
+ fontSizeArg === '' ? DEFAULT_LABEL_FONT_SIZE : Number(fontSizeArg);
27070
+ var em;
27071
+ if (utils.isFiniteNumber(val)) return val;
27072
+ em = /^(-?[.0-9]+)em$/.exec(String(val));
27073
+ if (!em) return null;
27074
+ if (fontSize > 0 === false) return null;
27075
+ return Number(em[1]) * fontSize;
27076
+ }
27077
+
27078
+ // Tenths of a pixel. The serializer prints what it is given, and a correction
27079
+ // is a product of two measurements -- left alone it arrives as
27080
+ // 4.800000000000001.
27081
+ function roundShift(px) {
27082
+ return Math.round(px * 10) / 10;
27083
+ }
27084
+
26584
27085
  var SvgLabels = /*#__PURE__*/Object.freeze({
26585
27086
  __proto__: null,
27087
+ DEFAULT_LABEL_FONT_SIZE: DEFAULT_LABEL_FONT_SIZE,
27088
+ getDrawnLabelOffset: getDrawnLabelOffset,
27089
+ labelNewlineRxp: labelNewlineRxp,
26586
27090
  renderLabel: renderLabel$1,
26587
- renderStyledLabel: renderStyledLabel
27091
+ renderStyledLabel: renderStyledLabel,
27092
+ toLabelString: toLabelString
26588
27093
  });
26589
27094
 
26590
27095
  // convert data records (properties like svg-symbol, label-text, fill, r) to svg symbols
@@ -26672,10 +27177,20 @@
26672
27177
  return size / 2;
26673
27178
  }
26674
27179
 
27180
+ // A label with a symbol renders two elements from one record, and every opacity
27181
+ // property in commonProperties is applied to both, so the text's opacity fades
27182
+ // the symbol with it. icon-color and icon-opacity are how a symbol is styled
27183
+ // apart from the text: each overrides its counterpart here.
27184
+ //
27185
+ // icon-opacity maps to opacity rather than fill-opacity because a ring is drawn
27186
+ // as a stroked circle with no fill, which a fill opacity would leave alone.
26675
27187
  function getIconStyleData(d, r) {
26676
27188
  var o = utils.extend({}, d);
26677
27189
  o.r = r;
26678
27190
  o.fill = d['icon-color'] || d.fill || 'black';
27191
+ if (isSvgNumber(d['icon-opacity'])) {
27192
+ o.opacity = Number(d['icon-opacity']);
27193
+ }
26679
27194
  return o;
26680
27195
  }
26681
27196
 
@@ -27179,16 +27694,19 @@
27179
27694
  renderFurnitureLayer: renderFurnitureLayer
27180
27695
  });
27181
27696
 
27697
+ // Tags whose content is rendered text, where whitespace added for legibility
27698
+ // would show up in the output.
27699
+ var textTags = {text: true, tspan: true, textPath: true};
27700
+
27182
27701
  function stringify(obj) {
27183
27702
  var svg, joinStr;
27184
27703
  if (!obj || !obj.tag) return '';
27185
27704
  svg = '<' + obj.tag;
27186
- // w.s. is significant in text elements
27187
27705
  if (obj.properties) {
27188
27706
  svg += stringifyProperties(obj.properties);
27189
27707
  }
27190
27708
  if (obj.children || obj.value) {
27191
- joinStr = obj.tag == 'text' || obj.tag == 'tspan' ? '' : '\n';
27709
+ joinStr = obj.tag in textTags ? '' : '\n';
27192
27710
  svg += '>' + joinStr;
27193
27711
  if (obj.value) {
27194
27712
  svg += stringEscape(obj.value);
@@ -27247,47 +27765,618 @@
27247
27765
  stringifyProperties: stringifyProperties
27248
27766
  });
27249
27767
 
27250
- // public domain implementation
27251
- // source: https://github.com/jbt/js-crypto
27252
- function sha1(str1){
27253
- for (
27254
- var blockstart = 0,
27255
- i = 0,
27256
- W = [],
27257
- A, B, C, D, F, G,
27258
- H = [A=0x67452301, B=0xEFCDAB89, ~A, ~B, 0xC3D2E1F0],
27259
- word_array = [],
27260
- temp2,
27261
- s = unescape(encodeURI(str1)),
27262
- str_len = s.length;
27768
+ // Fits an interpolating spline through a sequence of knots and flattens it to
27769
+ // a polyline. Used to derive a label path from the knots stored as a label's
27770
+ // multipoint geometry (see docs/development/label-tool-design.md).
27771
+ //
27772
+ // The curve passes through every knot -- knots are not Bezier handles. This is
27773
+ // the behavior of Illustrator's Curvature tool, and it is what lets a curve be
27774
+ // re-edited by moving the points the user originally clicked.
27775
+ //
27776
+ // The fit is Hobby's algorithm: John Hobby, "Smooth, Easy to Compute
27777
+ // Interpolating Splines", Stanford CS-TR-85-1047 (1985) / Discrete &
27778
+ // Computational Geometry 1 (1986). It is the spline Metafont, MetaPost and
27779
+ // TikZ draw, chosen here because it was designed for the aesthetics of the
27780
+ // result rather than for analytic tidiness.
27781
+ //
27782
+ // Hobby picks a tangent direction at each knot such that "mock curvature" -- a
27783
+ // first-order approximation of curvature that keeps the system linear -- is
27784
+ // equal on both sides of the knot. The curve is therefore near enough to
27785
+ // curvature-continuous to look it, where an earlier centripetal Catmull-Rom
27786
+ // fit here was only tangent-continuous and visibly kinked at every knot: its
27787
+ // curvature jumped by 55% to 220% of the curve's mean curvature at each one.
27788
+ //
27789
+ // Two properties of the method matter to the label tool. The solve is a single
27790
+ // tridiagonal system rather than an iteration, so the fit is deterministic and
27791
+ // the GUI preview and the CLI export cannot drift apart. And the curves are
27792
+ // invariant under translation, rotation and scaling (Hobby 1986), which is
27793
+ // what lets the tool fit in display coordinates while storing knots in map
27794
+ // coordinates.
27795
+
27796
+ // Curl controls how the curve behaves at the two ends of a run. At 0 it
27797
+ // approaches a straight line there; at 1 (Metafont's default) it approaches a
27798
+ // circular arc, which on a path that turns hard near its end throws the curve
27799
+ // into a wide loop beyond the last knot. Label paths are short enough that the
27800
+ // end segments are most of the curve, so they want a much flatter end than
27801
+ // Metafont does.
27802
+ var DEFAULT_CURL = 0.15;
27803
+ var curl = DEFAULT_CURL;
27804
+
27805
+ // Temporary knob for judging the end behavior by eye; see
27806
+ // window.mapshaper.setLabelCurveCurl() in the GUI. Expected to be retired once
27807
+ // a default is settled on.
27808
+ function setCurveCurl(val) {
27809
+ curl = val >= 0 ? +val : DEFAULT_CURL;
27810
+ return curl;
27811
+ }
27812
+
27813
+ function getCurveCurl() {
27814
+ return curl;
27815
+ }
27816
+
27817
+ // Cap on subdivision levels per curve segment, bounding output at 2^8 = 256
27818
+ // vertices per segment if a pathological input (e.g. knots far enough apart to
27819
+ // exhaust float precision) never satisfies the flatness test.
27820
+ var MAX_SUBDIVIDE_DEPTH = 8;
27821
+
27822
+ // Relative accuracy of the arc-length estimate, and a matching depth cap. The
27823
+ // bracket between a cubic's chord and its control polygon narrows by roughly a
27824
+ // factor of four per subdivision, so this converges in well under ten levels.
27825
+ var LENGTH_TOLERANCE = 1e-5;
27826
+ var MAX_LENGTH_DEPTH = 16;
27827
+
27828
+ // knots: array of [x, y] in a single coordinate space
27829
+ // tolerance: max deviation of the output polyline from the true curve, in the
27830
+ // same units as the knots
27831
+ // Returns an array of [x, y] starting at the first knot, ending at the last,
27832
+ // and passing through every knot in between.
27833
+ function fitCurveThroughKnots(knots, tolerance) {
27834
+ var pts = dedupeKnots(knots || []),
27835
+ segments, out, i;
27836
+ if (pts.length < 2) return pts;
27837
+ if (tolerance > 0 === false) {
27838
+ // with no usable tolerance there is nothing to flatten against; the knots
27839
+ // are the curve at its coarsest
27840
+ return pts;
27841
+ }
27842
+ segments = fitRun(pts);
27843
+ out = [pts[0].slice()];
27844
+ for (i = 0; i < segments.length; i++) {
27845
+ flattenCubic(segments[i].p0, segments[i].c1, segments[i].c2,
27846
+ segments[i].p3, tolerance, 0, out);
27847
+ out.push([segments[i].p3[0], segments[i].p3[1]]);
27848
+ }
27849
+ return out;
27850
+ }
27263
27851
 
27264
- i <= str_len;
27265
- ){
27266
- word_array[i >> 2] |= (s.charCodeAt(i)||128) << (8 * (3 - i++ % 4));
27852
+ // Returns the curve's control points as cubic Bezier segments, without
27853
+ // flattening. Kept separate from the flattening so that SVG export can emit
27854
+ // true curves rather than a densified polyline.
27855
+ // Returns [{p0, c1, c2, p3}, ...], one per knot interval.
27856
+ function getCurveSegments(knots) {
27857
+ var pts = dedupeKnots(knots || []);
27858
+ if (pts.length < 2) return [];
27859
+ return fitRun(pts);
27860
+ }
27861
+
27862
+ // Arc length of the fitted curve, in the same units as the knots. Export uses
27863
+ // this to decide whether a label's text fits its path, so it is measured on the
27864
+ // true curve rather than on a flattened approximation of it -- a flattening
27865
+ // tolerance tight enough to be accurate in output pixels would be meaningless
27866
+ // if the knots were in degrees or metres, and this estimate is scale-free.
27867
+ function getCurveLength(knots) {
27868
+ var segments = getCurveSegments(knots);
27869
+ var len = 0, i;
27870
+ for (i = 0; i < segments.length; i++) {
27871
+ len += getCubicLength(segments[i], 0);
27267
27872
  }
27268
- word_array[temp2 = ((str_len + 8) >> 2) | 15] = str_len << 3;
27873
+ return len;
27874
+ }
27269
27875
 
27270
- for (; blockstart <= temp2; blockstart += 16) {
27271
- A = H; i = 0;
27876
+ // Hobby's velocity function constants (Hobby 1986, eq. 11), the approximation
27877
+ // Metafont uses in place of the transcendental form.
27878
+ var VEL_A = Math.SQRT2,
27879
+ VEL_B = 1 / 16,
27880
+ VEL_C = (3 - Math.sqrt(5)) / 2;
27881
+
27882
+ // Fits a run of knots, returning a cubic per interval.
27883
+ function fitRun(pts) {
27884
+ var n = pts.length - 1, // segment count
27885
+ dd = [], om = [], psi = [], segments = [],
27886
+ theta, phi, i;
27887
+ if (n < 1) return [];
27888
+ // two knots have no interior knot to bend around
27889
+ if (n === 1) return [straightSegment(pts[0], pts[1])];
27890
+ for (i = 0; i < n; i++) {
27891
+ dd.push(distance2D$1(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]));
27892
+ om.push(Math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0]));
27893
+ }
27894
+ // psi[i] is how far the polyline turns at knot i
27895
+ psi.push(0);
27896
+ for (i = 1; i < n; i++) psi.push(wrapAngle(om[i] - om[i - 1]));
27272
27897
 
27273
- for (; i < 80;
27274
- A = [[
27275
- (G = ((s = A[0]) << 5 | s >>> 27) + A[4] + (W[i] = (i<16) ? ~~word_array[blockstart + i] : G << 1 | G >>> 31) + 1518500249) + ((B = A[1]) & (C = A[2]) | ~B & (D = A[3])),
27276
- F = G + (B ^ C ^ D) + 341275144,
27277
- G + (B & C | B & D | C & D) + 882459459,
27278
- F + 1535694389
27279
- ][0|((i++) / 20)] | 0, s, B << 30 | B >>> 2, C, D]
27280
- ) {
27281
- G = W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16];
27898
+ theta = solveDepartureAngles(dd, psi, n);
27899
+ phi = getArrivalAngles(theta, psi, n);
27900
+
27901
+ for (i = 0; i < n; i++) {
27902
+ segments.push(buildSegment(pts[i], pts[i + 1], dd[i], om[i],
27903
+ theta[i], phi[i + 1]));
27904
+ }
27905
+ return segments;
27906
+ }
27907
+
27908
+ // The tridiagonal system of Hobby's mock-curvature conditions, in the
27909
+ // departure angle at each knot measured from the chord leaving it. Written for
27910
+ // unit tension throughout; the general form carries a tension per knot, which
27911
+ // the label tool has no use for.
27912
+ //
27913
+ // The interior rows come from equalizing mock curvature across knot i; the
27914
+ // first and last rows are the curl conditions that close the system at the
27915
+ // ends of an open run.
27916
+ function solveDepartureAngles(dd, psi, n) {
27917
+ var lo = [], di = [], up = [], r = [], i;
27918
+ lo.push(0);
27919
+ di.push(2 + curl);
27920
+ up.push(1 + 2 * curl);
27921
+ r.push(-(1 + 2 * curl) * psi[1]);
27922
+ for (i = 1; i < n - 1; i++) {
27923
+ lo.push(dd[i]);
27924
+ di.push(2 * (dd[i] + dd[i - 1]));
27925
+ up.push(dd[i - 1]);
27926
+ r.push(-2 * dd[i] * psi[i] - dd[i - 1] * psi[i + 1]);
27927
+ }
27928
+ if (n > 1) {
27929
+ lo.push(dd[n - 1]);
27930
+ di.push(2 * (dd[n - 1] + dd[n - 2]) -
27931
+ dd[n - 2] * (1 + 2 * curl) / (2 + curl));
27932
+ up.push(0);
27933
+ r.push(-2 * dd[n - 1] * psi[n - 1]);
27934
+ }
27935
+ return solveTridiagonal(lo, di, up, r);
27936
+ }
27937
+
27938
+ // The angle at which the curve arrives at each knot, again measured from the
27939
+ // chord. At an interior knot the departure and arrival angles have to account
27940
+ // between them for the whole turn in the polyline; at the far end of the run
27941
+ // the curl condition fixes it.
27942
+ function getArrivalAngles(theta, psi, n) {
27943
+ var phi = [0], i;
27944
+ for (i = 1; i < n; i++) phi.push(-psi[i] - theta[i]);
27945
+ phi.push((1 + 2 * curl) * theta[n - 1] / (2 + curl));
27946
+ return phi;
27947
+ }
27948
+
27949
+ // Turns a pair of angles into a cubic, by way of Hobby's velocity functions --
27950
+ // how far along each tangent the control point sits, as a multiple of a third
27951
+ // of the chord.
27952
+ function buildSegment(p0, p1, d, om, theta, phi) {
27953
+ var alpha = VEL_A * (Math.sin(theta) - VEL_B * Math.sin(phi)) *
27954
+ (Math.sin(phi) - VEL_B * Math.sin(theta)) *
27955
+ (Math.cos(theta) - Math.cos(phi)),
27956
+ rho = (2 + alpha) /
27957
+ (1 + (1 - VEL_C) * Math.cos(theta) + VEL_C * Math.cos(phi)),
27958
+ sigma = (2 - alpha) /
27959
+ (1 + (1 - VEL_C) * Math.cos(phi) + VEL_C * Math.cos(theta));
27960
+ return {
27961
+ p0: p0,
27962
+ c1: [p0[0] + d * rho / 3 * Math.cos(theta + om),
27963
+ p0[1] + d * rho / 3 * Math.sin(theta + om)],
27964
+ c2: [p1[0] - d * sigma / 3 * Math.cos(om - phi),
27965
+ p1[1] - d * sigma / 3 * Math.sin(om - phi)],
27966
+ p3: p1
27967
+ };
27968
+ }
27969
+
27970
+ // A cubic that is exactly its chord, with the control points at the thirds --
27971
+ // the degenerate case of the above when both angles are zero.
27972
+ function straightSegment(a, b) {
27973
+ return {
27974
+ p0: a,
27975
+ c1: [a[0] + (b[0] - a[0]) / 3, a[1] + (b[1] - a[1]) / 3],
27976
+ c2: [a[0] + 2 * (b[0] - a[0]) / 3, a[1] + 2 * (b[1] - a[1]) / 3],
27977
+ p3: b
27978
+ };
27979
+ }
27980
+
27981
+ // Thomas algorithm. Hobby shows the system is diagonally dominant, so this
27982
+ // needs no pivoting.
27983
+ function solveTridiagonal(lo, di, up, r) {
27984
+ var n = di.length,
27985
+ cp = [], dp = [], x = new Array(n), i, m;
27986
+ cp.push(up[0] / di[0]);
27987
+ dp.push(r[0] / di[0]);
27988
+ for (i = 1; i < n; i++) {
27989
+ m = di[i] - lo[i] * cp[i - 1];
27990
+ cp.push(up[i] / m);
27991
+ dp.push((r[i] - lo[i] * dp[i - 1]) / m);
27992
+ }
27993
+ x[n - 1] = dp[n - 1];
27994
+ for (i = n - 2; i >= 0; i--) {
27995
+ x[i] = dp[i] - cp[i] * x[i + 1];
27996
+ }
27997
+ return x;
27998
+ }
27999
+
28000
+ // Into (-PI, PI], so that a turn is measured the short way round.
28001
+ function wrapAngle(a) {
28002
+ while (a > Math.PI) a -= 2 * Math.PI;
28003
+ while (a <= -Math.PI) a += 2 * Math.PI;
28004
+ return a;
28005
+ }
28006
+
28007
+ // A cubic's arc length is bracketed below by its chord and above by its control
28008
+ // polygon. Subdividing narrows the bracket; the midpoint of a narrow one is the
28009
+ // length. (Gravesen's method.)
28010
+ function getCubicLength(seg, depth) {
28011
+ var chord = distance2D$1(seg.p0[0], seg.p0[1], seg.p3[0], seg.p3[1]);
28012
+ var poly = distance2D$1(seg.p0[0], seg.p0[1], seg.c1[0], seg.c1[1]) +
28013
+ distance2D$1(seg.c1[0], seg.c1[1], seg.c2[0], seg.c2[1]) +
28014
+ distance2D$1(seg.c2[0], seg.c2[1], seg.p3[0], seg.p3[1]);
28015
+ var halves;
28016
+ if (poly === 0 || depth >= MAX_LENGTH_DEPTH ||
28017
+ poly - chord <= LENGTH_TOLERANCE * poly) {
28018
+ return (chord + poly) / 2;
28019
+ }
28020
+ halves = subdivideCubic(seg.p0, seg.c1, seg.c2, seg.p3);
28021
+ return getCubicLength(halves.left, depth + 1) +
28022
+ getCubicLength(halves.right, depth + 1);
28023
+ }
28024
+
28025
+ // Removes non-finite and consecutively duplicated knots. A duplicate spans a
28026
+ // zero-length chord, which would leave the direction of the polyline there
28027
+ // undefined -- and double-clicking to finish a path is an easy way to make one.
28028
+ function dedupeKnots(knots) {
28029
+ var points = [], i, p, prev;
28030
+ for (i = 0; i < knots.length; i++) {
28031
+ p = knots[i];
28032
+ if (!p || p.length < 2 || !isFiniteNumber$1(p[0]) || !isFiniteNumber$1(p[1])) continue;
28033
+ if (prev && p[0] === prev[0] && p[1] === prev[1]) continue;
28034
+ points.push([p[0], p[1]]);
28035
+ prev = p;
28036
+ }
28037
+ return points;
28038
+ }
28039
+
28040
+ // Recursively subdivides a cubic until its control points lie within
28041
+ // @tolerance of its chord, emitting only the vertices strictly inside the
28042
+ // curve -- the caller adds the endpoint.
28043
+ //
28044
+ // Testing the control points is conservative: a cubic's greatest deviation
28045
+ // from its chord is at most 3/4 of the greater control point distance, so the
28046
+ // flattened path stays within 0.75 * tolerance of the true curve.
28047
+ function flattenCubic(p0, c1, c2, p3, tolerance, depth, out) {
28048
+ var tolSq = tolerance * tolerance;
28049
+ var halves;
28050
+ if (depth >= MAX_SUBDIVIDE_DEPTH) return;
28051
+ if (pointSegDistSq2(c1[0], c1[1], p0[0], p0[1], p3[0], p3[1]) <= tolSq &&
28052
+ pointSegDistSq2(c2[0], c2[1], p0[0], p0[1], p3[0], p3[1]) <= tolSq) {
28053
+ return; // flat enough -- the chord stands in for the curve
28054
+ }
28055
+ halves = subdivideCubic(p0, c1, c2, p3);
28056
+ flattenCubic(halves.left.p0, halves.left.c1, halves.left.c2, halves.left.p3,
28057
+ tolerance, depth + 1, out);
28058
+ out.push([halves.left.p3[0], halves.left.p3[1]]); // curve midpoint
28059
+ flattenCubic(halves.right.p0, halves.right.c1, halves.right.c2, halves.right.p3,
28060
+ tolerance, depth + 1, out);
28061
+ }
28062
+
28063
+ // de Casteljau split at t = 0.5
28064
+ function subdivideCubic(p0, c1, c2, p3) {
28065
+ var a = mid(p0, c1),
28066
+ b = mid(c1, c2),
28067
+ c = mid(c2, p3),
28068
+ d = mid(a, b),
28069
+ e = mid(b, c),
28070
+ m = mid(d, e);
28071
+ return {
28072
+ left: {p0: p0, c1: a, c2: d, p3: m},
28073
+ right: {p0: m, c1: e, c2: c, p3: p3}
28074
+ };
28075
+ }
28076
+
28077
+ function mid(a, b) {
28078
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
28079
+ }
28080
+
28081
+ function isFiniteNumber$1(n) {
28082
+ return typeof n == 'number' && isFinite(n);
28083
+ }
28084
+
28085
+ var CurveFit = /*#__PURE__*/Object.freeze({
28086
+ __proto__: null,
28087
+ fitCurveThroughKnots: fitCurveThroughKnots,
28088
+ getCurveCurl: getCurveCurl,
28089
+ getCurveLength: getCurveLength,
28090
+ getCurveSegments: getCurveSegments,
28091
+ setCurveCurl: setCurveCurl
28092
+ });
28093
+
28094
+ var roundCoord$2 = getRoundingFunction(0.01);
28095
+
28096
+ function stringifyVertex(p) {
28097
+ return ' ' + roundCoord$2(p[0]) + ' ' + roundCoord$2(p[1]);
28098
+ }
28099
+
28100
+ function isCubicCtrl(p) {
28101
+ return p.length > 2 && p[2] == 'C';
28102
+ }
28103
+
28104
+ function stringifyPolygonCoords(coords) {
28105
+ var parts = [];
28106
+ for (var i=0; i<coords.length; i++) {
28107
+ parts.push(stringifyLineStringCoords(coords[i]) + ' Z');
28108
+ }
28109
+ return parts.length > 0 ? parts.join(' ') : '';
28110
+ }
28111
+
28112
+ function stringifyLineStringCoords(coords) {
28113
+ if (coords.length === 0) return '';
28114
+ var d = 'M';
28115
+ var fromCurve = false;
28116
+ var p, i, n;
28117
+ for (i=0, n=coords.length; i<n; i++) {
28118
+ p = coords[i];
28119
+ if (isCubicCtrl(p)) {
28120
+ // TODO: add defensive check
28121
+ d += ' C' + stringifyVertex(p) + stringifyVertex(coords[++i]) + stringifyVertex(coords[++i]);
28122
+ fromCurve = true;
28123
+ } else if (fromCurve) {
28124
+ d += ' L' + stringifyVertex(p);
28125
+ fromCurve = false;
28126
+ } else {
28127
+ d += stringifyVertex(p);
27282
28128
  }
28129
+ }
28130
+ return d;
28131
+ }
27283
28132
 
27284
- for(i = 5; i; ) H[--i] = H[i] + A[i] | 0;
28133
+ var SvgPathUtils = /*#__PURE__*/Object.freeze({
28134
+ __proto__: null,
28135
+ stringifyLineStringCoords: stringifyLineStringCoords,
28136
+ stringifyPolygonCoords: stringifyPolygonCoords
28137
+ });
28138
+
28139
+ // Decides whether a path-aligned label's text fits its path.
28140
+ //
28141
+ // The decision compares two numbers, and only one of them is geometry. Path
28142
+ // length export computes; text width takes font metrics, which come from
28143
+ // svg-label-metrics.mjs -- measured by rendering in the GUI, and read from the
28144
+ // installed font files in Node.
28145
+ //
28146
+ // What that module holds is the measured width, not a fits/doesn't-fit verdict,
28147
+ // because a verdict would depend on output size and a width does not: text is
28148
+ // exported at its native font-size, so its rendered width in output pixels is
28149
+ // the same at every output size, while the path shrinks and grows.
28150
+ //
28151
+ // See docs/development/label-tool-design.md.
28152
+
28153
+ // One of:
28154
+ // 'fits' the text is known to fit
28155
+ // 'overflow' the text is known not to fit; export drops the label
28156
+ // 'unmeasured' no width is available, so fit is unknown
28157
+ //
28158
+ // 'unmeasured' renders, because silently deleting a label from a map is a worse
28159
+ // outcome than drawing one that overflows: an overflow is visible and fixable,
28160
+ // a deletion is neither. It is what a machine without the label's font gets,
28161
+ // and what every reader got outside the GUI before Node could measure.
28162
+ function getLabelFitState(rec, pathLength) {
28163
+ var width = getMeasuredTextWidth(rec);
28164
+ if (!width) return 'unmeasured';
28165
+ return width <= pathLength ? 'fits' : 'overflow';
28166
+ }
28167
+
28168
+ var SvgLabelFit = /*#__PURE__*/Object.freeze({
28169
+ __proto__: null,
28170
+ getLabelFitState: getLabelFitState
28171
+ });
28172
+
28173
+ // Renders a path-aligned label as <text><textPath>, with the path itself
28174
+ // hoisted into <defs> by svg-definitions.mjs.
28175
+ //
28176
+ // A label's knots are its geometry, so a point label is an anchored label and a
28177
+ // multipoint label is a path-aligned one. Note that this changes what a
28178
+ // multipoint feature carrying label-text used to export as: previously the same
28179
+ // label was drawn once at every point. Nothing depended on that -- the
28180
+ // duplicate rendering was a known wart, flagged by a commented-out warning in
28181
+ // layerHasLabels().
28182
+ //
28183
+ // See docs/development/label-tool-design.md.
28184
+
28185
+ // Number of dropped/joined ids to name in a report before summarizing the rest.
28186
+ var MAX_REPORTED_IDS = 10;
28187
+
28188
+ // Property that carries a label's path data until a consumer moves it into a
28189
+ // <defs> entry and replaces it with a reference. Export and the GUI both do
28190
+ // this but assign ids differently -- export derives them from the path data so
28191
+ // that output is byte-stable and identical paths share a definition, while the
28192
+ // GUI namespaces them per rendered layer.
28193
+ var LABEL_PATH_PROPERTY = 'label-path-d';
28194
+
28195
+ // Class marking a label whose text is too long for its path. Only the GUI sets
28196
+ // it, via keepOverflow; export drops such labels instead.
28197
+ var LABEL_OVERFLOW_CLASS = 'label-overflow';
28198
+
28199
+ function featureIsPathLabel(geom, rec) {
28200
+ return !!(geom && geom.type == 'MultiPoint' && geom.coordinates &&
28201
+ geom.coordinates.length > 1 && featureHasLabel(rec));
28202
+ }
28203
+
28204
+ // featureIsPathLabel() against a point layer's shape -- an array of coordinate
28205
+ // pairs. Separate so that the GUI's render loop does not have to build a
28206
+ // geometry object per shape just to ask the question.
28207
+ //
28208
+ // It is the GUI's test, and so it accepts a label whose text is still empty
28209
+ // where the export test does not: a path label is one being typed into before
28210
+ // it has any text, and it has to keep its curve while that is true.
28211
+ function shapeIsPathLabel(shp, rec) {
28212
+ return !!(shp && shp.length > 1 && featureIsLabel(rec));
28213
+ }
28214
+
28215
+ // The 'd' attribute of a label's path, as true cubic curves rather than a
28216
+ // densified polyline -- worth it here because an exported label path is
28217
+ // re-editable in Illustrator, and because a smooth baseline is what the text
28218
+ // is positioned against.
28219
+ function getLabelPathData(knots) {
28220
+ var segments = getCurveSegments(knots);
28221
+ var coords, seg, i;
28222
+ if (segments.length === 0) return null; // knots collapsed to a single point
28223
+ coords = [[segments[0].p0[0], segments[0].p0[1]]];
28224
+ for (i = 0; i < segments.length; i++) {
28225
+ seg = segments[i];
28226
+ // the 'C' tag marks a control point for the path writer, which then
28227
+ // consumes the following two coordinates as the second control point and
28228
+ // the segment's endpoint
28229
+ coords.push([seg.c1[0], seg.c1[1], 'C'], [seg.c2[0], seg.c2[1]],
28230
+ [seg.p3[0], seg.p3[1]]);
28231
+ }
28232
+ return stringifyLineStringCoords(coords);
28233
+ }
28234
+
28235
+ function initPathLabelReport() {
28236
+ return {dropped: [], joined: []};
28237
+ }
28238
+
28239
+ // Returns an SVG object, or null if the label is not rendered.
28240
+ // knots: the feature's coordinates, in the units the label is drawn in
28241
+ // opts:
28242
+ // report: optional collector, see initPathLabelReport()
28243
+ // id: feature id, used only in reports
28244
+ // keepOverflow: render a label that doesn't fit, marked with
28245
+ // LABEL_OVERFLOW_CLASS, instead of dropping it. The editor sets this,
28246
+ // because hiding a broken label makes it unfindable and so unfixable.
28247
+ function renderPathLabel(rec, knots, opts) {
28248
+ var d = getLabelPathData(knots);
28249
+ var report = opts && opts.report;
28250
+ var id = opts && opts.id;
28251
+ var state, text, textPath, o, cls;
28252
+ if (!d) return null;
28253
+ state = getLabelFitState(rec, getCurveLength(knots));
28254
+ if (state == 'overflow') {
28255
+ if (!(opts && opts.keepOverflow)) {
28256
+ addToReport(report, 'dropped', id);
28257
+ return null;
28258
+ }
28259
+ cls = LABEL_OVERFLOW_CLASS;
28260
+ }
28261
+ text = toLabelString(rec['label-text']);
28262
+ if (labelNewlineRxp.test(text)) {
28263
+ // a <tspan> inside a <textPath> advances along the path instead of
28264
+ // stacking below it, so the lines are joined rather than dropping
28265
+ // everything after the first one
28266
+ text = text.split(labelNewlineRxp).join(' ');
28267
+ addToReport(report, 'joined', id);
27285
28268
  }
28269
+ textPath = {
28270
+ tag: 'textPath',
28271
+ value: text,
28272
+ properties: {
28273
+ startOffset: rec['label-start-offset'] || getDefaultStartOffset(rec)
28274
+ }
28275
+ };
28276
+ // the caller replaces this with a reference to a <defs> entry
28277
+ textPath.properties[LABEL_PATH_PROPERTY] = d;
28278
+ if (rec['label-side']) {
28279
+ textPath.properties.side = rec['label-side'];
28280
+ }
28281
+ // dx and dy shift the text along and across the path respectively. They go
28282
+ // on the <textPath>, not the <text>: x and y on a <text> are ignored once it
28283
+ // has a <textPath> child, so renderLabel()'s trick of using x/y for offsets
28284
+ // does not carry over.
28285
+ if (rec.dx) textPath.properties.dx = rec.dx;
28286
+ if (rec.dy) textPath.properties.dy = rec.dy;
28287
+ o = {tag: 'text', properties: {}, children: [textPath]};
28288
+ applyStyleAttributes(o, 'label', rec);
28289
+ if (cls) {
28290
+ o.properties.class = o.properties.class ? o.properties.class + ' ' + cls : cls;
28291
+ }
28292
+ return o;
28293
+ }
27286
28294
 
27287
- for(str1 = ''; i < 40; )str1 += (H[i >> 3] >> (7 - i++ % 8) * 4 & 15).toString(16);
27288
- return str1;
28295
+ // Maps a label's knots into the coordinate space *inside* its rendered symbol
28296
+ // group, which is translated to the label's first knot and scaled by
28297
+ // @symbolScale. @transform maps CRS coordinates to screen pixels.
28298
+ //
28299
+ // The result depends only on the ratio between the two scales, which is what
28300
+ // makes the framed case cheap: with a frame defined, zooming multiplies the
28301
+ // view scale and the symbol scale by the same factor, so these coordinates --
28302
+ // and the path built from them -- do not change. Panning cancels too, because
28303
+ // the coordinates are relative to the first knot. With no frame the symbol
28304
+ // scale is fixed at 1, so zooming does change them and the path has to be
28305
+ // rebuilt.
28306
+ function getLabelPathCoords(knots, transform, symbolScale) {
28307
+ var origin = transform.transform(knots[0][0], knots[0][1]);
28308
+ var coords = [];
28309
+ var p, i;
28310
+ for (i = 0; i < knots.length; i++) {
28311
+ p = transform.transform(knots[i][0], knots[i][1]);
28312
+ coords.push([(p[0] - origin[0]) / symbolScale, (p[1] - origin[1]) / symbolScale]);
28313
+ }
28314
+ return coords;
28315
+ }
28316
+
28317
+ // Replaces a rendered path label's marker property with a reference to a
28318
+ // definition, and returns the path data the caller now has to define. Kept next
28319
+ // to the renderer so that the element's shape is only assumed in one place.
28320
+ function referenceLabelPath(el, pathId) {
28321
+ var props = el.children[0].properties;
28322
+ var d = props[LABEL_PATH_PROPERTY];
28323
+ delete props[LABEL_PATH_PROPERTY];
28324
+ props.href = '#' + pathId;
28325
+ return d;
28326
+ }
28327
+
28328
+ // startOffset positions the text's anchor along the path, so the default that
28329
+ // leaves text where the anchor implies depends on text-anchor. 'middle' is the
28330
+ // layer-level default set by getEmptyLayerForSVG().
28331
+ function getDefaultStartOffset(rec) {
28332
+ var anchor = rec['text-anchor'] || 'middle';
28333
+ if (anchor == 'start') return '0%';
28334
+ if (anchor == 'end') return '100%';
28335
+ return '50%';
28336
+ }
28337
+
28338
+ function addToReport(report, key, id) {
28339
+ if (report && report[key]) report[key].push(id);
27289
28340
  }
27290
28341
 
28342
+ function reportPathLabels(report, lyr) {
28343
+ var name = lyr && lyr.name || '[unnamed]';
28344
+ if (!report) return;
28345
+ if (report.dropped.length > 0) {
28346
+ // A drop is reported rather than silent: an absent label is otherwise
28347
+ // indistinguishable from a bug.
28348
+ message(utils.format('Dropped %,d path label%s from layer "%s" because the text is longer than the path: %s.',
28349
+ report.dropped.length, utils.pluralSuffix(report.dropped.length), name,
28350
+ formatIds(report.dropped)));
28351
+ }
28352
+ if (report.joined.length > 0) {
28353
+ warn(utils.format('%,d path label%s in layer "%s" contain%s a line break; multi-line text on a path is not supported, so the lines were joined: %s.',
28354
+ report.joined.length, utils.pluralSuffix(report.joined.length), name,
28355
+ report.joined.length == 1 ? 's' : '', formatIds(report.joined)));
28356
+ }
28357
+ }
28358
+
28359
+ function formatIds(ids) {
28360
+ var extra = ids.length - MAX_REPORTED_IDS;
28361
+ var listed = extra > 0 ? ids.slice(0, MAX_REPORTED_IDS) : ids;
28362
+ var str = (listed.length == 1 ? 'feature ' : 'features ') + listed.join(', ');
28363
+ return extra > 0 ? str + ' and ' + extra + ' more' : str;
28364
+ }
28365
+
28366
+ var SvgLabelPaths = /*#__PURE__*/Object.freeze({
28367
+ __proto__: null,
28368
+ LABEL_OVERFLOW_CLASS: LABEL_OVERFLOW_CLASS,
28369
+ LABEL_PATH_PROPERTY: LABEL_PATH_PROPERTY,
28370
+ featureIsPathLabel: featureIsPathLabel,
28371
+ getLabelPathCoords: getLabelPathCoords,
28372
+ getLabelPathData: getLabelPathData,
28373
+ initPathLabelReport: initPathLabelReport,
28374
+ referenceLabelPath: referenceLabelPath,
28375
+ renderPathLabel: renderPathLabel,
28376
+ reportPathLabels: reportPathLabels,
28377
+ shapeIsPathLabel: shapeIsPathLabel
28378
+ });
28379
+
27291
28380
  function getSphereEffectParams() {
27292
28381
  return {
27293
28382
  cx: 0.5,
@@ -27334,6 +28423,9 @@
27334
28423
  procNode(obj);
27335
28424
 
27336
28425
  function procNode(obj) {
28426
+ if (obj.tag == 'textPath' && obj.properties && obj.properties[LABEL_PATH_PROPERTY]) {
28427
+ convertLabelPath(obj.properties, defs);
28428
+ }
27337
28429
  if (obj.tag == 'path' && obj.properties['fill-pattern']) {
27338
28430
  convertFillPattern(obj.properties, defs);
27339
28431
  }
@@ -27350,6 +28442,28 @@
27350
28442
  }
27351
28443
  }
27352
28444
 
28445
+ // Moves a label's baseline into <defs>, where it is referenced but never
28446
+ // painted. It must not be left in the document body: a <path> with no stroke or
28447
+ // fill of its own picks up SVG's defaults -- no stroke and *black fill* -- and
28448
+ // renders as a filled blob instead of a line.
28449
+ //
28450
+ // The id is derived from the path data, so identical paths share one
28451
+ // definition and repeated exports of the same map produce identical output.
28452
+ function convertLabelPath(properties, defs) {
28453
+ var d = properties[LABEL_PATH_PROPERTY];
28454
+ var item = utils.find(defs, function(item) {return item.labelPath === d;});
28455
+ delete properties[LABEL_PATH_PROPERTY];
28456
+ if (!item) {
28457
+ item = {
28458
+ labelPath: d,
28459
+ id: 'label-path-' + sha1(d).substr(0, 12)
28460
+ };
28461
+ item.svg = '<path id="' + item.id + '" d="' + d + '"/>\n';
28462
+ defs.push(item);
28463
+ }
28464
+ properties.href = '#' + item.id;
28465
+ }
28466
+
27353
28467
  function convertSvgImage(obj, defs) {
27354
28468
  // Same-origin policy prevents embedding images in the web UI
27355
28469
  var href = obj.properties.href;
@@ -27445,13 +28559,18 @@
27445
28559
 
27446
28560
  function importGeoJSONFeatures(features, opts) {
27447
28561
  opts = opts || {};
27448
- return features.map(function(obj) {
28562
+ return features.map(function(obj, featureId) {
27449
28563
  var geom = obj.type == 'Feature' ? obj.geometry : obj; // could be null
27450
28564
  var geomType = geom && geom.type;
27451
28565
  var msType = GeoJSON.translateGeoJSONType(geomType);
27452
28566
  var d = obj.properties || {};
27453
28567
  var svgObj = null;
27454
- if (geomType && geom.coordinates) {
28568
+ if (featureIsPathLabel(geom, d)) {
28569
+ // a label's knots are its geometry, so this is one label along a curve
28570
+ // rather than several labels at several points
28571
+ svgObj = renderPathLabel(d, geom.coordinates,
28572
+ {report: opts.path_label_report, id: featureId});
28573
+ } else if (geomType && geom.coordinates) {
27455
28574
  svgObj = geojsonImporters[geomType](geom.coordinates, d);
27456
28575
  }
27457
28576
  if (!svgObj) {
@@ -27868,7 +28987,12 @@ ${svg}
27868
28987
  var geojson = exportDatasetAsGeoJSON(d, opts);
27869
28988
  var features = geojson.features || geojson.geometries || (geojson.type ? [geojson] : []);
27870
28989
  warnIfIllustratorPathLimitExceeded(lyr, features);
27871
- var children = importGeoJSONFeatures(features, opts);
28990
+ // path labels that don't fit their path render as nothing, and are reported
28991
+ // rather than dropped silently
28992
+ var pathLabelReport = initPathLabelReport();
28993
+ var children = importGeoJSONFeatures(features,
28994
+ utils.defaults({path_label_report: pathLabelReport}, opts));
28995
+ reportPathLabels(pathLabelReport, lyr);
27872
28996
  // Drop empty placeholder <g/> elements (features whose geometry was null in the
27873
28997
  // source data, collapsed during simplification, or otherwise produced no
27874
28998
  // visible output). Keep the layer's records in lockstep so that data-*
@@ -28044,14 +29168,25 @@ ${svg}
28044
29168
 
28045
29169
  // add default text properties to layers with labels
28046
29170
  if (layerHasLabels(lyr) || layerHasSvgSymbols(lyr) || layerHasFurniture(lyr)) {
28047
- layerObj.properties['font-family'] = 'sans-serif';
28048
- layerObj.properties['font-size'] = '12';
28049
- layerObj.properties['text-anchor'] = 'middle';
29171
+ utils.extend(layerObj.properties, getLabelTextDefaults());
28050
29172
  }
28051
29173
 
28052
29174
  return layerObj;
28053
29175
  }
28054
29176
 
29177
+ // The text properties a label layer's container carries, which its labels
29178
+ // inherit instead of each holding a copy. Exported because a label rendered
29179
+ // outside any container -- the one the GUI draws while it is being typed, which
29180
+ // belongs to no layer until it is created -- has to be given the same ones, or
29181
+ // it changes size and alignment at the moment it becomes a feature.
29182
+ function getLabelTextDefaults() {
29183
+ return {
29184
+ 'font-family': 'sans-serif',
29185
+ 'font-size': String(DEFAULT_LABEL_FONT_SIZE),
29186
+ 'text-anchor': 'middle'
29187
+ };
29188
+ }
29189
+
28055
29190
  function layerHasSvgSymbols(lyr) {
28056
29191
  return lyr.geometry_type == 'point' && lyr.data && (
28057
29192
  lyr.data.fieldExists('svg-symbol') ||
@@ -28083,6 +29218,7 @@ ${svg}
28083
29218
  featureHasLabel: featureHasLabel,
28084
29219
  featureHasSvgSymbol: featureHasSvgSymbol,
28085
29220
  getEmptyLayerForSVG: getEmptyLayerForSVG,
29221
+ getLabelTextDefaults: getLabelTextDefaults,
28086
29222
  layerHasLabels: layerHasLabels,
28087
29223
  layerHasSvgSymbols: layerHasSvgSymbols,
28088
29224
  validateSvgDataFields: validateSvgDataFields
@@ -39079,6 +40215,30 @@ ${svg}
39079
40215
  nameOpt2 = { // for -calc and -info
39080
40216
  describe: 'name the output layer'
39081
40217
  },
40218
+ // label style properties accepted by -add-label, so that a label can be
40219
+ // created and styled in one command. These are a subset of the
40220
+ // properties -style accepts -- the ones that apply to text.
40221
+ labelStyleOpts = {
40222
+ 'font-family': {describe: 'label font, e.g. Georgia'},
40223
+ 'font-size': {describe: 'label font size, e.g. 14'},
40224
+ 'font-style': {describe: 'normal or italic'},
40225
+ 'font-weight': {describe: 'normal, bold or a numeric weight'},
40226
+ 'font-stretch': {describe: 'e.g. condensed'},
40227
+ 'letter-spacing': {describe: 'extra space between letters'},
40228
+ 'line-height': {describe: 'spacing between lines of a multi-line label'},
40229
+ 'text-anchor': {describe: 'start, middle or end'},
40230
+ 'label-align': {describe: 'how the lines of a multi-line label align: left, center or right'},
40231
+ 'dominant-baseline': {describe: 'vertical alignment, e.g. central'},
40232
+ 'label-pos': {describe: 'position relative to the anchor: n s e w ne se nw sw c'},
40233
+ 'label-side': {describe: 'which side of its path the text sits on: left or right'},
40234
+ 'label-start-offset': {describe: 'where text starts along its path, e.g. 50%'},
40235
+ dx: {describe: 'horizontal offset from the anchor'},
40236
+ dy: {describe: 'vertical offset from the anchor'},
40237
+ fill: {describe: 'text color'},
40238
+ opacity: {describe: 'text opacity'},
40239
+ css: {describe: 'inline css style'},
40240
+ class: {describe: 'name of CSS class or classes (space-separated)'}
40241
+ },
39082
40242
  noReplaceOpt2 = { // for -calc and -info
39083
40243
  alias: '+',
39084
40244
  type: 'flag',
@@ -40352,6 +41512,10 @@ ${svg}
40352
41512
  describe: 'size of grid cells in degrees (default is 10)',
40353
41513
  type: 'number'
40354
41514
  })
41515
+ .option('base', {
41516
+ describe: 'longitude to align meridians to (default is 0)',
41517
+ type: 'number'
41518
+ })
40355
41519
  .option('polygon', {
40356
41520
  describe: 'create a polygon to match the outline of the graticule',
40357
41521
  type: 'flag'
@@ -41084,6 +42248,9 @@ ${svg}
41084
42248
  .option('icon-color', {
41085
42249
  describe: 'point icon color (defaults to fill color, then black)'
41086
42250
  })
42251
+ .option('icon-opacity', {
42252
+ describe: 'point icon opacity, 0-1 (defaults to the label\'s opacity)'
42253
+ })
41087
42254
  .option('label-text', {
41088
42255
  describe: 'label text (set this to export points as labels)'
41089
42256
  })
@@ -41093,6 +42260,9 @@ ${svg}
41093
42260
  .option('text-anchor', {
41094
42261
  describe: 'label alignment; one of: start, end, middle (default)'
41095
42262
  })
42263
+ .option('label-align', {
42264
+ describe: 'alignment of the lines of multi-line labels; left, center or right'
42265
+ })
41096
42266
  .option('dx', {
41097
42267
  describe: 'x offset of labels (default is 0)'
41098
42268
  })
@@ -41120,6 +42290,15 @@ ${svg}
41120
42290
  .option('line-height', {
41121
42291
  describe: 'line spacing of multi-line labels (default is 1.1em)'
41122
42292
  })
42293
+ .option('dominant-baseline', {
42294
+ describe: 'vertical alignment of labels (e.g. central)'
42295
+ })
42296
+ .option('label-side', {
42297
+ describe: 'which side of its path a label sits on: left or right'
42298
+ })
42299
+ .option('label-start-offset', {
42300
+ describe: 'where label text starts along its path, e.g. 50%'
42301
+ })
41123
42302
  .option('target', targetOpt);
41124
42303
 
41125
42304
  parser.command('repel')
@@ -41357,6 +42536,64 @@ ${svg}
41357
42536
  .option('target', targetOpt)
41358
42537
  .option('no-replace', noReplaceOpt);
41359
42538
 
42539
+ // used by GUI
42540
+ parser.command('add-label')
42541
+ // .describe('add a map label to a point layer')
42542
+ .option('coordinates', {
42543
+ describe: 'x,y of the label anchor, or x,y,x,y,... of a curve\'s knots'
42544
+ })
42545
+ .option('text', {
42546
+ describe: 'label text'
42547
+ })
42548
+ // label style properties, so that creating and styling a label is one
42549
+ // command; anything not listed here can be set with a following -style
42550
+ .options(labelStyleOpts)
42551
+ // a label can carry a symbol at its anchor, so these come along too even
42552
+ // though they are not text properties
42553
+ .option('icon', {
42554
+ describe: 'symbol drawn at the label anchor: circle, square, ring, star'
42555
+ })
42556
+ .option('icon-size', {
42557
+ describe: 'size of the anchor symbol in px'
42558
+ })
42559
+ .option('icon-color', {
42560
+ describe: 'color of the anchor symbol (defaults to the text color)'
42561
+ })
42562
+ .option('icon-opacity', {
42563
+ describe: 'opacity of the anchor symbol, 0-1'
42564
+ })
42565
+ .option('properties', {
42566
+ describe: 'other attributes, as a JSON object'
42567
+ })
42568
+ .option('name', nameOpt)
42569
+ .option('target', targetOpt)
42570
+ .option('no-replace', noReplaceOpt);
42571
+
42572
+ // used by GUI
42573
+ parser.command('add-layer')
42574
+ // .describe('create an empty layer, to add shapes or labels to')
42575
+ .option('geometry-type', {
42576
+ describe: 'point, polygon or polyline'
42577
+ })
42578
+ .option('name', {
42579
+ describe: 'name of the new layer'
42580
+ })
42581
+ // only the CRS is taken from the target, so that a shape drawn at a
42582
+ // projected coordinate is not read as lat-long
42583
+ .option('target', targetOpt);
42584
+
42585
+ // used by GUI
42586
+ parser.command('update-label')
42587
+ // .describe('move the anchor or curve knots of an existing label')
42588
+ .option('ids', {
42589
+ describe: 'feature id of the label to update',
42590
+ type: 'numbers'
42591
+ })
42592
+ .option('coordinates', {
42593
+ describe: 'x,y of the label anchor, or x,y,x,y,... of a curve\'s knots'
42594
+ })
42595
+ .option('target', targetOpt);
42596
+
41360
42597
  parser.command('alpha-shapes')
41361
42598
  // .describe('convert points to alpha shapes (aka concave hulls)')
41362
42599
  .option('interval', {
@@ -48301,6 +49538,377 @@ ${svg}
48301
49538
  });
48302
49539
  }
48303
49540
 
49541
+ // Geometry concerns shared by the commands that write a label's knots,
49542
+ // -add-label and -update-label. A label's knots are its geometry, so both
49543
+ // commands accept coordinates in the same forms and answer the same questions
49544
+ // about them. See docs/development/label-tool-design.md.
49545
+
49546
+ // Accepts "x,y,x,y,..." or a JSON array, either flat or as an array of pairs.
49547
+ function parseLabelCoords(arg) {
49548
+ var arr, coords;
49549
+ if (arg === undefined || arg === null || arg === '') {
49550
+ stop$1('Missing required coordinates parameter');
49551
+ }
49552
+ arr = utils.isString(arg) ? parseCoordString(arg) : arg;
49553
+ if (!Array.isArray(arr) || arr.length === 0) {
49554
+ stop$1('Unable to parse coordinates:', arg);
49555
+ }
49556
+ coords = Array.isArray(arr[0]) ? arr : transposeCoords$1(arr, arg);
49557
+ coords.forEach(function(p) {
49558
+ if (!Array.isArray(p) || p.length != 2 ||
49559
+ !utils.isFiniteNumber(p[0]) || !utils.isFiniteNumber(p[1])) {
49560
+ stop$1('Invalid coordinate pair:', JSON.stringify(p));
49561
+ }
49562
+ });
49563
+ return coords;
49564
+ }
49565
+
49566
+ function parseJsonArg(arg, name) {
49567
+ if (!utils.isString(arg)) return arg;
49568
+ try {
49569
+ return JSON.parse(arg);
49570
+ } catch(e) {
49571
+ stop$1('Unable to parse', name + ':', arg);
49572
+ }
49573
+ }
49574
+
49575
+ // A curve is fitted in the coordinate space it is stored in, so a fit done in
49576
+ // degrees bows incorrectly away from the equator. A modest curve in a small
49577
+ // area is unaffected, so this warns rather than failing.
49578
+ function warnIfCurveIsUnprojected(dataset, knotCount) {
49579
+ var crs;
49580
+ if (knotCount < 2) return; // no curve to distort
49581
+ if (!dataset) return;
49582
+ crs = getDatasetCRS(dataset);
49583
+ if (crs && crs.is_latlong) {
49584
+ warn('Fitting a label curve to unprojected coordinates. Consider using -proj first.');
49585
+ }
49586
+ }
49587
+
49588
+ function parseCoordString(str) {
49589
+ var s = String(str).trim();
49590
+ if (s.startsWith('[')) return parseJsonArg(s, 'coordinates');
49591
+ return s.split(/[,\s]+/).map(function(part) {
49592
+ var n = Number(part);
49593
+ if (part === '' || isNaN(n)) {
49594
+ stop$1('Unable to parse coordinates:', str);
49595
+ }
49596
+ return n;
49597
+ });
49598
+ }
49599
+
49600
+ function transposeCoords$1(arr, srcArg) {
49601
+ var coords = [], i;
49602
+ if (arr.length % 2 !== 0) {
49603
+ stop$1('Expected an even number of coordinates; received', arr.length,
49604
+ 'in:', srcArg);
49605
+ }
49606
+ for (i = 0; i < arr.length; i += 2) {
49607
+ coords.push([arr[i], arr[i + 1]]);
49608
+ }
49609
+ return coords;
49610
+ }
49611
+
49612
+ cmd.addLabel = addLabel;
49613
+
49614
+ // Adds a map label to a point layer. One coordinate pair makes an anchored
49615
+ // label; several make a path-aligned label whose points are the knots its
49616
+ // curve is fitted through. See docs/development/label-tool-design.md.
49617
+ //
49618
+ // Options consumed directly by this command, rather than passed through as
49619
+ // label properties.
49620
+ var RESERVED_OPTIONS = {
49621
+ coordinates: true,
49622
+ name: true,
49623
+ no_replace: true,
49624
+ properties: true,
49625
+ target: true,
49626
+ text: true
49627
+ };
49628
+
49629
+ function addLabel(targetLayers, targetDataset, opts) {
49630
+ var targetLyr, coords, feature, dataset, outputLyr, merged;
49631
+ if (targetLayers.length > 1) {
49632
+ stop$1('Command expects a single target layer');
49633
+ }
49634
+ targetLyr = targetLayers[0]; // may be undefined
49635
+ if (targetLyr && !opts.no_replace) {
49636
+ requirePointTarget(targetLyr);
49637
+ }
49638
+ coords = parseLabelCoords(opts.coordinates);
49639
+ feature = toLabelFeature(opts, coords);
49640
+ matchTargetFieldTypes(feature.properties, targetLyr);
49641
+ warnIfCurveIsUnprojected(targetDataset, coords.length);
49642
+ dataset = importGeoJSON(feature);
49643
+ outputLyr = mergeDatasetsIntoDataset(targetDataset, [dataset])[0];
49644
+ if (opts.no_replace || !targetLyr) {
49645
+ setOutputLayerName(outputLyr, targetLyr && targetLyr.name, 'labels', opts);
49646
+ return [outputLyr];
49647
+ }
49648
+ // verbose: false silences "Fields [...] are missing from one or more layers".
49649
+ // Labels differ in which style properties they carry -- one has an icon, the
49650
+ // next a css rule -- so a one-label layer almost never has the same fields as
49651
+ // the layer it is joining.
49652
+ //
49653
+ // force stays on for the same reason.
49654
+ merged = cmd.mergeLayers([targetLyr, outputLyr], {force: true, verbose: false});
49655
+ // The target's name is restored because mergeLayers() drops empty layers
49656
+ // before merging: adding the first label to an empty layer hands back the
49657
+ // one-label layer on its own, which has no name because the name was on the
49658
+ // layer that was dropped. A layer called 'labels' would lose that name on
49659
+ // its first label, and the label tool creates exactly such a layer when it
49660
+ // opens with nothing loaded.
49661
+ merged[0].name = targetLyr.name;
49662
+ return merged;
49663
+ }
49664
+
49665
+ // Makes the new label's values match the types the target layer already holds,
49666
+ // so that adding a label cannot be the thing that breaks a layer's schema.
49667
+ //
49668
+ // Only string and number are worth reconciling. Anything else in a style field
49669
+ // is odd enough that quietly rewriting it would hide a real problem.
49670
+ function matchTargetFieldTypes(d, targetLyr) {
49671
+ var records = targetLyr && targetLyr.data ? targetLyr.data.getRecords() : null;
49672
+ if (!records || records.length === 0) return;
49673
+ Object.keys(d).forEach(function(key) {
49674
+ var type = getColumnType(key, records);
49675
+ var val = d[key];
49676
+ if (!type || type === typeof val) return;
49677
+ if (type == 'string' && utils.isNumber(val)) {
49678
+ d[key] = String(val);
49679
+ } else if (type == 'number' && utils.isString(val)) {
49680
+ if (isFiniteString(val)) {
49681
+ d[key] = Number(val);
49682
+ } else {
49683
+ stringifyColumn(key, records);
49684
+ }
49685
+ }
49686
+ });
49687
+ }
49688
+
49689
+ // Widens a column of numbers to strings, for a value that cannot be a number:
49690
+ // '0.45em' is a length with units, which is what a label position east of its
49691
+ // anchor expands to.
49692
+ //
49693
+ // Every number has a faithful string form, and these values are written out as
49694
+ // SVG attributes, so restating 0 as '0' changes nothing that is drawn or
49695
+ // exported. Narrowing the other way is what is not always possible, which is
49696
+ // why this is the direction the column moves.
49697
+ //
49698
+ // Reached by a layer whose dx column holds numbers -- written before label
49699
+ // positions stored dx as a string -- which would otherwise refuse every label
49700
+ // offered an em offset.
49701
+ function stringifyColumn(key, records) {
49702
+ for (var i = 0; i < records.length; i++) {
49703
+ if (records[i] && utils.isNumber(records[i][key])) {
49704
+ records[i][key] = String(records[i][key]);
49705
+ }
49706
+ }
49707
+ }
49708
+
49709
+ function isFiniteString(str) {
49710
+ return str.trim() !== '' && utils.isFiniteNumber(Number(str));
49711
+ }
49712
+
49713
+ // Both kinds of label are point features, so a non-point target can never
49714
+ // receive one. Refusing is better than silently making a new layer, because
49715
+ // the CLI user named a target explicitly.
49716
+ function requirePointTarget(lyr) {
49717
+ if (lyr.geometry_type && lyr.geometry_type != 'point') {
49718
+ stop$1('Labels can only be added to a point layer; target "' +
49719
+ (lyr.name || '[unnamed]') + '" contains ' + lyr.geometry_type + 's. ' +
49720
+ 'Use no-replace to add labels as a new layer.');
49721
+ }
49722
+ }
49723
+
49724
+ function toLabelFeature(opts, coords) {
49725
+ return {
49726
+ type: 'Feature',
49727
+ properties: getLabelProperties(opts, coords.length),
49728
+ geometry: coords.length == 1 ?
49729
+ {type: 'Point', coordinates: coords[0]} :
49730
+ {type: 'MultiPoint', coordinates: coords}
49731
+ };
49732
+ }
49733
+
49734
+ function getLabelProperties(opts, knotCount) {
49735
+ var d = {};
49736
+ if (opts.properties) {
49737
+ utils.extend(d, parseJsonArg(opts.properties, 'properties'));
49738
+ }
49739
+ // an explicit empty string is meaningful: it creates a label that the user
49740
+ // is about to type into, which is how the GUI starts an editing session
49741
+ d['label-text'] = 'text' in opts ? String(opts.text) : '';
49742
+
49743
+ // any option matching a -style property becomes a label property, so that
49744
+ // creating and styling a label is one command and one history entry
49745
+ Object.keys(opts).forEach(function(key) {
49746
+ var name = key.replace(/_/g, '-');
49747
+ var val;
49748
+ if (key in RESERVED_OPTIONS) return;
49749
+ if (!isSupportedSvgStyleProperty(name)) return;
49750
+ // Stored as the type -style would store it in. Copying the option string
49751
+ // through instead left icon-size=20 as "20" here and 20 from -style, so a
49752
+ // layer holding both kinds of label had a column with two types in it --
49753
+ // which the merge below then refused, making the next label impossible to
49754
+ // add.
49755
+ val = parseStyleLiteral(name, opts[key]);
49756
+ if (val === undefined) {
49757
+ stop$1('Unexpected value for', name + ':', opts[key]);
49758
+ }
49759
+ d[name] = val;
49760
+ // label-pos is the only one of the four position properties stored: the
49761
+ // offsets and justification it stands for are resolved when the label is
49762
+ // drawn. A dx= or dy= given as well is kept and wins there, so the two can
49763
+ // be combined to nudge a label off a standard position.
49764
+ if (name == 'label-pos' && !parseLabelPosition(val)) {
49765
+ stop$1('Unexpected value for label-pos:', opts[key]);
49766
+ }
49767
+ });
49768
+
49769
+ if (d['label-pos'] && knotCount > 1) {
49770
+ // A path label's text runs along its curve from a start offset, so it has
49771
+ // no position around an anchor to take. Warned about rather than rejected,
49772
+ // so that a script can style a mixed layer in one pass.
49773
+ warn('Ignoring label-pos on a label with', knotCount, 'points.',
49774
+ 'Use label-start-offset= and text-anchor= to place text along a path.');
49775
+ delete d['label-pos'];
49776
+ }
49777
+ return d;
49778
+ }
49779
+
49780
+ cmd.addLayer = addLayer;
49781
+
49782
+ var GEOMETRY_TYPES = ['point', 'polygon', 'polyline'];
49783
+
49784
+ // Creates an empty layer, for features that are about to be added to it: by the
49785
+ // GUI's drawing and label tools, or by -add-shape and -add-label in a script.
49786
+ //
49787
+ // The layer goes into a dataset of its own rather than joining the target's, so
49788
+ // that shapes drawn into it have their own topology and editing them cannot
49789
+ // disturb the arcs of the layer it was created beside. What it does take from
49790
+ // the target is the CRS, without which a shape drawn at a projected coordinate
49791
+ // would be read as lat-long.
49792
+ //
49793
+ // targetDataset: the current target, or undefined if there is none
49794
+ // opts:
49795
+ // geometry_type: 'point', 'polygon' or 'polyline'
49796
+ // name: name for the new layer
49797
+ // Returns a new dataset, for the caller to add to the catalog.
49798
+ function addLayer(targetDataset, opts) {
49799
+ var type = opts.geometry_type;
49800
+ var dataset;
49801
+ if (!type) {
49802
+ stop$1('Missing required geometry-type parameter:', GEOMETRY_TYPES.join('|'));
49803
+ }
49804
+ if (!GEOMETRY_TYPES.includes(type)) {
49805
+ stop$1('Unsupported geometry type:', type + '.', 'Expected',
49806
+ GEOMETRY_TYPES.join('|'));
49807
+ }
49808
+ dataset = {
49809
+ layers: [{
49810
+ name: opts.name || undefined,
49811
+ geometry_type: type,
49812
+ shapes: []
49813
+ }],
49814
+ info: {}
49815
+ };
49816
+ // A path layer needs somewhere to put its arcs. A point layer holds
49817
+ // coordinates in its shapes and never has an ArcCollection.
49818
+ if (type != 'point') {
49819
+ dataset.arcs = new ArcCollection();
49820
+ }
49821
+ if (targetDataset) {
49822
+ setDatasetCrsInfo(dataset, getDatasetCrsInfo(targetDataset));
49823
+ }
49824
+ return dataset;
49825
+ }
49826
+
49827
+ var OPERATION = 'update-label';
49828
+
49829
+ cmd.updateLabel = updateLabel;
49830
+
49831
+ // Rewrites the knots of a label that already exists.
49832
+ //
49833
+ // -add-label creates and -style sets properties, so neither can move a label:
49834
+ // a label's knots are its geometry, not an attribute. This is the command the
49835
+ // GUI emits when a knot or an anchor is dragged, so that an interactive move is
49836
+ // an ordinary command with undo and session history rather than a direct
49837
+ // mutation with hand-rolled undo.
49838
+ //
49839
+ // See docs/development/label-tool-design.md.
49840
+ function updateLabel(targetLayers, dataset, opts) {
49841
+ var lyr, id, coords, rec;
49842
+ if (targetLayers.length > 1) {
49843
+ // One coordinate list describes one label, so there is no sensible reading
49844
+ // of this across layers.
49845
+ stop$1('Command expects a single target layer');
49846
+ }
49847
+ lyr = targetLayers[0];
49848
+ id = getTargetId(lyr, opts);
49849
+ coords = parseLabelCoords(opts.coordinates);
49850
+ rec = getLayerDataTable(lyr).getRecords()[id];
49851
+ if (!featureIsLabel(rec)) {
49852
+ // Moving an arbitrary point through a command named -update-label would be
49853
+ // a surprising way to succeed, and in the GUI it would mean the hit test
49854
+ // handed over the wrong feature.
49855
+ stop$1('Feature', id, 'in layer "' + layerName(lyr) + '" is not a label ' +
49856
+ '(it has no label-text property)');
49857
+ }
49858
+ warnIfCurveIsUnprojected(dataset, coords.length);
49859
+ // Undo works from what a command declares it is about to change, so an edit
49860
+ // that does not say so is an edit that cannot be undone -- the shapes array
49861
+ // keeps its identity here, leaving nothing for the transaction to notice
49862
+ // afterwards.
49863
+ noteLayerWillChange(lyr, {operation: OPERATION, unit: 'shapes'});
49864
+ // Point shapes hold their coordinates directly, so this is the whole edit.
49865
+ // The pairs are copied rather than aliased: the GUI drags a live copy of the
49866
+ // knots and would otherwise keep a handle on the layer's own arrays.
49867
+ lyr.shapes[id] = coords.map(function(p) { return [p[0], p[1]]; });
49868
+ markLayerChanged(lyr, {operation: OPERATION, unit: 'shapes'});
49869
+ // Nothing about the label's text is touched, and nothing needs to be: a text
49870
+ // measurement describes the *text*, and moving a knot changes the length of
49871
+ // the *path*. The fit check compares the two at render time, against the
49872
+ // curve as it then stands, so a move can turn a fitting label into an
49873
+ // overflowing one with no measurement going out of date.
49874
+ }
49875
+
49876
+ // Which feature to move. One coordinate list can only describe one label, so
49877
+ // this takes a single id rather than the ids= list that -style accepts.
49878
+ function getTargetId(lyr, opts) {
49879
+ var ids = opts.ids;
49880
+ var id;
49881
+ requirePointLayer(lyr);
49882
+ if (!ids || ids.length === 0) {
49883
+ stop$1('Missing required ids parameter (the feature id of the label to update)');
49884
+ }
49885
+ if (ids.length > 1) {
49886
+ stop$1('Command expects a single feature id; received', ids.join(','));
49887
+ }
49888
+ id = ids[0];
49889
+ if (id >= 0 === false || id >= getFeatureCount(lyr)) {
49890
+ stop$1('Layer "' + layerName(lyr) + '" has no feature with id', id);
49891
+ }
49892
+ if (!lyr.shapes || !lyr.shapes[id]) {
49893
+ stop$1('Feature', id, 'in layer "' + layerName(lyr) + '" has no geometry');
49894
+ }
49895
+ return id;
49896
+ }
49897
+
49898
+ function requirePointLayer(lyr) {
49899
+ if (!lyr || getFeatureCount(lyr) === 0) {
49900
+ stop$1('Missing a target layer containing labels');
49901
+ }
49902
+ if (lyr.geometry_type != 'point') {
49903
+ stop$1('Labels can only be updated in a point layer; target "' +
49904
+ layerName(lyr) + '" contains ' + (lyr.geometry_type || 'no geometry'));
49905
+ }
49906
+ }
49907
+
49908
+ function layerName(lyr) {
49909
+ return lyr && lyr.name || '[unnamed]';
49910
+ }
49911
+
48304
49912
  cmd.addShape = addShape;
48305
49913
 
48306
49914
  function addShape(targetLayers, targetDataset, opts) {
@@ -49340,7 +50948,7 @@ ${svg}
49340
50948
  }
49341
50949
 
49342
50950
  cmd.alphaShapes = function(pointLyr, targetDataset, opts) {
49343
- requirePointLayer(pointLyr);
50951
+ requirePointLayer$1(pointLyr);
49344
50952
  if (opts.interval > 0 === false) {
49345
50953
  stop$1('Expected a non-negative interval parameter');
49346
50954
  }
@@ -60725,7 +62333,7 @@ ${svg}
60725
62333
  cmd.lines = function(lyr, dataset, opts) {
60726
62334
  opts = opts || {};
60727
62335
  if (opts.callouts) {
60728
- requirePointLayer(lyr);
62336
+ requirePointLayer$1(lyr);
60729
62337
  return pointsToCallouts(lyr, dataset, opts);
60730
62338
  } else if (lyr.geometry_type == 'point') {
60731
62339
  return pointsToLines(lyr, dataset, opts);
@@ -72614,17 +74222,19 @@ ${svg}
72614
74222
  //
72615
74223
  function createGraticule(P, outlined, opts) {
72616
74224
  var interval = opts.interval || 10;
74225
+ var base = opts.base || 0;
72617
74226
  if (Math.round(interval) != interval || interval > 0 === false) {
72618
74227
  stop$1('Invalid interval:', interval);
72619
74228
  }
74229
+ if (!utils.isFiniteNumber(base)) {
74230
+ stop$1('Invalid base:', opts.base);
74231
+ }
72620
74232
  P.lam0 * 180 / Math.PI;
72621
74233
  var precision = interval > 10 ? 1 : 0.5; // degrees between each vertex
72622
- var xstep = interval;
72623
74234
  var ystep = interval;
72624
74235
  var xstepMajor = 90;
72625
- var xn = Math.round(360 / xstep);
72626
74236
  var yn = Math.round(180 / ystep) + 1;
72627
- var xx = utils.range(xn, -180 + xstep, xstep);
74237
+ var xx = getGraticuleMeridianLongitudes(interval, base);
72628
74238
  var yy = utils.range(yn, -90, ystep);
72629
74239
  var meridians = [];
72630
74240
  var parallels = [];
@@ -72674,7 +74284,7 @@ ${svg}
72674
74284
 
72675
74285
  function createMeridianPart(x, ymin, ymax) {
72676
74286
  var coords = densifyPathByInterval([[x, ymin], [x, ymax]], precision);
72677
- meridians.push(graticuleFeature(coords, {type: 'meridian', value: roundCoord$2(x)}));
74287
+ meridians.push(graticuleFeature(coords, {type: 'meridian', value: roundCoord$1(x)}));
72678
74288
  }
72679
74289
 
72680
74290
  function createParallel(y) {
@@ -72690,8 +74300,25 @@ ${svg}
72690
74300
  }
72691
74301
  }
72692
74302
 
74303
+ // Longitudes of meridians at interval spacing, aligned so that base (mod
74304
+ // interval) is included. Values are wrapped to (-180, 180].
74305
+ function getGraticuleMeridianLongitudes(interval, base) {
74306
+ var offset = ((base % interval) + interval) % interval;
74307
+ var n = Math.round(360 / interval);
74308
+ var longitudes = [];
74309
+ for (var i = 0; i < n; i++) {
74310
+ longitudes.push(wrapMeridianLongitude(offset + i * interval));
74311
+ }
74312
+ return longitudes;
74313
+ }
74314
+
74315
+ function wrapMeridianLongitude(lon) {
74316
+ lon -= Math.floor((lon + 180) / 360) * 360; // [-180, 180)
74317
+ return lon === -180 ? 180 : lon;
74318
+ }
74319
+
72693
74320
  // remove tiny offsets
72694
- function roundCoord$2(x) {
74321
+ function roundCoord$1(x) {
72695
74322
  return +x.toFixed(3) || 0;
72696
74323
  }
72697
74324
 
@@ -75330,7 +76957,7 @@ ${svg}
75330
76957
  }
75331
76958
 
75332
76959
  cmd.pointToGrid = function(targetLayers, targetDataset, opts) {
75333
- targetLayers.forEach(requirePointLayer);
76960
+ targetLayers.forEach(requirePointLayer$1);
75334
76961
  if (opts.interval > 0 === false) {
75335
76962
  stop$1('Expected a non-negative interval parameter');
75336
76963
  }
@@ -75731,7 +77358,7 @@ ${svg}
75731
77358
  };
75732
77359
  }
75733
77360
 
75734
- var roundCoord$1 = getRoundingFunction(0.01);
77361
+ var roundCoord = getRoundingFunction(0.01);
75735
77362
 
75736
77363
  function getSymbolFillColor(d) {
75737
77364
  return d.fill || 'magenta';
@@ -75870,8 +77497,8 @@ ${svg}
75870
77497
 
75871
77498
  function roundCoordsForSVG(coords) {
75872
77499
  forEachSymbolCoord(coords, function(p) {
75873
- p[0] = roundCoord$1(p[0]);
75874
- p[1] = roundCoord$1(p[1]);
77500
+ p[0] = roundCoord(p[0]);
77501
+ p[1] = roundCoord(p[1]);
75875
77502
  });
75876
77503
  }
75877
77504
 
@@ -78280,7 +79907,12 @@ ${svg}
78280
79907
  }
78281
79908
 
78282
79909
  cmd.svgStyle = function(lyr, dataset, opts) {
78283
- var filterFn, table, fields, hasNewFields;
79910
+ var filterFn, table, fields, hasNewFields, optFields, clearedByPosition, fieldsBefore;
79911
+
79912
+ function hadField(field) {
79913
+ return field in fieldsBefore;
79914
+ }
79915
+
78284
79916
  if (getFeatureCount(lyr) === 0) {
78285
79917
  return;
78286
79918
  }
@@ -78297,10 +79929,25 @@ ${svg}
78297
79929
  lyr.data.getFields().filter(isSupportedSvgStyleProperty).forEach(lyr.data.deleteField, lyr.data);
78298
79930
  }
78299
79931
  table = getLayerDataTable(lyr);
78300
- fields = getStyleFields(opts);
79932
+ optFields = getOptionFields(opts);
79933
+ fields = getStyleFields(optFields);
79934
+ // Which of dx/dy/text-anchor a label-pos in this command clears: not the ones
79935
+ // the same command also sets, so that `label-pos=n dx=3` keeps the nudge it
79936
+ // was given instead of clearing it a moment later.
79937
+ //
79938
+ // Clearing at all is what keeps the shorthand usable, since a value on the
79939
+ // record wins over the position: without it, setting a position on a label
79940
+ // that had been dragged would appear to do nothing.
79941
+ clearedByPosition = labelPositionDerivedFields.filter(function(field) {
79942
+ return optFields.indexOf(field) == -1;
79943
+ });
78301
79944
  hasNewFields = fields.some(function(field) {
78302
79945
  return !table.fieldExists(field);
78303
79946
  });
79947
+ // The table's columns as this command found them. Taken once, because the
79948
+ // command adds to them as it runs, and a blanked property must be judged
79949
+ // against what was there before rather than against what it has just made.
79950
+ fieldsBefore = utils.arrayToIndex(table.getFields());
78304
79951
  if (fields.length > 0) {
78305
79952
  if (hasNewFields) {
78306
79953
  table.captureSchemaBefore({operation: 'style', fields: fields});
@@ -78309,32 +79956,67 @@ ${svg}
78309
79956
  }
78310
79957
  }
78311
79958
  Object.keys(opts).forEach(function(optName) {
78312
- var svgName = optName.replace('_', '-'); // undo cli parser name conversion
79959
+ // undo cli parser name conversion; the regex must be global, or a
79960
+ // property with more than one hyphen (e.g. label-start-offset) is silently
79961
+ // skipped rather than applied
79962
+ var svgName = optName.replace(/_/g, '-');
78313
79963
  if (!isSupportedSvgStyleProperty(svgName)) {
78314
79964
  return;
78315
79965
  }
78316
79966
  var strVal = opts[optName].trim();
78317
- var accessor = getSymbolPropertyAccessor(strVal, svgName, lyr);
79967
+ // An empty value removes the property, rather than being rejected as an
79968
+ // unparseable one. This is how a control gives a property back: a label
79969
+ // dragged off its position clears label-pos, and there is otherwise no
79970
+ // per-property unset -- only -style clear, which clears all of them.
79971
+ var unset = strVal === '' && emptyValueUnsetsProperty(svgName);
79972
+ var accessor = unset ? null : getSymbolPropertyAccessor(strVal, svgName, lyr);
78318
79973
  var badIcons = svgName == 'icon' ? [] : null;
79974
+ // Removing a position is not setting one, so it neither validates the
79975
+ // value nor clears the offsets the position would have stood for.
79976
+ var posOnPaths = svgName == 'label-pos' && !unset ? [] : null;
78319
79977
  table.getRecords().forEach(function(rec, i) {
78320
79978
  if (filterFn && !filterFn(i)) {
78321
79979
  // make sure field exists if record is excluded by filter
78322
- setUndefinedFields(rec, svgName == 'label-pos' ? labelPositionFields : [svgName]);
79980
+ setUndefinedFields(rec, [svgName]);
79981
+ if (svgName == 'label-pos') {
79982
+ // ...but a field the position would only have cleared is one this
79983
+ // command is not writing anywhere, so an excluded record has nothing
79984
+ // to stay consistent with
79985
+ setUndefinedFields(rec, labelPositionDerivedFields, {has: hadField});
79986
+ }
79987
+ } else if (unset) {
79988
+ // Nothing to remove, and so nothing to create: removing a property no
79989
+ // record has would otherwise add an empty column for it.
79990
+ if (hadField(svgName) || svgName in rec) rec[svgName] = undefined;
78323
79991
  } else {
78324
79992
  rec[svgName] = accessor(i);
78325
79993
  if (badIcons) {
78326
79994
  addUnsupportedIconName(badIcons, rec.icon);
78327
79995
  }
78328
- if (svgName == 'label-pos') {
78329
- if (!setLabelPositionStyle(rec, rec['label-pos'])) {
79996
+ if (posOnPaths) {
79997
+ if (!parseLabelPosition(rec['label-pos'])) {
78330
79998
  stop$1('Unexpected value for label-pos:', rec['label-pos']);
78331
79999
  }
80000
+ if (shapeIsPathLabel(lyr.shapes && lyr.shapes[i], rec)) {
80001
+ // Not stored, so that ignoring it means ignoring it: a stored
80002
+ // position would show up in the style panel and would start
80003
+ // applying if the label ever lost all but one of its knots. Its
80004
+ // text-anchor is left alone too -- that one does place text along a
80005
+ // path, so a position that had no effect must not clear it.
80006
+ posOnPaths.push(i);
80007
+ rec['label-pos'] = undefined;
80008
+ } else {
80009
+ setUndefinedFields(rec, clearedByPosition, {overwrite: true, has: hadField});
80010
+ }
78332
80011
  }
78333
80012
  }
78334
80013
  });
78335
80014
  if (badIcons && badIcons.length > 0) {
78336
80015
  warn(formatUnsupportedIconMessage(badIcons));
78337
80016
  }
80017
+ if (posOnPaths && posOnPaths.length > 0) {
80018
+ warn(formatPositionOnPathMessage(posOnPaths));
80019
+ }
78338
80020
  });
78339
80021
  if (fields.length > 0) {
78340
80022
  if (hasNewFields) {
@@ -78345,11 +80027,22 @@ ${svg}
78345
80027
  }
78346
80028
  };
78347
80029
 
78348
- function getStyleFields(opts) {
80030
+ // The style properties this command was given, in SVG spelling.
80031
+ function getOptionFields(opts) {
78349
80032
  var fields = [];
78350
80033
  Object.keys(opts).forEach(function(optName) {
78351
- var svgName = optName.replace('_', '-');
78352
- if (!isSupportedSvgStyleProperty(svgName)) return;
80034
+ var svgName = optName.replace(/_/g, '-');
80035
+ if (isSupportedSvgStyleProperty(svgName)) addField(fields, svgName);
80036
+ });
80037
+ return fields;
80038
+ }
80039
+
80040
+ // The fields the command will write, which is what the undo capture covers.
80041
+ // label-pos reaches dx/dy/text-anchor as well -- it no longer stores values in
80042
+ // them, but it does clear them.
80043
+ function getStyleFields(optFields) {
80044
+ var fields = [];
80045
+ optFields.forEach(function(svgName) {
78353
80046
  addField(fields, svgName);
78354
80047
  if (svgName == 'label-pos') {
78355
80048
  labelPositionFields.forEach(function(field) {
@@ -78392,14 +80085,43 @@ ${svg}
78392
80085
  return str + '. Expected one of: ' + iconNames.join(', ');
78393
80086
  }
78394
80087
 
78395
- function setUndefinedFields(rec, fields) {
80088
+ // Adds @fields to @rec with no value, so that a record the filter excluded
80089
+ // still has the same schema as the ones it kept. With overwrite, also blanks a
80090
+ // value already there -- which is how setting a position takes back the offsets
80091
+ // a label was carrying.
80092
+ //
80093
+ // @has: optional test for whether the layer carries a field at all. A field
80094
+ // nobody has is not created in order to be blanked: -style label-pos=n clears
80095
+ // dx, dy and text-anchor because a value on the record wins over the position,
80096
+ // and there is nothing to win with when the column does not exist. Without
80097
+ // this, clicking a position in the style panel put three empty columns in the
80098
+ // user's table, and three empty columns in their CSV.
80099
+ function setUndefinedFields(rec, fields, opts) {
80100
+ var overwrite = !!(opts && opts.overwrite);
80101
+ var has = opts && opts.has;
78396
80102
  fields.forEach(function(field) {
78397
- if (field in rec === false) {
80103
+ if (has && !has(field) && field in rec === false) return;
80104
+ if (overwrite || field in rec === false) {
78398
80105
  rec[field] = undefined;
78399
80106
  }
78400
80107
  });
78401
80108
  }
78402
80109
 
80110
+ // label-pos places text around an anchor point, which a label strung along a
80111
+ // path does not have: its text runs from a start offset in the direction the
80112
+ // path goes. A warning rather than an error, because a layer can hold both
80113
+ // kinds of label and styling all of it at once is reasonable.
80114
+ function formatPositionOnPathMessage(ids) {
80115
+ var extra = ids.length - maxReportedIds;
80116
+ var listed = (extra > 0 ? ids.slice(0, maxReportedIds) : ids).join(', ');
80117
+ return 'Ignoring label-pos on ' + ids.length + ' path ' +
80118
+ (ids.length > 1 ? 'labels' : 'label') + ' (' + listed +
80119
+ (extra > 0 ? ' and ' + extra + ' more' : '') + '). ' +
80120
+ 'Use label-start-offset= and text-anchor= to place text along a path.';
80121
+ }
80122
+
80123
+ var maxReportedIds = 4;
80124
+
78403
80125
  function getStickArrowCoords(d) {
78404
80126
  return getArrowCoords(d, 'stick');
78405
80127
  }
@@ -78931,7 +80653,7 @@ ${svg}
78931
80653
 
78932
80654
  // TODO: refactor to remove duplication in mapshaper-svg-style.js
78933
80655
  cmd.symbols = function(inputLyr, dataset, opts) {
78934
- requirePointLayer(inputLyr);
80656
+ requirePointLayer$1(inputLyr);
78935
80657
  var lyr = opts.no_replace ? copyLayer(inputLyr) : inputLyr;
78936
80658
  var shapeMode = !!opts.geographic;
78937
80659
  var metersPerPx;
@@ -79552,6 +81274,7 @@ ${svg}
79552
81274
  name == 'require' || name == 'run' || name == 'define' ||
79553
81275
  name == 'include' || name == 'print' || name == 'comment' || name == 'if' || name == 'elif' ||
79554
81276
  name == 'else' || name == 'endif' || name == 'stop' || name == 'add-shape' ||
81277
+ name == 'add-label' || name == 'add-layer' ||
79555
81278
  name == 'scalebar' || name == 'vars' || name == 'defaults';
79556
81279
  }
79557
81280
 
@@ -79663,6 +81386,27 @@ ${svg}
79663
81386
  job.catalog.addDataset(targetDataset);
79664
81387
  }
79665
81388
  outputLayers = cmd.addShape(targetLayers, targetDataset, opts);
81389
+
81390
+ } else if (name == 'add-label') {
81391
+ if (!targetDataset) {
81392
+ targetDataset = {info: {}, layers: []};
81393
+ targetLayers = targetDataset.layers;
81394
+ job.catalog.addDataset(targetDataset);
81395
+ }
81396
+ outputLayers = cmd.addLabel(targetLayers, targetDataset, opts);
81397
+
81398
+ } else if (name == 'add-layer') {
81399
+ // The new layer arrives in a dataset of its own, so it is added to the
81400
+ // catalog here rather than integrated into the target below.
81401
+ // captureCatalogBefore() gives an undo transaction the state before the
81402
+ // change; addDataset() marks the change itself. Both are no-ops in the
81403
+ // CLI, where nothing is listening.
81404
+ job.catalog.captureCatalogBefore({operation: 'addLayer'});
81405
+ job.catalog.addDataset(cmd.addLayer(targetDataset, opts));
81406
+
81407
+ } else if (name == 'update-label') {
81408
+ cmd.updateLabel(targetLayers, targetDataset, opts);
81409
+
79666
81410
  } else if (name == 'affine') {
79667
81411
  cmd.affine(targetLayers, targetDataset, opts);
79668
81412
 
@@ -80211,7 +81955,7 @@ ${svg}
80211
81955
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
80212
81956
  }
80213
81957
 
80214
- var version = "0.7.60";
81958
+ var version = "0.7.62";
80215
81959
 
80216
81960
  // Parse command line args into commands and run them
80217
81961
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
@@ -80602,49 +82346,597 @@ ${svg}
80602
82346
  testCommands: testCommands
80603
82347
  });
80604
82348
 
80605
- var roundCoord = getRoundingFunction(0.01);
82349
+ // Finding the font file a label's font-family names, among the fonts installed
82350
+ // on this computer.
82351
+ //
82352
+ // The browser does this for us in the GUI; in Node there is nothing between
82353
+ // mapshaper and the filesystem, so a family name has to be matched against the
82354
+ // name tables of the files themselves. Names cannot be inferred from
82355
+ // filenames: NYTFranklinLight.otf calls itself "NYTFranklin Light", and a
82356
+ // label asking for NYTFranklin at weight 300 has to reach it.
82357
+ //
82358
+ // See docs/development/label-tool-design.md.
82359
+
82360
+ // Collections (.ttc/.otc) hold several faces in one file and are how macOS
82361
+ // ships Helvetica, Menlo and Avenir, so a lookup that skipped them would miss
82362
+ // the font mapshaper's own tool writes by default there.
82363
+ var FONT_FILE_RXP = /\.(ttf|otf|ttc|otc)$/i;
82364
+
82365
+ // What a generic family means when there is no browser to resolve it. A guess,
82366
+ // but an ordered one: the first of these that is installed is what a browser
82367
+ // on this platform would almost certainly have picked.
82368
+ var GENERIC_FAMILIES = {
82369
+ 'sans-serif': ['Helvetica', 'Arial', 'Liberation Sans', 'DejaVu Sans', 'Roboto', 'Segoe UI'],
82370
+ serif: ['Times New Roman', 'Times', 'Liberation Serif', 'DejaVu Serif', 'Georgia'],
82371
+ monospace: ['Menlo', 'Courier New', 'Liberation Mono', 'DejaVu Sans Mono', 'Consolas'],
82372
+ 'system-ui': ['Helvetica Neue', 'Segoe UI', 'Cantarell', 'Roboto'],
82373
+ cursive: [],
82374
+ fantasy: []
82375
+ };
80606
82376
 
80607
- function stringifyVertex(p) {
80608
- return ' ' + roundCoord(p[0]) + ' ' + roundCoord(p[1]);
82377
+ var faceCache = {};
82378
+ var familyCache = {};
82379
+ var fullIndex = null;
82380
+ var fileList = null;
82381
+
82382
+ // Width classes, as OS/2 numbers them 1 to 9. A font-stretch is one of these
82383
+ // keywords or a percentage of normal width.
82384
+ var STRETCH_NAMES = {
82385
+ 'ultra-condensed': 1, 'extra-condensed': 2, condensed: 3,
82386
+ 'semi-condensed': 4, normal: 5, 'semi-expanded': 6, expanded: 7,
82387
+ 'extra-expanded': 8, 'ultra-expanded': 9
82388
+ };
82389
+ var STRETCH_PERCENTS = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
82390
+
82391
+ // The face to open for @family in (@weight, @italic, @stretch), or null if this
82392
+ // computer has no such font: {path, postscriptName}.
82393
+ //
82394
+ // postscriptName is how a face inside a collection is named to fontkit, and is
82395
+ // null for a file holding one face.
82396
+ function findFontFace(family, weight, italic, stretch) {
82397
+ var key = [family, weight, italic ? 'i' : 'n', stretch || ''].join('|');
82398
+ if (!(key in faceCache)) {
82399
+ faceCache[key] = lookupFace(family, weight, italic, stretch);
82400
+ }
82401
+ return faceCache[key];
80609
82402
  }
80610
82403
 
80611
- function isCubicCtrl(p) {
80612
- return p.length > 2 && p[2] == 'C';
82404
+ // Which of a family's faces answers a request for (@weight, @italic,
82405
+ // @stretch).
82406
+ //
82407
+ // Width first, then upright before oblique, then the nearest weight, then the
82408
+ // heavier of two equally near. The first three are the order CSS matches fonts
82409
+ // in, and the last is the rule the style menu uses to carry a face across a
82410
+ // change of font (getNearestVariant() in gui-label-fonts.mjs), applied here to
82411
+ // faces read from files rather than measured in a browser.
82412
+ //
82413
+ // A missing face is answered with a near one rather than refused: the browser
82414
+ // would synthesize the missing weight or slant from exactly this face, so its
82415
+ // widths are much closer to what is drawn than no measurement at all.
82416
+ function pickFace(faces, weight, italic, stretch) {
82417
+ var wanted = weight > 0 ? weight : 400;
82418
+ var wantedWidth = parseFontStretch(stretch);
82419
+ var best = null;
82420
+ var bestScore = null;
82421
+ (faces || []).forEach(function(face) {
82422
+ var score = [
82423
+ Math.abs((face.width || 5) - wantedWidth),
82424
+ !!face.italic === !!italic ? 0 : 1,
82425
+ Math.abs(face.weight - wanted),
82426
+ face.weight < wanted ? 1 : 0
82427
+ ];
82428
+ if (!best || compareScores(score, bestScore) < 0) {
82429
+ best = face;
82430
+ bestScore = score;
82431
+ }
82432
+ });
82433
+ return best;
80613
82434
  }
80614
82435
 
80615
- function stringifyPolygonCoords(coords) {
80616
- var parts = [];
80617
- for (var i=0; i<coords.length; i++) {
80618
- parts.push(stringifyLineStringCoords(coords[i]) + ' Z');
82436
+ // A font-stretch as the width class it names, or normal width for anything
82437
+ // unreadable. A percentage is taken to the nearest class, the way CSS defines
82438
+ // the keywords.
82439
+ function parseFontStretch(stretch) {
82440
+ var str = String(stretch === null || stretch === undefined ? '' : stretch).trim().toLowerCase();
82441
+ var pct = /^([.0-9]+)%$/.exec(str);
82442
+ var best = 5;
82443
+ if (STRETCH_NAMES[str]) return STRETCH_NAMES[str];
82444
+ if (!pct) return 5;
82445
+ STRETCH_PERCENTS.forEach(function(val, i) {
82446
+ if (Math.abs(val - Number(pct[1])) <
82447
+ Math.abs(STRETCH_PERCENTS[best - 1] - Number(pct[1]))) {
82448
+ best = i + 1;
82449
+ }
82450
+ });
82451
+ return best;
82452
+ }
82453
+
82454
+ // Family names are compared without spaces, punctuation or case, so that
82455
+ // "NYTFranklin" finds "NYT Franklin" and "Helvetica Neue" finds
82456
+ // "HelveticaNeue". Font vendors are not consistent about any of the three.
82457
+ function normalizeFamilyName(name) {
82458
+ return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
82459
+ }
82460
+
82461
+ // The families a face answers to. The typographic family (name ID 16) is the
82462
+ // one a stylesheet means: every weight of NYTFranklin has its own ID 1 family
82463
+ // ("NYTFranklin Light", "NYTFranklin Medium") and they are one family only
82464
+ // under ID 16. Both are indexed, because plenty of fonts have no ID 16.
82465
+ function getFaceFamilies(font) {
82466
+ var records = font && font.name && font.name.records || {};
82467
+ return [pickName(records.preferredFamily), pickName(records.fontFamily),
82468
+ font && font.familyName].filter(Boolean);
82469
+ }
82470
+
82471
+ function clearFontCache() {
82472
+ faceCache = {};
82473
+ familyCache = {};
82474
+ fullIndex = null;
82475
+ fileList = null;
82476
+ }
82477
+
82478
+ function lookupFace(family, weight, italic, stretch) {
82479
+ var names = resolveFamilyNames(family);
82480
+ var face = findBestFace(names, weight, italic, stretch);
82481
+ // The filename guess can find a family and still miss one of its faces:
82482
+ // Segoe UI Semibold lives in seguisb.ttf, which does not begin with the
82483
+ // family's name. Anything other than the face that was asked for is worth
82484
+ // the full index -- once per session -- to be sure it is the nearest this
82485
+ // machine has.
82486
+ if (!fullIndex && !faceAnswersRequest(face, weight, italic, stretch)) {
82487
+ fullIndex = readFaces(getFontFiles());
82488
+ face = findBestFace(names, weight, italic, stretch) || face;
80619
82489
  }
80620
- return parts.length > 0 ? parts.join(' ') : '';
82490
+ return face;
80621
82491
  }
80622
82492
 
80623
- function stringifyLineStringCoords(coords) {
80624
- if (coords.length === 0) return '';
80625
- var d = 'M';
80626
- var fromCurve = false;
80627
- var p, i, n;
80628
- for (i=0, n=coords.length; i<n; i++) {
80629
- p = coords[i];
80630
- if (isCubicCtrl(p)) {
80631
- // TODO: add defensive check
80632
- d += ' C' + stringifyVertex(p) + stringifyVertex(coords[++i]) + stringifyVertex(coords[++i]);
80633
- fromCurve = true;
80634
- } else if (fromCurve) {
80635
- d += ' L' + stringifyVertex(p);
80636
- fromCurve = false;
82493
+ function findBestFace(names, weight, italic, stretch) {
82494
+ for (var i = 0; i < names.length; i++) {
82495
+ var faces = findFamilyFaces(names[i]);
82496
+ if (faces && faces.length > 0) return pickFace(faces, weight, italic, stretch);
82497
+ }
82498
+ return null;
82499
+ }
82500
+
82501
+ function faceAnswersRequest(face, weight, italic, stretch) {
82502
+ return !!face && face.weight == (weight > 0 ? weight : 400) &&
82503
+ !!face.italic === !!italic && (face.width || 5) == parseFontStretch(stretch);
82504
+ }
82505
+
82506
+ // A font-family is a list, and may end in a generic: each name is tried in
82507
+ // turn, exactly as a browser would, and a generic stands for the first of its
82508
+ // candidates that is installed.
82509
+ function resolveFamilyNames(family) {
82510
+ var out = [];
82511
+ splitFamilyList(family).forEach(function(name) {
82512
+ var generic = GENERIC_FAMILIES[name.toLowerCase()];
82513
+ if (generic) {
82514
+ out = out.concat(generic);
80637
82515
  } else {
80638
- d += stringifyVertex(p);
82516
+ out.push(name);
80639
82517
  }
82518
+ });
82519
+ return out;
82520
+ }
82521
+
82522
+ function splitFamilyList(family) {
82523
+ return String(family || '').split(',').map(function(name) {
82524
+ return name.trim().replace(/^['"]|['"]$/g, '');
82525
+ }).filter(Boolean);
82526
+ }
82527
+
82528
+ // Two passes, because parsing every font on the computer costs the best part
82529
+ // of a second and most lookups do not need it: the file holding a family is
82530
+ // usually named after it, so files whose name begins with the family's are
82531
+ // parsed first. Whether that was good enough is lookupFace()'s decision.
82532
+ function findFamilyFaces(family) {
82533
+ var key = normalizeFamilyName(family);
82534
+ if (!key) return null;
82535
+ if (fullIndex) return fullIndex[key] || null;
82536
+ if (!(key in familyCache)) {
82537
+ familyCache[key] = readFaces(getLikelyFiles(key))[key] || null;
80640
82538
  }
80641
- return d;
82539
+ return familyCache[key];
80642
82540
  }
80643
82541
 
80644
- var SvgPathUtils = /*#__PURE__*/Object.freeze({
82542
+ function getLikelyFiles(key) {
82543
+ return getFontFiles().filter(function(file) {
82544
+ return normalizeFamilyName(basename(file)).indexOf(key) === 0;
82545
+ });
82546
+ }
82547
+
82548
+ // Faces by normalized family name. A file that cannot be parsed is skipped
82549
+ // rather than reported: a font directory can hold anything, and a broken font
82550
+ // is not an error in the user's data.
82551
+ function readFaces(files) {
82552
+ var index = {};
82553
+ files.forEach(function(file) {
82554
+ getFileFaces(file).forEach(function(face) {
82555
+ face.families.forEach(function(name) {
82556
+ var key = normalizeFamilyName(name);
82557
+ if (!key) return;
82558
+ if (!index[key]) index[key] = [];
82559
+ index[key].push(face);
82560
+ });
82561
+ });
82562
+ });
82563
+ return index;
82564
+ }
82565
+
82566
+ function getFileFaces(file) {
82567
+ var fontkit = getFontkit();
82568
+ var font, fonts;
82569
+ if (!fontkit) return [];
82570
+ try {
82571
+ font = fontkit.openSync(file);
82572
+ // A collection reports its members in .fonts; a single font is its own.
82573
+ fonts = font && font.fonts || [font];
82574
+ return fonts.filter(Boolean).map(function(one) {
82575
+ return {
82576
+ path: file,
82577
+ // Named rather than numbered because fontkit takes a name, and because
82578
+ // a name survives a font being reinstalled in a different order.
82579
+ postscriptName: font.fonts ? one.postscriptName : null,
82580
+ families: getFaceFamilies(one),
82581
+ weight: getFaceWeight(one),
82582
+ width: getFaceWidth(one),
82583
+ italic: isItalicFace(one)
82584
+ };
82585
+ }).filter(function(face) {
82586
+ return face.families.length > 0;
82587
+ });
82588
+ } catch (e) {
82589
+ return [];
82590
+ }
82591
+ }
82592
+
82593
+ function getFaceWeight(font) {
82594
+ var os2 = font['OS/2'];
82595
+ var weight = os2 && os2.usWeightClass;
82596
+ return weight > 0 ? weight : 400;
82597
+ }
82598
+
82599
+ function getFaceWidth(font) {
82600
+ var os2 = font['OS/2'];
82601
+ var width = os2 && os2.usWidthClass;
82602
+ return width >= 1 && width <= 9 ? width : 5;
82603
+ }
82604
+
82605
+ function isItalicFace(font) {
82606
+ var os2 = font['OS/2'];
82607
+ if (os2 && os2.fsSelection && typeof os2.fsSelection.italic == 'boolean') {
82608
+ return os2.fsSelection.italic;
82609
+ }
82610
+ return !!font.italicAngle;
82611
+ }
82612
+
82613
+ // A name record is a string, or an object of translations to pick English out
82614
+ // of, depending on the font and the version of fontkit.
82615
+ function pickName(rec) {
82616
+ if (!rec) return '';
82617
+ if (typeof rec == 'string') return rec;
82618
+ return rec.en || Object.keys(rec).map(function(k) { return rec[k]; })[0] || '';
82619
+ }
82620
+
82621
+ function compareScores(a, b) {
82622
+ for (var i = 0; i < a.length; i++) {
82623
+ if (a[i] != b[i]) return a[i] - b[i];
82624
+ }
82625
+ return 0;
82626
+ }
82627
+
82628
+ // Every font file on this computer, listed once. Listing is cheap -- a few
82629
+ // hundred directory entries -- next to parsing them, so this is not the part
82630
+ // worth avoiding.
82631
+ function getFontFiles() {
82632
+ var fs = getFs();
82633
+ var files = [];
82634
+ if (fileList) return fileList;
82635
+ if (!fs) return [];
82636
+ getFontDirs().forEach(function(dir) {
82637
+ var entries;
82638
+ try {
82639
+ entries = fs.readdirSync(dir, {recursive: true});
82640
+ } catch (e) {
82641
+ return; // a directory this platform does not have
82642
+ }
82643
+ entries.forEach(function(entry) {
82644
+ var file = dir + '/' + String(entry).split('\\').join('/');
82645
+ if (FONT_FILE_RXP.test(file)) files.push(file);
82646
+ });
82647
+ });
82648
+ fileList = files;
82649
+ return files;
82650
+ }
82651
+
82652
+ // Where each platform keeps fonts, or MAPSHAPER_FONT_PATH if it is set.
82653
+ //
82654
+ // It replaces the platform's directories rather than adding to them, which is
82655
+ // what makes a machine's font situation something a caller can state: a
82656
+ // container with its fonts somewhere of its own, a build that has to produce
82657
+ // the same SVG wherever it runs, a test that needs to know there is nothing to
82658
+ // find. Several directories are separated by : or ;.
82659
+ function getFontDirs() {
82660
+ var home = getHomeDir();
82661
+ var platform = typeof process == 'object' && process.platform || '';
82662
+ var dirs = getEnvDirs();
82663
+ if (dirs.length > 0) return dirs;
82664
+ if (platform == 'darwin') {
82665
+ dirs = ['/System/Library/Fonts', '/Library/Fonts',
82666
+ '/Network/Library/Fonts'];
82667
+ if (home) dirs.push(home + '/Library/Fonts');
82668
+ } else if (platform == 'win32') {
82669
+ dirs = [(getEnv('WINDIR') || 'C:\\Windows') + '/Fonts'];
82670
+ if (home) dirs.push(home + '/AppData/Local/Microsoft/Windows/Fonts');
82671
+ } else {
82672
+ dirs = ['/usr/share/fonts', '/usr/local/share/fonts', '/run/host/fonts'];
82673
+ if (home) {
82674
+ dirs.push(home + '/.fonts', home + '/.local/share/fonts');
82675
+ }
82676
+ }
82677
+ return dirs;
82678
+ }
82679
+
82680
+ function getEnvDirs() {
82681
+ var val = getEnv('MAPSHAPER_FONT_PATH');
82682
+ if (!val) return [];
82683
+ return val.split(/[:;]/).filter(Boolean);
82684
+ }
82685
+
82686
+ function getEnv(name) {
82687
+ return typeof process == 'object' && process.env && process.env[name] || '';
82688
+ }
82689
+
82690
+ function getHomeDir() {
82691
+ var os = safeRequire('os');
82692
+ try {
82693
+ return os && os.homedir() || '';
82694
+ } catch (e) {
82695
+ return '';
82696
+ }
82697
+ }
82698
+
82699
+ function basename(file) {
82700
+ var parts = file.split('/');
82701
+ return parts[parts.length - 1].replace(FONT_FILE_RXP, '');
82702
+ }
82703
+
82704
+ function getFs() {
82705
+ return safeRequire('fs');
82706
+ }
82707
+
82708
+ // Loaded through the require shim and only when a label actually needs
82709
+ // measuring, so that the browser bundle -- which is this same file -- never
82710
+ // reaches for a module it does not have, and a CLI run that touches no labels
82711
+ // never pays for loading it.
82712
+ function getFontkit() {
82713
+ return safeRequire('fontkit');
82714
+ }
82715
+
82716
+ function safeRequire(name) {
82717
+ if (runningInBrowser()) return null;
82718
+ try {
82719
+ return require$1(name) || null;
82720
+ } catch (e) {
82721
+ return null;
82722
+ }
82723
+ }
82724
+
82725
+ var FontLookup = /*#__PURE__*/Object.freeze({
80645
82726
  __proto__: null,
80646
- stringifyLineStringCoords: stringifyLineStringCoords,
80647
- stringifyPolygonCoords: stringifyPolygonCoords
82727
+ clearFontCache: clearFontCache,
82728
+ findFontFace: findFontFace,
82729
+ getFaceFamilies: getFaceFamilies,
82730
+ normalizeFamilyName: normalizeFamilyName,
82731
+ parseFontStretch: parseFontStretch,
82732
+ pickFace: pickFace
82733
+ });
82734
+
82735
+ // Measuring a label's text outside a browser, from the font files installed on
82736
+ // this computer.
82737
+ //
82738
+ // The GUI measures by rendering (gui-label-measure.mjs) and the core asks it
82739
+ // for widths through svg-label-metrics.mjs. Nothing answered that question in
82740
+ // Node, so `-style label-align=left` from the command line re-justified a
82741
+ // label's lines and left the block where it was, and the path-fit check could
82742
+ // not tell whether a label was longer than its curve.
82743
+ //
82744
+ // Advance widths plus kerning, which is what a browser lays out with: summing
82745
+ // hmtx advances alone is exact for most text but out by up to 5% on strings
82746
+ // like "AVATAR Toledo", and the pair positioning that closes that gap is what
82747
+ // fontkit's layout() applies. Measured against Chrome on the same fonts, this
82748
+ // agrees to a hundredth of a pixel.
82749
+ //
82750
+ // See docs/development/label-tool-design.md.
82751
+
82752
+ // Weight keywords. Anything else is a number, or 400 if it is not.
82753
+ var WEIGHT_NAMES = {normal: 400, bold: 700, lighter: 300, bolder: 700};
82754
+
82755
+ var fontCache = {};
82756
+
82757
+ // Installed for every Node use of mapshaper -- the CLI, the API and a script
82758
+ // that only exports -- rather than at one entry point, because a width is read
82759
+ // during rendering and export, which both of those reach without going near a
82760
+ // command of their own. Costs nothing until a label is measured: no font is
82761
+ // read, and fontkit is not even loaded, until then.
82762
+ function initNodeTextMeasurement() {
82763
+ if (runningInBrowser()) return false;
82764
+ setTextMeasureFunction(measureLabelText);
82765
+ return true;
82766
+ }
82767
+
82768
+ // The width of @rec's text in px, or null if it cannot be known -- an unusable
82769
+ // record, a font this computer does not have, or a font file that will not
82770
+ // parse. Null is what every reader already falls back from.
82771
+ function measureLabelText(rec) {
82772
+ var text = toLabelString(rec && rec['label-text']);
82773
+ var font, fontSize, spacing, width;
82774
+ if (!text) return null;
82775
+ font = getFontForRecord(rec);
82776
+ if (!font) return null;
82777
+ fontSize = getFontSizeInPx(rec);
82778
+ spacing = getLetterSpacingInPx(rec, fontSize);
82779
+ if (!(fontSize > 0)) return null;
82780
+ // The widest line, which is what the block of a multi-line label is as wide
82781
+ // as, and what the browser's getBBox() reports for the same text.
82782
+ width = text.split(labelNewlineRxp).reduce(function(max, line) {
82783
+ var w = measureLine(font, line, fontSize, spacing);
82784
+ return w > max ? w : max;
82785
+ }, 0);
82786
+ return width > 0 ? width : null;
82787
+ }
82788
+
82789
+ // Font units scaled to the size the label is drawn at, plus letter-spacing.
82790
+ //
82791
+ // Spacing is added after every character including the last, which is what the
82792
+ // browser does -- letter-spacing=2 on a four-character label widens it by 8px,
82793
+ // not 6.
82794
+ function measureLine(font, line, fontSize, spacing) {
82795
+ var chars = Array.from(line).length;
82796
+ var advance;
82797
+ if (!line) return 0;
82798
+ try {
82799
+ advance = font.layout(line).advanceWidth;
82800
+ } catch (e) {
82801
+ return 0;
82802
+ }
82803
+ return advance / font.unitsPerEm * fontSize + chars * spacing;
82804
+ }
82805
+
82806
+ // The font a record is drawn in, opened and remembered. A record with no
82807
+ // font-family is drawn in the layer group's default, the same one the GUI
82808
+ // measures against -- see getLabelTextDefaults().
82809
+ function getFontForRecord(rec) {
82810
+ var family = rec['font-family'] || 'sans-serif';
82811
+ var weight = getFontWeight(rec);
82812
+ var italic = isItalic(rec);
82813
+ var stretch = rec['font-stretch'] || '';
82814
+ var key = [family, weight, italic ? 'i' : 'n', stretch].join('|');
82815
+ if (!(key in fontCache)) {
82816
+ fontCache[key] = openFace(findFontFace(family, weight, italic, stretch),
82817
+ family, weight);
82818
+ }
82819
+ return fontCache[key];
82820
+ }
82821
+
82822
+ function openFace(face, family, weight) {
82823
+ var fontkit = loadFontkit();
82824
+ var font;
82825
+ if (!face || !fontkit) {
82826
+ // Once per font, and only for a font the user named: a label falling back
82827
+ // to the layer default is the ordinary case and not worth a warning, but a
82828
+ // label asking for a font this computer has not got is worth knowing
82829
+ // about, because its alignment is the thing that will be wrong.
82830
+ if (!face && family != 'sans-serif') {
82831
+ warnOnce('[label] Unable to measure text in font "' + family +
82832
+ '" (not installed?); alignment may be off.');
82833
+ }
82834
+ return null;
82835
+ }
82836
+ try {
82837
+ font = fontkit.openSync(face.path, face.postscriptName || undefined);
82838
+ return font ? applyVariation(font, weight) : null;
82839
+ } catch (e) {
82840
+ return null;
82841
+ }
82842
+ }
82843
+
82844
+ // A variable font set to the weight asked for. One file covers a range of
82845
+ // weights, and its glyphs are wider at the heavy end: measuring at the file's
82846
+ // default instance would report Light widths for Bold text. Only the weight
82847
+ // axis is applied, because that is the one a label can ask for -- slant and
82848
+ // width usually arrive as separate faces, which the lookup has already chosen
82849
+ // between.
82850
+ function applyVariation(font, weight) {
82851
+ var settings = getVariationSettings(font.variationAxes, weight);
82852
+ if (!settings) return font;
82853
+ try {
82854
+ return font.getVariation(settings) || font;
82855
+ } catch (e) {
82856
+ return font;
82857
+ }
82858
+ }
82859
+
82860
+ // The variation to set, or null for a font that is not variable or is already
82861
+ // at the weight wanted. A weight outside the axis is clamped to it, the way a
82862
+ // browser does.
82863
+ function getVariationSettings(axes, weight) {
82864
+ var axis = axes && axes.wght;
82865
+ var val;
82866
+ if (!axis) return null;
82867
+ val = Math.max(axis.min, Math.min(axis.max, weight));
82868
+ return val == axis.default ? null : {wght: val};
82869
+ }
82870
+
82871
+ // px, for a font-size that may be a number, a px value, an em value relative
82872
+ // to the layer default, or a pt value. Anything else takes the default rather
82873
+ // than failing the measurement: a size mapshaper cannot read is one the
82874
+ // renderer is also reading its own way, and a width from the default size is
82875
+ // closer than no width at all.
82876
+ function getFontSizeInPx(rec) {
82877
+ var val = rec && rec['font-size'];
82878
+ var px = toPixelMeasure(val, DEFAULT_LABEL_FONT_SIZE);
82879
+ return px === null ? DEFAULT_LABEL_FONT_SIZE : px;
82880
+ }
82881
+
82882
+ // px, for a letter-spacing relative to the label's own size rather than to the
82883
+ // layer default: 0.1em on 24px text is 2.4px.
82884
+ function getLetterSpacingInPx(rec, fontSize) {
82885
+ var px = toPixelMeasure(rec && rec['letter-spacing'], fontSize);
82886
+ return px === null ? 0 : px;
82887
+ }
82888
+
82889
+ function getFontWeight(rec) {
82890
+ var val = rec && rec['font-weight'];
82891
+ var named = WEIGHT_NAMES[String(val).toLowerCase()];
82892
+ if (named) return named;
82893
+ return Number(val) > 0 ? Number(val) : 400;
82894
+ }
82895
+
82896
+ function isItalic(rec) {
82897
+ var val = String(rec && rec['font-style'] || '').toLowerCase();
82898
+ return val == 'italic' || val == 'oblique';
82899
+ }
82900
+
82901
+ function toPixelMeasure(val, emBasis) {
82902
+ var measure, match;
82903
+ // An absent value is not a zero: a label with no font-size of its own is
82904
+ // drawn at the layer's size, and one with no letter-spacing is not spaced.
82905
+ if (val === null || val === undefined || val === '') return null;
82906
+ measure = parseSvgMeasure(val);
82907
+ if (typeof measure == 'number' && !isNaN(measure)) return measure;
82908
+ match = /^(-?[.0-9]+)(em|px|pt)$/.exec(String(measure));
82909
+ if (!match) return null;
82910
+ if (match[2] == 'em') return Number(match[1]) * emBasis;
82911
+ if (match[2] == 'pt') return Number(match[1]) * 4 / 3;
82912
+ return Number(match[1]);
82913
+ }
82914
+
82915
+ function loadFontkit() {
82916
+ if (runningInBrowser()) return null;
82917
+ try {
82918
+ return require$1('fontkit') || null;
82919
+ } catch (e) {
82920
+ warnOnce('[label] fontkit is not available; labels cannot be measured.');
82921
+ return null;
82922
+ }
82923
+ }
82924
+
82925
+ // For tests, and for a session that has installed a font since it started.
82926
+ function clearMeasuredFontCache() {
82927
+ fontCache = {};
82928
+ }
82929
+
82930
+ var TextMeasure = /*#__PURE__*/Object.freeze({
82931
+ __proto__: null,
82932
+ clearMeasuredFontCache: clearMeasuredFontCache,
82933
+ getFontSizeInPx: getFontSizeInPx,
82934
+ getFontWeight: getFontWeight,
82935
+ getLetterSpacingInPx: getLetterSpacingInPx,
82936
+ getVariationSettings: getVariationSettings,
82937
+ initNodeTextMeasurement: initNodeTextMeasurement,
82938
+ isItalic: isItalic,
82939
+ measureLabelText: measureLabelText
80648
82940
  });
80649
82941
 
80650
82942
  // Return an array containing points from a path iterator, clipped to a bounding box
@@ -82262,7 +84554,16 @@ ${svg}
82262
84554
  // export only functions called by the GUI.
82263
84555
  var internal = {};
82264
84556
 
82265
- internal.svg = Object.assign({}, SvgStringify, SvgPathUtils, GeojsonToSvg, SvgLabels, SvgSymbols);
84557
+ internal.svg = Object.assign({}, SvgStringify, SvgPathUtils, GeojsonToSvg,
84558
+ SvgFeatureUtils,
84559
+ SvgLabels, SvgSymbols, SvgLabelPaths, SvgLabelFit, SvgLabelAlign,
84560
+ SvgLabelMetrics);
84561
+
84562
+ // Reached through the bundle rather than imported from source, unlike most of
84563
+ // what tests use, because these modules load fs and fontkit through the
84564
+ // require shim -- which resolves to a stub outside the bundle, there being no
84565
+ // require() in an ES module.
84566
+ internal.fonts = Object.assign({}, FontLookup, TextMeasure);
82266
84567
 
82267
84568
  // Assign functions and objects exported from modules to the 'internal' namespace
82268
84569
  // to maintain compatibility with tests and to expose (some of) them to the GUI.
@@ -82307,6 +84608,7 @@ ${svg}
82307
84608
  // BufferCommon,
82308
84609
  Calc,
82309
84610
  CalcUtils,
84611
+ CurveFit,
82310
84612
  Catalog$1,
82311
84613
  ClipErase,
82312
84614
  ClipPoints,
@@ -82440,6 +84742,13 @@ ${svg}
82440
84742
  Zip
82441
84743
  );
82442
84744
 
84745
+ // Outside a browser, label text is measured from the installed font files --
84746
+ // see mapshaper-text-measure.mjs. Installed here rather than in the CLI's
84747
+ // entry point because a width is read while rendering and exporting, which the
84748
+ // API reaches without a command line, and it costs nothing until a label
84749
+ // actually needs measuring.
84750
+ initNodeTextMeasurement();
84751
+
82443
84752
  // the mapshaper public api only has 4 functions
82444
84753
  var api = {
82445
84754
  runCommands,