documonster 0.16.0 → 0.16.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
@@ -13,7 +13,7 @@ Documonster is a zero-dependency TypeScript toolkit for spreadsheets and documen
13
13
  - **AI-Friendly** — Clean, consistent API designed for AI coding agents. Every module has comprehensive documentation and runnable examples for AI to learn from. An [MCP server](packages/mcp/README.md) is available for AI clients that need to work on real files
14
14
  - **Zero Runtime Dependencies** — Pure TypeScript, no external packages
15
15
  - **Nine Modules** — Excel, Word, Formula, PDF, CSV, Markdown, XML, Archive, Stream
16
- - **Cross-Platform** — Node.js 22.13+, Bun, Chrome 89+, Firefox 102+, Safari 14.1+
16
+ - **Cross-Platform** — Node.js LTS + Current (22, 24, 26), Bun latest, Chrome 98+, Firefox 102+, Safari 15.4+
17
17
  - **ESM only** — Native ES Modules with full tree-shaking; CommonJS consumers `require()` it unchanged on Node >= 22.13
18
18
 
19
19
  ## Modules
@@ -211,7 +211,7 @@ const buffer = await Workbook.toBuffer(wb);
211
211
 
212
212
  ```html
213
213
  <!-- Script tag (no bundler) — one IIFE per module, each under the shared `Documonster` global -->
214
- <script src="https://unpkg.com/documonster@0.16.0/dist/iife/documonster.excel.iife.min.js"></script>
214
+ <script src="https://unpkg.com/documonster@0.16.1/dist/iife/documonster.excel.iife.min.js"></script>
215
215
  <script>
216
216
  const { Workbook, Cell } = Documonster.Excel;
217
217
  const wb = Workbook.create();
@@ -246,13 +246,24 @@ For older browsers without native `CompressionStream` API, Documonster automatic
246
246
 
247
247
  ## Requirements
248
248
 
249
- - **Node.js >= 22.13.0**
250
- - **Bun >= 1.0**
249
+ | Runtime | Supported |
250
+ | ---------- | ----------------- |
251
+ | Node.js 22 | LTS — floor 22.13 |
252
+ | Node.js 24 | LTS |
253
+ | Node.js 26 | Current |
254
+ | Bun | latest |
255
+
256
+ | Browser | Minimum Version |
257
+ | ------- | ------------------- |
258
+ | Chrome | 98+ (February 2022) |
259
+ | Edge | 98+ (February 2022) |
260
+ | Firefox | 102+ (June 2022) |
261
+ | Safari | 15.4+ (March 2022) |
262
+ | Opera | 84+ (February 2022) |
251
263
 
252
264
  The package is ESM-only. Nothing changes for Node ESM, a bundler or a `<script>` tag, and a
253
265
  CommonJS `require()` call site is unchanged too — Node loads an ES module through `require()`
254
- from 22.12, and stops printing an experimental warning about it from 22.13, which is why that
255
- is the floor.
266
+ from 22.12, and stops warning about it from 22.13, which is why the floor is that patch.
256
267
 
257
268
  <details>
258
269
  <summary>Using it from TypeScript with <code>module: node16</code></summary>
@@ -298,14 +309,6 @@ Vitest needs no configuration.
298
309
 
299
310
  </details>
300
311
 
301
- | Browser | Minimum Version |
302
- | ------- | ------------------ |
303
- | Chrome | 89+ (March 2021) |
304
- | Edge | 89+ (March 2021) |
305
- | Firefox | 102+ (June 2022) |
306
- | Safari | 14.1+ (April 2021) |
307
- | Opera | 75+ (March 2021) |
308
-
309
312
  ## Links
310
313
 
311
314
  - 🏠 [GitHub Repository](https://github.com/documonster/documonster)
package/README_zh.md CHANGED
@@ -13,7 +13,7 @@ Documonster 是一个零依赖的 TypeScript 电子表格和文档工具包:
13
13
  - **AI 友好** — 简洁一致的 API,专为 AI 编程助手设计。每个模块都配有完整的文档和可运行的示例供 AI 学习。另提供 [MCP 服务器](packages/mcp/README.md),供需要操作真实文件的 AI 客户端使用
14
14
  - **零运行时依赖** — 纯 TypeScript,无外部包
15
15
  - **九大模块** — Excel、Word、Formula、PDF、CSV、Markdown、XML、Archive、Stream
16
- - **跨平台** — Node.js 22.13+、Bun、Chrome 89+、Firefox 102+、Safari 14.1+
16
+ - **跨平台** — Node.js LTS + Current(22、24、26)、Bun latest、Chrome 98+、Firefox 102+、Safari 15.4+
17
17
  - **纯 ESM** — 原生 ES Modules,完整 tree-shaking;CommonJS 消费者在 Node >= 22.13 上写法不变,直接 `require()`
18
18
 
19
19
  ## 模块
@@ -198,7 +198,7 @@ const buffer = await Workbook.toBuffer(wb);
198
198
 
199
199
  ```html
200
200
  <!-- Script 标签(无需打包工具)— 每个模块一个 IIFE,共享同一个 `Documonster` 全局 -->
201
- <script src="https://unpkg.com/documonster@0.16.0/dist/iife/documonster.excel.iife.min.js"></script>
201
+ <script src="https://unpkg.com/documonster@0.16.1/dist/iife/documonster.excel.iife.min.js"></script>
202
202
  <script>
203
203
  const { Workbook, Cell } = Documonster.Excel;
204
204
  const wb = Workbook.create();
@@ -231,12 +231,24 @@ URL 中的版本号是刻意锁定的:不锁版本的 `unpkg.com/documonster/
231
231
 
232
232
  ## 系统要求
233
233
 
234
- - **Node.js >= 22.13.0**
235
- - **Bun >= 1.0**
236
-
237
- 本包是纯 ESM。Node ESM、打包工具、`<script>` 标签一切不变;CommonJS 的 `require()` 写法也
238
- 不变——Node 从 22.12 起支持用 `require()` 加载 ES 模块,从 22.13 起不再为此打印实验特性警告,
239
- 所以下限定在 22.13。
234
+ | 运行时 | 支持范围 |
235
+ | ---------- | ----------------- |
236
+ | Node.js 22 | LTS —— 下限 22.13 |
237
+ | Node.js 24 | LTS |
238
+ | Node.js 26 | Current |
239
+ | Bun | latest |
240
+
241
+ | 浏览器 | 最低版本 |
242
+ | ------- | --------------------- |
243
+ | Chrome | 98+(2022 年 2 月) |
244
+ | Edge | 98+(2022 年 2 月) |
245
+ | Firefox | 102+(2022 年 6 月) |
246
+ | Safari | 15.4+(2022 年 3 月) |
247
+ | Opera | 84+(2022 年 2 月) |
248
+
249
+ 本包是纯 ESM。Node ESM、打包工具、`<script>` 标签一切不变;CommonJS 的 `require()` 写法也
250
+ 不变 —— Node 从 22.12 起支持用 `require()` 加载 ES 模块,从 22.13 起不再为此打印警告,所以
251
+ 下限定在这个补丁版本。
240
252
 
241
253
  <details>
242
254
  <summary>TypeScript 用 <code>module: node16</code> 时</summary>
@@ -279,14 +291,6 @@ Vitest 无需任何配置。
279
291
 
280
292
  </details>
281
293
 
282
- | 浏览器 | 最低版本 |
283
- | ------- | ------------------ |
284
- | Chrome | 89+(2021年3月) |
285
- | Edge | 89+(2021年3月) |
286
- | Firefox | 102+(2022年6月) |
287
- | Safari | 14.1+(2021年4月) |
288
- | Opera | 75+(2021年3月) |
289
-
290
294
  ## 链接
291
295
 
292
296
  - 🏠 [GitHub 仓库](https://github.com/documonster/documonster)
@@ -1,3 +1,4 @@
1
+ import { hasDeflateRawCompressionStream } from "../compression/compress.base.js";
1
2
  import { crc32Finalize, crc32Update } from "#platform/modules/archive/compression/crc32";
2
3
  import { ByteQueue } from "../core/byte-queue.js";
3
4
  import { DEFAULT_ZIP_LEVEL, DEFAULT_ZIP_TIMESTAMPS, REPRODUCIBLE_ZIP_MOD_TIME } from "../core/defaults.js";
@@ -14,6 +15,28 @@ import { buildZipDeflateFileOptions } from "./zip-entry-options.js";
14
15
  import { createZipOperation } from "./zip-output-pipeline.js";
15
16
  import { stringToUint8Array as encodeUtf8 } from "../../../utils/binary.js";
16
17
  import { isNode } from "../../../utils/env.js";
18
+ /**
19
+ * Decide whether {@link ZipArchive.bytes} may take the browser Blob fast path,
20
+ * which streams each source instead of materialising every Blob through
21
+ * `Blob.arrayBuffer()`.
22
+ *
23
+ * A pure function because `isNode` is one of its terms, so the decision cannot
24
+ * be reached from a Node test otherwise.
25
+ *
26
+ * `hasDeflateRawCompressionStream` is a thunk so a STORE-only archive never
27
+ * consults it: the probe constructs a `CompressionStream`, and an archive with
28
+ * `needsDeflate: false` neither needs the codec nor may be refused on it.
29
+ * Chromium 80–102 has the class but not the `deflate-raw` format.
30
+ */
31
+ export function canUseBrowserBlobFastPath(facts) {
32
+ if (facts.isNode || !facts.hasBlobSource || !facts.allSourcesInMemory) {
33
+ return false;
34
+ }
35
+ if (facts.smartStore || facts.zip64Requested) {
36
+ return false;
37
+ }
38
+ return !facts.needsDeflate || facts.hasDeflateRawCompressionStream();
39
+ }
17
40
  export class ZipArchive {
18
41
  constructor(options = {}) {
19
42
  this._entries = [];
@@ -291,13 +314,20 @@ export class ZipArchive {
291
314
  }
292
315
  const allSourcesInMemory = this._entries.every(e => isInMemoryArchiveSource(e.source));
293
316
  const hasBlobSource = typeof Blob !== "undefined" && this._entries.some(e => e.source instanceof Blob);
294
- // Browser fast path: stream Blob sources through CompressionStream
295
- const canUseBrowserFastPath = !isNode() &&
296
- hasBlobSource &&
297
- !this._options.smartStore &&
298
- this._options.zip64 !== true &&
299
- typeof CompressionStream !== "undefined";
300
- if (canUseBrowserFastPath && allSourcesInMemory) {
317
+ // Browser fast path: stream Blob sources instead of materialising them.
318
+ // `needsDeflate` mirrors the per-entry `level > 0` test inside
319
+ // `_browserStreamingBytes`, which is the only thing there that reaches
320
+ // for `CompressionStream("deflate-raw")`.
321
+ const needsDeflate = this._entries.some(e => (e.options?.level ?? this._options.level) > 0);
322
+ if (canUseBrowserBlobFastPath({
323
+ isNode: isNode(),
324
+ hasBlobSource,
325
+ allSourcesInMemory,
326
+ smartStore: this._options.smartStore,
327
+ zip64Requested: this._options.zip64 === true,
328
+ needsDeflate,
329
+ hasDeflateRawCompressionStream
330
+ })) {
301
331
  try {
302
332
  return await this._browserStreamingBytes();
303
333
  }
@@ -1334,10 +1334,10 @@ function _chartExtractFirstRunProperties(c, title) {
1334
1334
  return undefined;
1335
1335
  }
1336
1336
  /**
1337
- * Deep-clone a chart model. Uses `structuredClone` which is always available
1338
- * in our supported environments (Node 22+, all modern browsers) and handles
1339
- * the `Uint8Array` captured on {@link ChartExModel.rawXml} correctly — the
1340
- * older `JSON.parse(JSON.stringify(...))` fallback stripped the typed-array
1337
+ * Deep-clone a chart model. `structuredClone` is available in every supported
1338
+ * environment and preserves the `Uint8Array` values on
1339
+ * {@link ChartExModel.externalParts}; the older
1340
+ * `JSON.parse(JSON.stringify(...))` fallback stripped the typed-array
1341
1341
  * prototype and corrupted round-trip data.
1342
1342
  */
1343
1343
  function deepClone(obj) {
@@ -1,5 +1,4 @@
1
- import { encodePng, withPngDpi } from "../../../archive/png.js";
2
- import { BasicRasterCanvas } from "../../../draw/raster/canvas.js";
1
+ import { withPngDpi } from "../../../archive/png.js";
3
2
  import { renderDrawList } from "../../../draw/render.js";
4
3
  import { toSvg } from "../../../draw/svg.js";
5
4
  import { createChartPdfDrawSurface } from "./chart-pdf-draw-surface.js";
@@ -7,7 +6,6 @@ import { rasterizeDrawList } from "./draw-raster-png.js";
7
6
  import { sceneToDrawList } from "./scene-to-draw.js";
8
7
  import { AXIS_COLOR, COLORS, DEFAULT_HEIGHT, DEFAULT_WIDTH, GRID_COLOR, clamp01, densifySparsePoints, estimateTextWidth, fmt, interpolateColor, normalizeHex6, previewShapeFillColor, previewShapeLineColor, previewShapeLineWidthPx, resolveChartColor, singleLineLabel, valueToX, valueToY } from "../shared/chart-utils.js";
9
8
  import { parseSpPr, parseTxPr, getSpPrFill, getSpPrLine, getTxPrFontSize } from "../shared/shape-properties.js";
10
- import { parseCssColor, parseSvgAttributes, parseSvgNumberList, parseSvgPointPairs, parseSvgRotate, parseSvgTextRuns } from "../../../../utils/svg-lex.js";
11
9
  // ---------------------------------------------------------------------------
12
10
  // Plot layout constants — extracted from getPlotRect / legendRect so the
13
11
  // spatial reasoning behind the scene builder is self-documenting.
@@ -262,7 +260,11 @@ export async function renderSvgToPng(svg, options) {
262
260
  const outputWidth = Math.max(1, Math.round(width * scale));
263
261
  const outputHeight = Math.max(1, Math.round(height * scale));
264
262
  if (typeof document === "undefined" || typeof Image === "undefined") {
265
- return renderSvgToBasicPng(svg, width, height, scale, options.dpi);
263
+ // Unreachable in production: both callers guard on the same pair. This used
264
+ // to re-parse the SVG with a regex scanner, which had no callers left once
265
+ // the chart PNG paths started rasterising a display list directly.
266
+ throw new Error("renderSvgToPng needs a DOM: use renderChartPng or renderChartExPng, which " +
267
+ "rasterise a display list directly on Node.");
266
268
  }
267
269
  const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
268
270
  const url = URL.createObjectURL(blob);
@@ -294,381 +296,6 @@ export async function renderSvgToPng(svg, options) {
294
296
  URL.revokeObjectURL(url);
295
297
  }
296
298
  }
297
- /**
298
- * Scale a `points` list into device pixels.
299
- *
300
- * Tokenising lives in `@utils/svg-lex` so this raster path and the PDF SVG
301
- * importer cannot disagree about what a coordinate list means; only the scale is
302
- * local.
303
- */
304
- /**
305
- * Parse `stroke-dasharray` into device-pixel on/off lengths.
306
- *
307
- * Returns `undefined` for `none`, an empty list or an all-zero pattern, so the
308
- * caller keeps the cheaper solid path. An odd-length list repeats to make an even
309
- * cycle, which is what SVG specifies.
310
- */
311
- function parseStrokeDashArray(value, scale) {
312
- if (!value || value.trim() === "none") {
313
- return undefined;
314
- }
315
- const parts = parseSvgNumberList(value).filter(part => part >= 0);
316
- if (parts.length === 0 || parts.every(part => part === 0)) {
317
- return undefined;
318
- }
319
- const cycle = parts.length % 2 === 0 ? parts : [...parts, ...parts];
320
- return cycle.map(part => part * scale);
321
- }
322
- function scaledSvgPoints(input, scale) {
323
- return parseSvgPointPairs(input).map(point => ({ x: point.x * scale, y: point.y * scale }));
324
- }
325
- /**
326
- * Resolve a paint to 8-bit RGBA, folding in the SVG opacity attributes.
327
- *
328
- * `opacity` applies to the element as a whole and multiplies with the
329
- * channel-specific `fill-opacity` / `stroke-opacity`, which in turn multiply
330
- * with any alpha carried by the colour itself. All three used to be ignored
331
- * here, so a ChartEx series that emits `opacity="0.55"` rasterised fully opaque
332
- * on Node while the browser canvas honoured it — the same `Chart.toPNG` call
333
- * produced visibly different images per platform.
334
- */
335
- function rasterPaint(color, attrs, channel) {
336
- const parsed = parseCssColor(color);
337
- if (!parsed) {
338
- return undefined;
339
- }
340
- let alpha = parsed.a;
341
- for (const name of [channel, "opacity"]) {
342
- const raw = attrs?.[name];
343
- if (raw === undefined) {
344
- continue;
345
- }
346
- const value = Number.parseFloat(raw);
347
- if (Number.isFinite(value)) {
348
- alpha *= value < 0 ? 0 : value > 1 ? 1 : value;
349
- }
350
- }
351
- return [
352
- Math.round(parsed.r * 255),
353
- Math.round(parsed.g * 255),
354
- Math.round(parsed.b * 255),
355
- Math.round(Math.max(0, Math.min(1, alpha)) * 255)
356
- ];
357
- }
358
- /**
359
- * Resolve a paint plus its opacity attributes into a single `#RRGGBBAA` token.
360
- *
361
- * The canvas primitives take a colour *string*, so folding the opacity into the
362
- * token keeps their signatures untouched while still compositing correctly —
363
- * `setPixel` already does source-over blending on the alpha channel.
364
- */
365
- function paintToken(color, attrs, channel) {
366
- const rgba = rasterPaint(color, attrs, channel);
367
- if (!rgba) {
368
- return undefined;
369
- }
370
- const hex = (value) => value.toString(16).padStart(2, "0");
371
- return `#${hex(rgba[0])}${hex(rgba[1])}${hex(rgba[2])}${hex(rgba[3])}`;
372
- }
373
- function renderSvgToBasicPng(svg, width, height, scale, dpi) {
374
- const outputWidth = Math.max(1, Math.round(width * scale));
375
- const outputHeight = Math.max(1, Math.round(height * scale));
376
- const canvas = new BasicRasterCanvas(outputWidth, outputHeight);
377
- const tagRe = /<(rect|line|circle|polyline|polygon|path)\b[^>]*>|<text\b[^>]*>[\s\S]*?<\/text>/g;
378
- let match;
379
- while ((match = tagRe.exec(svg)) !== null) {
380
- const tag = match[0];
381
- const name = tag.startsWith("<text") ? "text" : match[1];
382
- const attrs = parseSvgAttributes(tag);
383
- // Resolve paint once per element so `opacity` / `fill-opacity` /
384
- // `stroke-opacity` reach the canvas. They used to be dropped entirely here.
385
- const fillPaint = paintToken(attrs.fill, attrs, "fill-opacity");
386
- const strokePaint = paintToken(attrs.stroke, attrs, "stroke-opacity");
387
- // `stroke-dasharray` is emitted by the waterfall connector, the box-whisker
388
- // mean line and classic dashed trendlines; it was previously dropped, so
389
- // those strokes rasterised solid.
390
- const dash = parseStrokeDashArray(attrs["stroke-dasharray"], scale);
391
- if (name === "rect") {
392
- const x = numAttr(attrs, "x") * scale;
393
- const y = numAttr(attrs, "y") * scale;
394
- const rectWidth = numAttr(attrs, "width", 0, width) * scale;
395
- const rectHeight = numAttr(attrs, "height", 0, height) * scale;
396
- const strokeWidth = numAttr(attrs, "stroke-width", 1) * scale;
397
- const radius = numAttr(attrs, "rx", numAttr(attrs, "ry", 0)) * scale;
398
- if (fillPaint !== undefined) {
399
- if (radius > 0) {
400
- canvas.fillRoundRect(x, y, rectWidth, rectHeight, radius, fillPaint);
401
- }
402
- else {
403
- canvas.fillRect(x, y, rectWidth, rectHeight, fillPaint);
404
- }
405
- }
406
- if (strokePaint !== undefined && strokeWidth > 0) {
407
- canvas.strokeRect(x, y, rectWidth, rectHeight, strokePaint, strokeWidth);
408
- }
409
- }
410
- else if (name === "line") {
411
- const x1 = numAttr(attrs, "x1") * scale;
412
- const y1 = numAttr(attrs, "y1") * scale;
413
- const x2 = numAttr(attrs, "x2") * scale;
414
- const y2 = numAttr(attrs, "y2") * scale;
415
- const lineWidth = numAttr(attrs, "stroke-width", 1) * scale;
416
- if (dash) {
417
- canvas.drawPolyline([
418
- { x: x1, y: y1 },
419
- { x: x2, y: y2 }
420
- ], strokePaint, lineWidth, dash);
421
- }
422
- else {
423
- canvas.drawLine(x1, y1, x2, y2, strokePaint, lineWidth);
424
- }
425
- }
426
- else if (name === "circle") {
427
- canvas.fillCircle(numAttr(attrs, "cx") * scale, numAttr(attrs, "cy") * scale, numAttr(attrs, "r") * scale, fillPaint);
428
- canvas.strokeCircle(numAttr(attrs, "cx") * scale, numAttr(attrs, "cy") * scale, numAttr(attrs, "r") * scale, strokePaint, numAttr(attrs, "stroke-width", 1) * scale);
429
- }
430
- else if (name === "polyline") {
431
- canvas.drawPolyline(scaledSvgPoints(attrs.points, scale), strokePaint, numAttr(attrs, "stroke-width", 1) * scale, dash);
432
- }
433
- else if (name === "polygon") {
434
- const points = scaledSvgPoints(attrs.points, scale);
435
- canvas.fillPolygon(points, fillPaint);
436
- if (points.length > 0) {
437
- canvas.drawPolyline([...points, points[0]], strokePaint, numAttr(attrs, "stroke-width", 1) * scale, dash);
438
- }
439
- }
440
- else if (name === "path") {
441
- // Pie/doughnut slices emit `data-sector="cx,cy,outerR,innerR,startAngle,endAngle"`
442
- // for pixel-perfect circular rendering instead of polygon approximation.
443
- const sectorData = attrs["data-sector"];
444
- if (sectorData) {
445
- const parts = sectorData.split(",").map(Number);
446
- if (parts.length === 6 && parts.every(Number.isFinite)) {
447
- const [cx, cy, outerR, innerR, startAngle, endAngle] = parts;
448
- canvas.fillSector(cx * scale, cy * scale, outerR * scale, innerR * scale, startAngle, endAngle, fillPaint);
449
- }
450
- }
451
- else {
452
- const points = parsePathPoints(attrs.d, scale);
453
- canvas.fillPolygon(points, fillPaint);
454
- if (points.length > 0) {
455
- canvas.drawPolyline([...points, points[0]], strokePaint, numAttr(attrs, "stroke-width", 1) * scale, dash);
456
- }
457
- }
458
- }
459
- else if (name === "text") {
460
- const rotation = parseSvgRotate(attrs.transform);
461
- const fontSize = numAttr(attrs, "font-size", 10);
462
- const baseX = numAttr(attrs, "x");
463
- const baseY = numAttr(attrs, "y");
464
- const inner = tag.match(/<text\b[^>]*>([\s\S]*?)<\/text>/)?.[1] ?? "";
465
- // A multi-paragraph title is emitted as `<tspan dy>` children. Stripping
466
- // the markup and drawing the result as one string ran the paragraphs
467
- // together ("QuarterlyRevenue"), because stripping markup removes the
468
- // tags but nothing put the line break back. Walk the tspans instead so
469
- // the raster matches what an SVG viewer shows.
470
- for (const line of parseSvgTextRuns(inner, fontSize)) {
471
- canvas.drawText((line.x ?? baseX) * scale, (baseY + line.dy) * scale, line.text, fontSize * scale, fillPaint, attrs["text-anchor"], rotation
472
- ? {
473
- angle: rotation.angle,
474
- originX: rotation.cx * scale,
475
- originY: rotation.cy * scale
476
- }
477
- : undefined);
478
- }
479
- }
480
- }
481
- return encodePng(canvas.data, outputWidth, outputHeight, { dpi });
482
- }
483
- function numAttr(attrs, name, fallback = 0, percentBase) {
484
- const value = attrs[name];
485
- if (value === undefined) {
486
- return fallback;
487
- }
488
- if (value.endsWith("%")) {
489
- const percent = Number.parseFloat(value);
490
- return Number.isFinite(percent) && percentBase !== undefined
491
- ? (percent / 100) * percentBase
492
- : fallback;
493
- }
494
- const parsed = Number.parseFloat(value);
495
- return Number.isFinite(parsed) ? parsed : fallback;
496
- }
497
- function parsePathPoints(input, scale = 1) {
498
- if (!input) {
499
- return [];
500
- }
501
- // Accept both upper and lower-case SVG commands; we lowercase via
502
- // `toUpperCase` below, treating relative and absolute forms the
503
- // same (caller supplies paths the renderer itself emits, which are
504
- // always absolute — the tolerance is for third-party author-shape
505
- // round-trips).
506
- const tokens = input.match(/[MLAZ]|-?\d+(?:\.\d+)?/gi) ?? [];
507
- const points = [];
508
- let i = 0;
509
- let current;
510
- let start;
511
- while (i < tokens.length) {
512
- const command = tokens[i++].toUpperCase();
513
- if (command === "M" || command === "L") {
514
- const point = readPathPoint(tokens, i);
515
- i += 2;
516
- if (!point) {
517
- // Malformed M/L — skip its two parameter tokens (already
518
- // advanced) and continue walking the rest of the path. The
519
- // previous `break` abandoned every subsequent command,
520
- // silently dropping half a path for a single bad coordinate.
521
- continue;
522
- }
523
- point.x *= scale;
524
- point.y *= scale;
525
- current = point;
526
- start ?? (start = point);
527
- points.push(point);
528
- }
529
- else if (command === "A") {
530
- // An `A` command consumes seven parameter tokens regardless of
531
- // whether the arc is successfully decoded. Advance the cursor
532
- // up front so a malformed arc doesn't strand the parser on its
533
- // own parameter list.
534
- if (!current) {
535
- i += 7;
536
- continue;
537
- }
538
- const arc = readPathArc(tokens, i, current, scale);
539
- i += 7;
540
- if (!arc) {
541
- continue;
542
- }
543
- for (const p of arc.points) {
544
- points.push(p);
545
- }
546
- current = arc.end;
547
- }
548
- else if (command === "Z") {
549
- if (start) {
550
- points.push(start);
551
- }
552
- }
553
- else {
554
- // Unrecognised command — try to reinterpret the token as an
555
- // implicit coordinate pair (SVG allows repeated coordinates
556
- // after an `M` / `L`, e.g. `M 1 2 3 4` draws an implicit
557
- // lineTo from (1,2) to (3,4)).
558
- const numeric = Number.parseFloat(command);
559
- if (!Number.isFinite(numeric) || i >= tokens.length) {
560
- // Genuinely unknown token — skip and keep walking instead
561
- // of abandoning the rest of the path.
562
- continue;
563
- }
564
- const y = Number.parseFloat(tokens[i]);
565
- if (!Number.isFinite(y)) {
566
- continue;
567
- }
568
- i++;
569
- current = { x: numeric * scale, y: y * scale };
570
- start ?? (start = current);
571
- points.push(current);
572
- }
573
- }
574
- return points;
575
- }
576
- function readPathPoint(tokens, index) {
577
- const x = Number.parseFloat(tokens[index]);
578
- const y = Number.parseFloat(tokens[index + 1]);
579
- return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
580
- }
581
- function readPathArc(tokens, index, from, scale) {
582
- const rx = Number.parseFloat(tokens[index]);
583
- const ry = Number.parseFloat(tokens[index + 1]);
584
- const largeArc = Number.parseFloat(tokens[index + 3]) === 1;
585
- const sweep = Number.parseFloat(tokens[index + 4]) === 1;
586
- const end = readPathPoint(tokens, index + 5);
587
- if (!Number.isFinite(rx) || !Number.isFinite(ry) || !end) {
588
- return undefined;
589
- }
590
- end.x *= scale;
591
- end.y *= scale;
592
- return {
593
- end,
594
- points: approximateArcPoints(from, end, Math.max(rx, ry) * scale, largeArc, sweep)
595
- };
596
- }
597
- function approximateArcPoints(start, end, radius, largeArc, sweep) {
598
- const dx = end.x - start.x;
599
- const dy = end.y - start.y;
600
- const chord = Math.hypot(dx, dy);
601
- if (!Number.isFinite(radius) || radius <= 0 || chord === 0 || chord > radius * 2) {
602
- return [end];
603
- }
604
- const mx = (start.x + end.x) / 2;
605
- const my = (start.y + end.y) / 2;
606
- const halfChord = chord / 2;
607
- const h = Math.sqrt(Math.max(0, radius * radius - halfChord * halfChord));
608
- const nx = -dy / chord;
609
- const ny = dx / chord;
610
- // When the chord equals the diameter (`h ≈ 0` for a half-circle)
611
- // both candidate centres coincide at the midpoint and produce
612
- // `|delta| ≈ π`. The strict inequality `|delta| > π === largeArc`
613
- // treats both as "not large", so the selected centre was identical
614
- // regardless of `largeArc`, making a semicircle traced the wrong
615
- // way round for `largeArc=true`. Use the SVG endpoint→centre
616
- // parametrisation for this degenerate case and fall back to the
617
- // chord-bisector approach otherwise.
618
- const candidates = [
619
- { x: mx + nx * h, y: my + ny * h },
620
- { x: mx - nx * h, y: my - ny * h }
621
- ];
622
- // Prefer the candidate whose resulting arc length matches
623
- // `largeArc`. Use `>=` so the exact-π boundary sorts with
624
- // `largeArc=true` instead of `false`; combined with the
625
- // `sweep` tie-breaker below, this routes a true semicircle to the
626
- // candidate whose winding direction matches `sweep`.
627
- let selected = candidates[0];
628
- let bestScore = -Infinity;
629
- for (const center of candidates) {
630
- const delta = arcDelta(center, start, end, sweep);
631
- const isLarge = Math.abs(delta) >= Math.PI - 1e-9;
632
- // Score 2 when this candidate's large-arc classification agrees
633
- // with the requested `largeArc`; score 1 as a half-match when we
634
- // are exactly on the boundary (the `Math.abs(delta) - π` is near
635
- // zero); score 0 otherwise. Picking the highest score gives us
636
- // the disambiguation we need for semicircles without breaking
637
- // non-boundary cases.
638
- const score = isLarge === largeArc ? 2 : 1 - Math.abs(Math.abs(delta) - Math.PI);
639
- if (score > bestScore) {
640
- bestScore = score;
641
- selected = center;
642
- }
643
- }
644
- const startAngle = Math.atan2(start.y - selected.y, start.x - selected.x);
645
- const delta = arcDelta(selected, start, end, sweep);
646
- const steps = clampInt(Math.ceil((Math.abs(delta) * radius) / 3), 12, 180);
647
- const points = [];
648
- for (let i = 1; i <= steps; i++) {
649
- const angle = startAngle + (delta * i) / steps;
650
- points.push({
651
- x: selected.x + Math.cos(angle) * radius,
652
- y: selected.y + Math.sin(angle) * radius
653
- });
654
- }
655
- return points;
656
- }
657
- function arcDelta(center, start, end, sweep) {
658
- const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
659
- const endAngle = Math.atan2(end.y - center.y, end.x - center.x);
660
- let delta = endAngle - startAngle;
661
- if (sweep && delta < 0) {
662
- delta += Math.PI * 2;
663
- }
664
- else if (!sweep && delta > 0) {
665
- delta -= Math.PI * 2;
666
- }
667
- return delta;
668
- }
669
- function clampInt(value, min, max) {
670
- return Math.max(min, Math.min(max, value));
671
- }
672
299
  function normalizePngScale(scale) {
673
300
  if (scale === undefined) {
674
301
  return 1;