@versatiles/svg-renderer 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Renders vector maps as SVG.
12
12
 
13
13
  [Download SVG](docs/demo.svg)
14
14
 
15
- Currently supported layer types: background, fill, line, and raster.
15
+ Supported layer types: background, fill, line, circle, symbol, and raster.
16
16
 
17
17
  ## Installation
18
18
 
@@ -103,15 +103,25 @@ new SVGExportControl({
103
103
 
104
104
  ### `renderToSVG(options): Promise<string>`
105
105
 
106
- | Option | Type | Default | Description |
107
- | -------- | -------------------- | ------------ | ---------------------------- |
108
- | `style` | `StyleSpecification` | *(required)* | MapLibre style specification |
109
- | `width` | `number` | `1024` | Output width in pixels |
110
- | `height` | `number` | `1024` | Output height in pixels |
111
- | `scale` | `number` | `1` | Scale factor |
112
- | `lon` | `number` | `0` | Center longitude |
113
- | `lat` | `number` | `0` | Center latitude |
114
- | `zoom` | `number` | `2` | Zoom level |
106
+ | Option | Type | Default | Description |
107
+ | -------------- | -------------------- | ------------ | ----------------------------------------- |
108
+ | `style` | `StyleSpecification` | *(required)* | MapLibre style specification |
109
+ | `width` | `number` | `1024` | Output width in pixels |
110
+ | `height` | `number` | `1024` | Output height in pixels |
111
+ | `lon` | `number` | `0` | Center longitude |
112
+ | `lat` | `number` | `0` | Center latitude |
113
+ | `zoom` | `number` | `2` | Zoom level |
114
+ | `renderLabels` | `boolean` | `false` | Enable rendering of text labels and icons |
115
+
116
+ ### About `renderLabels`
117
+
118
+ When `renderLabels` is set to `true`, symbol layers are rendered, including text labels and sprite-based icons. By default, this option is disabled.
119
+
120
+ > [!WARNING]
121
+ > The rendering of labels and icons is experimental and may produce imperfect results. Since we cannot use the original layouting engine of MapLibre GL JS, there are known limitations:
122
+ >
123
+ > - **No collision detection:** Text labels are rendered without collision detection, so labels may overlap.
124
+ > - **Simplified text placement:** Labels can not be positioned along lines.
115
125
 
116
126
  ## E2E Visual Comparison
117
127
 
package/dist/index.cjs CHANGED
@@ -9263,6 +9263,9 @@ class SVGRenderer {
9263
9263
  height;
9264
9264
  #svg;
9265
9265
  #backgroundColor;
9266
+ #spriteSheetDefs = new Map();
9267
+ #spriteSymbolDefs = new Map();
9268
+ #sdfFilterDefs = new Map();
9266
9269
  constructor(opt) {
9267
9270
  this.width = opt.width;
9268
9271
  this.height = opt.height;
@@ -9455,7 +9458,7 @@ class SVGRenderer {
9455
9458
  drawIcons(id, features, spriteAtlas) {
9456
9459
  if (features.length === 0)
9457
9460
  return;
9458
- this.#svg.push(`<g id="${escapeXml(id)}">`);
9461
+ const elements = [];
9459
9462
  for (const [feature, style] of features) {
9460
9463
  if (style.opacity <= 0)
9461
9464
  continue;
@@ -9472,27 +9475,102 @@ class SVGRenderer {
9472
9475
  const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
9473
9476
  const ox = style.offset[0] * style.size + anchorDx;
9474
9477
  const oy = style.offset[1] * style.size + anchorDy;
9475
- const x = point.x + ox;
9476
- const y = point.y + oy;
9477
- const [sx, sy] = roundXY(x, y);
9478
- const [sw, sh] = roundXY(iconW, iconH);
9479
- const viewBox = `${String(sprite.x)} ${String(sprite.y)} ${String(sprite.width)} ${String(sprite.height)}`;
9480
- const attrs = [
9481
- `x="${formatNum(sx)}"`,
9482
- `y="${formatNum(sy)}"`,
9483
- `width="${formatNum(sw)}"`,
9484
- `height="${formatNum(sh)}"`,
9485
- ];
9486
- if (style.opacity < 1)
9487
- attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9478
+ const [iconXr, iconYr] = roundXY(point.x + ox, point.y + oy);
9479
+ // Register sprite sheet in global defs (once per unique data URI)
9480
+ const imgW = Math.round(sprite.sheetWidth * 10);
9481
+ const imgH = Math.round(sprite.sheetHeight * 10);
9482
+ const sheetKey = sprite.sheetDataUri;
9483
+ if (!this.#spriteSheetDefs.has(sheetKey)) {
9484
+ this.#spriteSheetDefs.set(sheetKey, {
9485
+ defId: `sprite-sheet-${String(this.#spriteSheetDefs.size)}`,
9486
+ width: imgW,
9487
+ height: imgH,
9488
+ href: sprite.sheetDataUri,
9489
+ });
9490
+ }
9491
+ const sheetDef = this.#spriteSheetDefs.get(sheetKey);
9492
+ // Register symbol for this sprite (once per sprite name + sheet)
9493
+ const sprX = Math.round(sprite.x * 10);
9494
+ const sprY = Math.round(sprite.y * 10);
9495
+ const sprW = Math.round(sprite.width * 10);
9496
+ const sprH = Math.round(sprite.height * 10);
9497
+ const symKey = `${style.image}\0${sheetKey}`;
9498
+ if (!this.#spriteSymbolDefs.has(symKey)) {
9499
+ this.#spriteSymbolDefs.set(symKey, {
9500
+ symbolId: `sprite-${escapeXml(style.image)}`,
9501
+ sheetDefId: sheetDef.defId,
9502
+ x: sprX,
9503
+ y: sprY,
9504
+ width: sprW,
9505
+ height: sprH,
9506
+ });
9507
+ }
9508
+ const symDef = this.#spriteSymbolDefs.get(symKey);
9509
+ // Build instance: translate to position, scale from native to desired size
9510
+ const scaleStr = scale === 1 ? '' : ` scale(${formatScale(scale)})`;
9511
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9512
+ // SDF filter for colorable icons
9513
+ let filterAttr = '';
9514
+ if (style.sdf) {
9515
+ const iconColor = new Color(style.color);
9516
+ const haloColor = new Color(style.haloColor);
9517
+ const hasHalo = style.haloWidth > 0 && haloColor.alpha > 0;
9518
+ const filterKey = hasHalo
9519
+ ? `sdf\0${iconColor.hex}\0${haloColor.hex}\0${String(style.haloWidth)}`
9520
+ : `sdf\0${iconColor.hex}`;
9521
+ if (!this.#sdfFilterDefs.has(filterKey)) {
9522
+ const filterId = `sdf-${String(this.#sdfFilterDefs.size)}`;
9523
+ const iconFloodOpacity = iconColor.alpha < 255 ? ` flood-opacity="${iconColor.opacity.toFixed(3)}"` : '';
9524
+ let content;
9525
+ if (hasHalo) {
9526
+ const haloRadius = formatScale(style.haloWidth);
9527
+ const haloFloodOpacity = haloColor.alpha < 255 ? ` flood-opacity="${haloColor.opacity.toFixed(3)}"` : '';
9528
+ content =
9529
+ `<filter id="${filterId}" color-interpolation-filters="sRGB">` +
9530
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp icon mask
9531
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9532
+ // Dilate sharp mask for halo
9533
+ `<feMorphology in="sharp" operator="dilate" radius="${haloRadius}" result="dilated" />` +
9534
+ `<feFlood flood-color="${haloColor.rgb}"${haloFloodOpacity} result="haloColor" />` +
9535
+ `<feComposite in="haloColor" in2="dilated" operator="in" result="halo" />` +
9536
+ // Color the sharp icon
9537
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="iconColor" />` +
9538
+ `<feComposite in="iconColor" in2="sharp" operator="in" result="colored" />` +
9539
+ `<feComposite in="colored" in2="halo" operator="over" />` +
9540
+ `</filter>`;
9541
+ }
9542
+ else {
9543
+ content =
9544
+ `<filter id="${filterId}" x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB">` +
9545
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp mask
9546
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9547
+ // Replace color while keeping sharp alpha
9548
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="color" />` +
9549
+ `<feComposite in="color" in2="sharp" operator="in" />` +
9550
+ `</filter>`;
9551
+ }
9552
+ this.#sdfFilterDefs.set(filterKey, { filterId, content });
9553
+ }
9554
+ const { filterId } = this.#sdfFilterDefs.get(filterKey);
9555
+ filterAttr = ` filter="url(#${filterId})"`;
9556
+ }
9488
9557
  if (style.rotate !== 0) {
9489
9558
  const [cx, cy] = roundXY(point.x + style.offset[0] * style.size, point.y + style.offset[1] * style.size);
9490
- attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})"`);
9559
+ elements.push(`<g transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})">` +
9560
+ `<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9561
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9562
+ `</g></g>`);
9563
+ }
9564
+ else {
9565
+ elements.push(`<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9566
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9567
+ `</g>`);
9491
9568
  }
9492
- this.#svg.push(`<svg ${attrs.join(' ')} viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">` +
9493
- `<image width="${String(sprite.sheetWidth)}" height="${String(sprite.sheetHeight)}" href="${sprite.sheetDataUri}" />` +
9494
- `</svg>`);
9495
9569
  }
9570
+ if (elements.length === 0)
9571
+ return;
9572
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9573
+ this.#svg.push(...elements);
9496
9574
  this.#svg.push('</g>');
9497
9575
  }
9498
9576
  drawRasterTiles(id, tiles, style) {
@@ -9528,9 +9606,21 @@ class SVGRenderer {
9528
9606
  getString() {
9529
9607
  const w = this.width.toFixed(0);
9530
9608
  const h = this.height.toFixed(0);
9609
+ // Build defs content
9610
+ const defsContent = [`<clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath>`];
9611
+ for (const sheet of this.#spriteSheetDefs.values()) {
9612
+ defsContent.push(`<image id="${escapeXml(sheet.defId)}" width="${formatNum(sheet.width)}" height="${formatNum(sheet.height)}" href="${escapeXml(sheet.href)}" />`);
9613
+ }
9614
+ for (const sym of this.#spriteSymbolDefs.values()) {
9615
+ const clipId = `${sym.symbolId}-clip`;
9616
+ defsContent.push(`<clipPath id="${escapeXml(clipId)}"><rect width="${formatNum(sym.width)}" height="${formatNum(sym.height)}" /></clipPath>`, `<symbol id="${escapeXml(sym.symbolId)}"><g clip-path="url(#${escapeXml(clipId)})"><use href="#${escapeXml(sym.sheetDefId)}" x="${formatNum(-sym.x)}" y="${formatNum(-sym.y)}" /></g></symbol>`);
9617
+ }
9618
+ for (const { content } of this.#sdfFilterDefs.values()) {
9619
+ defsContent.push(content);
9620
+ }
9531
9621
  const parts = [
9532
9622
  `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">`,
9533
- `<defs><clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath></defs>`,
9623
+ `<defs>\n ${defsContent.join('\n ')}\n</defs>`,
9534
9624
  `<g clip-path="url(#vb)">`,
9535
9625
  ];
9536
9626
  if (this.#backgroundColor.alpha > 0) {
@@ -9555,6 +9645,9 @@ function strokeAttr(color, width) {
9555
9645
  function formatScaled(v) {
9556
9646
  return formatNum(Math.round(v * 10));
9557
9647
  }
9648
+ function formatScale(v) {
9649
+ return (Math.round(v * 10000) / 10000).toString();
9650
+ }
9558
9651
  function roundXY(x, y) {
9559
9652
  return [Math.round(x * 10), Math.round(y * 10)];
9560
9653
  }
@@ -16017,14 +16110,29 @@ async function loadVectorSource(source, job, layerFeatures) {
16017
16110
  list = features.polygons;
16018
16111
  break;
16019
16112
  }
16020
- const feature = new Feature({
16021
- type,
16022
- geometry,
16023
- id: featureSrc.id,
16024
- properties: featureSrc.properties,
16025
- });
16026
- if (feature.doesOverlap([0, 0, width, height]))
16027
- list.push(feature);
16113
+ // Split MultiPoint into individual Point features
16114
+ if (type === 'Point' && geometry.length > 1) {
16115
+ for (const ring of geometry) {
16116
+ const feature = new Feature({
16117
+ type,
16118
+ geometry: [ring],
16119
+ id: featureSrc.id,
16120
+ properties: featureSrc.properties,
16121
+ });
16122
+ if (feature.doesOverlap([0, 0, width, height]))
16123
+ list.push(feature);
16124
+ }
16125
+ }
16126
+ else {
16127
+ const feature = new Feature({
16128
+ type,
16129
+ geometry,
16130
+ id: featureSrc.id,
16131
+ properties: featureSrc.properties,
16132
+ });
16133
+ if (feature.doesOverlap([0, 0, width, height]))
16134
+ list.push(feature);
16135
+ }
16028
16136
  }
16029
16137
  }
16030
16138
  }));
@@ -16161,6 +16269,14 @@ async function getRasterTiles(job, sourceName) {
16161
16269
  return rasterTiles.filter((tile) => tile !== null);
16162
16270
  }
16163
16271
 
16272
+ async function fetchSpritePair(url) {
16273
+ const [jsonResponse, imageResponse] = await Promise.all([
16274
+ fetch(`${url}.json`),
16275
+ fetch(`${url}.png`),
16276
+ ]);
16277
+ if (jsonResponse.ok && imageResponse.ok)
16278
+ return { jsonResponse, imageResponse };
16279
+ }
16164
16280
  async function loadSpriteAtlas(style) {
16165
16281
  const atlas = new Map();
16166
16282
  const sprite = style.sprite;
@@ -16180,12 +16296,11 @@ async function loadSpriteAtlas(style) {
16180
16296
  }
16181
16297
  await Promise.all(sources.map(async ({ id, url }) => {
16182
16298
  try {
16183
- const [jsonResponse, imageResponse] = await Promise.all([
16184
- fetch(`${url}.json`),
16185
- fetch(`${url}.png`),
16186
- ]);
16187
- if (!jsonResponse.ok || !imageResponse.ok)
16299
+ // Try @2x retina sprites first, fall back to 1x
16300
+ const spritePair = (await fetchSpritePair(`${url}@2x`)) ?? (await fetchSpritePair(url));
16301
+ if (!spritePair)
16188
16302
  return;
16303
+ const { jsonResponse, imageResponse } = spritePair;
16189
16304
  const json = (await jsonResponse.json());
16190
16305
  const imageBuffer = await imageResponse.arrayBuffer();
16191
16306
  const base64 = typeof Buffer !== 'undefined'
@@ -16207,6 +16322,7 @@ async function loadSpriteAtlas(style) {
16207
16322
  x: entry.x,
16208
16323
  y: entry.y,
16209
16324
  pixelRatio: entry.pixelRatio ?? 1,
16325
+ sdf: entry.sdf ?? false,
16210
16326
  sheetDataUri,
16211
16327
  sheetWidth,
16212
16328
  sheetHeight,
@@ -16540,6 +16656,7 @@ async function render(job) {
16540
16656
  : '';
16541
16657
  if (!iconName || !spriteAtlas.has(iconName))
16542
16658
  return [];
16659
+ const spriteEntry = spriteAtlas.get(iconName);
16543
16660
  return [
16544
16661
  [
16545
16662
  feature,
@@ -16550,6 +16667,10 @@ async function render(job) {
16550
16667
  offset: getLayout('icon-offset', feature),
16551
16668
  rotate: getLayout('icon-rotate', feature),
16552
16669
  opacity: getPaint('icon-opacity', feature),
16670
+ sdf: spriteEntry.sdf,
16671
+ color: getPaint('icon-color', feature),
16672
+ haloColor: getPaint('icon-halo-color', feature),
16673
+ haloWidth: getPaint('icon-halo-width', feature),
16553
16674
  },
16554
16675
  ],
16555
16676
  ];