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 +17 -14
- package/README_zh.md +20 -16
- package/dist/esm/modules/archive/zip/zip-archive.js +37 -7
- package/dist/esm/modules/excel/chart/chart-handle.js +4 -4
- package/dist/esm/modules/excel/chart/render/chart-renderer.js +6 -379
- package/dist/esm/modules/excel/chart/shared/shape-properties.js +32 -41
- package/dist/esm/modules/excel/core/workbook.browser.js +10 -4
- package/dist/esm/modules/excel/utils/guid.js +7 -29
- package/dist/esm/modules/stream/core/utils.js +1 -1
- package/dist/esm/modules/stream/node/factories.js +4 -2
- package/dist/esm/modules/word/core/internal-utils.js +8 -14
- package/dist/iife/documonster.archive.iife.min.js +4 -4
- package/dist/iife/documonster.csv.iife.min.js +8 -8
- package/dist/iife/documonster.draw.iife.min.js +2 -2
- package/dist/iife/documonster.excel.iife.min.js +46 -46
- package/dist/iife/documonster.formula.iife.min.js +3 -3
- package/dist/iife/documonster.markdown.iife.min.js +2 -2
- package/dist/iife/documonster.mermaid.iife.min.js +3 -3
- package/dist/iife/documonster.pdf.iife.min.js +28 -28
- package/dist/iife/documonster.stream.iife.min.js +2 -2
- package/dist/iife/documonster.word.iife.min.js +60 -60
- package/dist/iife/documonster.xml.iife.min.js +4 -4
- package/dist/types/modules/archive/zip/zip-archive.d.ts +22 -0
- package/dist/types/modules/excel/utils/guid.d.ts +0 -14
- package/dist/types/modules/word/core/internal-utils.d.ts +6 -0
- package/package.json +10 -10
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
|
|
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.
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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.
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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;
|